10.0.6 went out with 13 files and no dist/, so every import of it failed: the
repo gitignores **/dist and the package named no files, which left the packed
contents up to whichever tool packed it. A files field settles that. types also
pointed at dist/index.d.ts, which tsc never emits — the declarations are under
dist/esm — so exports now names the types beside the code it describes.
10.0.7 is published with all 61 files and installs clean; 10.0.6 is deprecated.
Co-authored-by: Hanzo Dev <[email protected]>
Ranges drift once nothing moves them: a caret that has not been rewritten
since it was written pins whatever was current that day, and an exact pin
cannot even take a patch. Both are updated here.
One line per package, so a surface reads the same components, the same
tokens and the same face as every other one.
luxd serves chain routes under /v1/chain, and the node, cli and sdk releases
already ship it. The deployed gateways still answer the old prefix, so remote
reads break until they are redeployed; the estate moves together rather than
sitting half converted.
Lines that record an observation - a probe, a status code, a past release -
keep the prefix they were written against, as do URLs on hosts that are not
ours. Avalanche endpoints move to /ext/bc, which is the path that host serves.
One family. Font stacks name Zen and fall back to the native faces, tokens
name the role rather than a face, and the packages that used to supply a
face now point at @hanzo/font.
Self-hosted @font-face blocks are removed rather than renamed: rewriting the
family in one would leave a src pointing at a file this repo no longer ships.
@hanzo/font carries the binaries and declares the faces, so the local copies
go with the declaration that used them.
Comments naming a face we no longer use are deleted, not reworded.
The rename to /v1/chain is real in luxd source, but the deployed fleet has not
taken it yet: probed just now, api.lux.network, api.lux-test.network,
api.zoo.network and api.hanzo.network each answer 200 on /v1/bc/C/rpc and 404 on
/v1/chain/C/rpc. Composing /v1/chain against those hosts reaches nothing, which
is the failure the rename set out to prevent.
The move belongs with the node redeploy, not ahead of it.
luxd serves chain routes under /v1/chain; the /v1/bc prefix it replaced is
gone, so anything composing it reaches nothing. Lines that record an
observation - a probe, a status code, a past release - keep the prefix they
were written against, as do URLs on hosts that are not ours.
luxd serves chain APIs under one segment from constants.ChainAliasPrefix
(constants v1.6.4, node v1.36.181):
/v1/bc/<alias>/... -> /v1/chain/<alias>/...
/ext/bc/<alias>/... -> /v1/chain/<alias>/...
Observations keep their original addresses: a dated note or a recorded status
code is a fact about when it was written, not an instruction to follow.
The dependency was pinned "^0.1.0", which under semver can never reach
0.2.x. So the bridge — a surface listed as a working consumer of the
shared connector — was carrying the bug 0.2.0 fixed.
Measured in the installed tree: 0.1.0's dist/connectors.js opens with
SEVEN STATIC IMPORTS, so every page load eagerly pulled sats-connect,
@tonconnect/sdk, @crossmarkio/sdk and @polkadot/util-crypto whether or
not anyone touched those chains. That is the eager-import shape that
blanked the exchange SPA. 0.2.1's connectors.js has zero: a chain's
wallet library arrives when that chain is asked for.
getConnector became async across that boundary, so resolveConnector is
async and its one caller awaits. Both call sites were already inside
async functions, which is why this is three lines rather than a refactor.
Cardano now maps to a connector instead of null. The comment beside it
said connect was unavailable — true when written, and 0.2.1 ships
./cardano/connect (CIP-30) with ./cardano/verify (CIP-8 COSE_Sign1).
Same day as the ADA custody address, from the other side: the register
can now name a Cardano deposit address, and a holder can now sign in
with the wallet that holds it.
EVM and Lux still return null and go through wagmi deliberately.
@luxwallet/connect has no transaction surface at all — no
sendTransaction, no chain switching, verified by grep — so it is a
connect-and-identity package, not a wagmi replacement. The comments say
that now instead of implying a gap.
luxd registers /v1/ and nothing else; api.lux.network, api.zoo.ngo,
api.hanzo.network, api.pars.network and api.osage.network all answer 200 on
/v1/bc/C/rpc and 404 on /ext/bc/C/rpc. Every /ext/ path aimed at one of our
hosts was a request that could only fail.
/ext/ is Avalanche's prefix. Endpoints on api.avax.network keep it — they are
someone else's convention and a real bridge corridor depends on them.
Co-authored-by: Hanzo Dev <[email protected]>
The header named the signing protocol — `MPC · CGGMP21` — and a transfer row
badged it again. Neither is something a visitor can do anything with, and the
header is the surface everybody reads. The copy now says what a person needs:
which network, that a deposit address is involved, that no single party can
release the funds. The cluster still runs whatever BRIDGE_MPC_PROTOCOL selects.
The pickers could not be operated from a keyboard. Every key was answered on
the menu and nothing ever focused the menu, so opening with Enter left the
arrows dead. They are answered on the trigger now, which holds focus for the
whole interaction, with aria-activedescendant naming the row the cursor is on.
Each picker takes a label bound to it or a name read with it, and the prop type
admits no third shape.
The brand primary is #000000 and the SDK painted its selected row, its live
phase dot, the refuel toggle and the header's only call to action with it —
black on a black page. Marking a thing is the neutral scale's job. The accent
stays as the published brand hook; nothing inside the SDK reads it.
The rest is the page reading like the exchange it sits beside: a flat ground,
panels separated by an edge rather than a shadow, one type scale, field names
in one case, and hints raised out of the ink you have to lean in for.
@luxfi/bridge 1.0.9.
The dependency was pinned "^0.1.0", which under semver can never reach
0.2.x. So the bridge — a surface listed as a working consumer of the
shared connector — was carrying the bug 0.2.0 fixed.
Measured in the installed tree: 0.1.0's dist/connectors.js opens with
SEVEN STATIC IMPORTS, so every page load eagerly pulled sats-connect,
@tonconnect/sdk, @crossmarkio/sdk and @polkadot/util-crypto whether or
not anyone touched those chains. That is the eager-import shape that
blanked the exchange SPA. 0.2.1's connectors.js has zero: a chain's
wallet library arrives when that chain is asked for.
getConnector became async across that boundary, so resolveConnector is
async and its one caller awaits. Both call sites were already inside
async functions, which is why this is three lines rather than a refactor.
Cardano now maps to a connector instead of null. The comment beside it
said connect was unavailable — true when written, and 0.2.1 ships
./cardano/connect (CIP-30) with ./cardano/verify (CIP-8 COSE_Sign1).
Same day as the ADA custody address, from the other side: the register
can now name a Cardano deposit address, and a holder can now sign in
with the wallet that holds it.
EVM and Lux still return null and go through wagmi deliberately.
@luxwallet/connect has no transaction surface at all — no
sendTransaction, no chain switching, verified by grep — so it is a
connect-and-identity package, not a wagmi replacement. The comments say
that now instead of implying a gap.
luxd registers /v1/ and nothing else; api.lux.network, api.zoo.ngo,
api.hanzo.network, api.pars.network and api.osage.network all answer 200 on
/v1/bc/C/rpc and 404 on /ext/bc/C/rpc. Every /ext/ path aimed at one of our
hosts was a request that could only fail.
/ext/ is Avalanche's prefix. Endpoints on api.avax.network keep it — they are
someone else's convention and a real bridge corridor depends on them.
Co-authored-by: Hanzo Dev <[email protected]>
The header named the signing protocol — `MPC · CGGMP21` — and a transfer row
badged it again. Neither is something a visitor can do anything with, and the
header is the surface everybody reads. The copy now says what a person needs:
which network, that a deposit address is involved, that no single party can
release the funds. The cluster still runs whatever BRIDGE_MPC_PROTOCOL selects.
The pickers could not be operated from a keyboard. Every key was answered on
the menu and nothing ever focused the menu, so opening with Enter left the
arrows dead. They are answered on the trigger now, which holds focus for the
whole interaction, with aria-activedescendant naming the row the cursor is on.
Each picker takes a label bound to it or a name read with it, and the prop type
admits no third shape.
The brand primary is #000000 and the SDK painted its selected row, its live
phase dot, the refuel toggle and the header's only call to action with it —
black on a black page. Marking a thing is the neutral scale's job. The accent
stays as the published brand hook; nothing inside the SDK reads it.
The rest is the page reading like the exchange it sits beside: a flat ground,
panels separated by an edge rather than a shadow, one type scale, field names
in one case, and hints raised out of the ink you have to lean in for.
@luxfi/bridge 1.0.9.
Lux Bridge is core network infrastructure meant for OSS developers to
build on, so it moves from the proprietary Lux Ecosystem License
(patent-protected tier) to BSD 3-Clause — the same permissive license
the rest of the Lux core stack carries (cli, sdk, genesis). Also
unifies the seven per-package license fields (MIT/ISC/BSD mixed) onto
the one repository license. No vendored modified upstream, so no
separate NOTICE is owed; third-party deps keep their licenses through
the module graph.
RollupError: [plugin typescript] @rollup/plugin-typescript: Path of
Typescript compiler option 'outDir' must be located inside the same
directory as the Rollup 'file' option.
No tsconfig in the chain sets outDir, so the plugin defaults it to the
tsconfig's own directory — pkg/utila — and then rejects it, because it requires
outDir to sit inside the directory each Rollup `file` writes to. Both outputs go
to dist/, so one value satisfies both.
This fails identically on a laptop, so it is not a container problem: the
package has simply been unbuildable since the toolchain moved under it, and
nobody noticed because dist/ was built once long ago and has been sitting there
older than its own sources. It only surfaced now because app/server's image
needs to build it from a clean checkout.
Verified: dist/index.cjs.js exports createGrpcClient and
serviceAccountAuthStrategy, which are what domain/utila.ts and
domain/cosigners.ts import.
Co-authored-by: Hanzo Dev <[email protected]>
This is the UI that is actually deployed — the root Dockerfile builds app/bridge,
which mounts @luxfi/bridge, and lux-bridge, zoo-bridge and pars-bridge all run
that image. Its destination picker offers EVERY network for every source, so
choosing a pair the bridge cannot route is an ordinary thing to do, not an edge
case.
Until now such a pair returned 200 with a fabricated number. It now returns 400
and names the problem — "no bridge route between LBTC and ZETH" — and that
sentence is the whole diagnosis. The hook was throwing it away and rendering the
status code, which reads as the site being broken rather than as the route not
existing.
Falls back to the status when the body carries no message, so nothing ever
renders "undefined" or "[object Object]".
Co-authored-by: Hanzo Dev <[email protected]>
Small wrapper around Select, but two real bugs live in the mapping
layer if it regresses: the family->display-name table (with a
defensive fallback to the raw uppercased family for anything not in
the table -- covers a future ChainFamily addition landing before its
label does), and the SelectOption->Chain reverse lookup on commit,
where a wrong id match would silently call onChange with the wrong
chain entirely.
Select's own header comment documents a real interaction contract --
click-outside closes, Escape closes, arrow keys move the highlight,
Enter/Space commits -- none of which had a dedicated test.
AssetInput.test.tsx exercises Select only incidentally (open + click
one option); this covers the contract directly, on the component that
owns it.
20 tests: open/close via click and via ArrowDown/Enter/Space at the
trigger, click-outside dismissing without committing (vs. a click
inside the popover, which must NOT dismiss), Escape closing and
returning focus to the trigger, arrow-key highlight movement clamped
at both ends, Home/End jumping to the first/last option, Enter and
Space both committing the HIGHLIGHTED option (not just whatever was
originally selected -- these are different indices once you've
navigated), hover updating the highlight, aria-selected reflecting the
current value, the secondary-label render, and the renderTrigger /
label props.
pkg/bridge test suite 130 -> 150 tests, all passing; typecheck clean.
TransferStatus renders the live transfer feed -- phase banners, the
deposit-address panel, MPC session detail, layered-cosigner badges --
the surface a user watches while their swap is mid-flight. Zero test
coverage despite the conditional logic (which banner shows for
lastError vs refunding vs refunded vs a terminal failure) being exactly
the kind of thing that's easy to get subtly wrong in a way that only
surfaces as "why does this say refunding when it already refunded."
17 tests cover: empty state, chain-name resolution (including the
fallback to the raw chainId for an unregistered chain), the deposit
panel's visibility rule (depositAddress set AND phase not yet terminal)
plus its copy-to-clipboard button, the MPC session line (including the
"session aborted" special case), the two layered-cosigner badges
appearing independently per config, and all four phase-banner branches
mutually excluding each other correctly.
useNetworks and getConfig are mocked (react-query + a mountBridge-gated
singleton this component's own logic doesn't need exercised); Card
renders for real -- it's a 37-line style wrapper with no logic.
The .network host was retired 2026-07-27 and no longer resolves at all, so every
reference to it named an address that cannot answer. The brand host is .cloud,
matching kms.zoo.cloud and the rest of the white-label convention.
Left alone deliberately, because rewriting them would invert what they say: the
LLM.md line that RECORDS the retirement, the e2e spec that ASSERTS
kms.lux.network must not resolve, and the recorded applies + cluster backups,
which are faithful accounts of what was applied and are not config.
1.0.7 shipped `"@luxfi/threshold": "workspace:*"` in dependencies, so no
consumer outside this monorepo could install it — `npm publish` does not
rewrite the workspace protocol and `pnpm publish` does. npm versions are
immutable, so correcting it needs a bump.
@luxfi/threshold had never been published at ALL, which is why fixing the
specifier alone would not have been enough: the rewrite points at the
sibling's real version, and that version has to exist on the registry.
Published it first (1.0.0), then bridge, so the graph resolves.
Both verified before publishing — 0 workspace: refs, real dist (133 files
for bridge, 24 for threshold), and bridge's dependency rewritten to the
concrete "@luxfi/threshold": "1.0.0".
The fail-closed guard added in d7ec4757 ran on this publish and passed under
pnpm, which is what it is for.
Co-authored-by: Hanzo Dev <[email protected]>
@luxfi/[email protected] is broken on npm right now: its published manifest
carries `"@luxfi/threshold": "workspace:*"`, a specifier that only resolves
inside this monorepo. Every external consumer fails at install.
Cause: `workspace:*` is rewritten to a real version by `pnpm publish` and
NOT by `npm publish`. This package had no publish script, so it was
published by hand with npm and shipped the specifier verbatim. The source
still declares it, so the next hand-publish breaks again the same way.
The defect is invisible in CI and inside the monorepo — pnpm resolves
workspace:* fine locally, and only an external consumer ever sees it. So
the check belongs at publish time and has to fail closed: prepublishOnly
now refuses unless the publishing client is pnpm, including when the user
agent is absent.
Verified: refuses under npm, refuses with no user agent, passes under pnpm.
Not done here: republishing 1.0.7. That is outward-facing and affects
anyone already pinned, so it needs an owner.
Co-authored-by: Hanzo Dev <[email protected]>
The first pass left three classes of config that BOTH tsc 5.9 and tsc 7
reject. Each was proven against both compilers before changing:
TS5090 A `paths` target must be relative once `baseUrl` is gone. The
first pass skipped the "./" prefix wherever baseUrl pointed at
the config's own directory, reasoning it was semantically
equivalent. It is not — without baseUrl a non-relative target
is rejected outright, by 5.9 as well as 7.
TS5110 `moduleResolution: node16` requires `module: node16`. The first
pass mapped commonjs projects to node16 resolution alone, which
BROKE those configs for the current toolchain. Both are now set.
TS5102 `downlevelIteration` is also removed in TS7; it was missing from
the dead-flag list.
Verified: repos that tsc 7 previously refused (base-studio, js-sdk, kv-js)
now report zero config errors on tsc 7 AND tsc 5.9.
Co-authored-by: Hanzo Dev <[email protected]>
luxd serves exactly one prefix, /v1 (node/server/http/server.go:33), and
has never served /ext. createBridgeRPCClient's two defaults are the SDK's
whole contract for a caller who passes only nodeUrl — both pointed at a
404, and a caller had no way to see it from the outside.
Two Zoo endpoints were also aimed at the wrong network entirely:
api.lux.network/ext/bc/zoo/rpc and api.zoo.network/ext/bc/Z/rpc. Zoo is
sovereign on api.zoo.network (200200) and api.zoo-test.network (200201),
both confirmed serving /v1/bc/C/rpc; /v1/bc/Z/rpc and /v1/bc/zoo/rpc are
404. Env is the hostname, never a path segment.
Patch-bumps @luxfi/bridge 1.0.6 -> 1.0.7 so the fixed defaults ship. Note
1.0.6 was never actually on the registry — `npm view @luxfi/bridge` is a
hard 404 with no versions, while @luxfi/ui resolves fine from the same
scope, so this is a first publish rather than a re-publish.
Tests pin both defaults and assert the overrides still win. They fail on
/ext and pass on /v1. Full suite: 11 files, 94 tests.
Co-authored-by: Hanzo Dev <[email protected]>
TypeScript 7 is the native Go compiler and removes `baseUrl` and
`moduleResolution: node|node10`. Both appear here, so `tsc` from TS7
refuses the config outright (TS5102 / TS5108) and cannot typecheck.
`paths` targets resolve relative to `baseUrl` when it is set and relative
to the tsconfig file otherwise. Every `baseUrl` folded here already
pointed at the config's own directory, so dropping it moves nothing and
the targets are left byte-identical. Where a baseUrl pointed elsewhere,
each affected target was rewritten as join(baseUrl, target).
`moduleResolution` was chosen from the declared `module`: commonjs ->
node16, esnext/preserve -> bundler. Configs whose `module` is unset or
exotic were left alone rather than guessed at.
The result is accepted by BOTH toolchains, so nothing has to upgrade
TypeScript in lockstep. Verified on hanzo/chat packages/api: tsc 5.9
779 -> 778 errors (no regression), and tsc 7.0.2 now runs the project
in 2s where it previously refused the config.
Co-authored-by: Hanzo Dev <[email protected]>
Two white-label defects surfaced by the universe branded e2e suite:
1. HEAD <meta name="description"> stayed "Cross-chain bridge for the Lux
Network." on Zoo/Pars surfaces (title/favicon/logo were already
white-labeled) — a foreign brand in a non-Lux head. applyBrandMetadata
now rewrites description + og:description + twitter:description from the
brand block (creating them if absent). bridge.config.ts derives the
description from the already-white-labeled brand name (single source;
BRIDGE_DESCRIPTION overrides), so a non-Lux surface never leaks "Lux".
Extended the __ENV.js contract (docker-entrypoint.sh + public/__ENV.js).
2. Body font fell back to Times serif — the intended face never loaded and
.bridge-root only styles the mount subtree, leaving <body> at the UA
default. Ship Basel Grotesk (the shared BRAND_TOKENS face) as a bundled
webfont, prepend it to --bridge-font, and apply the font to html/body so
the page owner inherits the brand sans stack.
Tests: pkg/bridge 91/91 pass (config.test.ts adds DOM coverage for the
description rewrite + the zero-foreign-leak invariant).
Co-authored-by: Hanzo Dev <[email protected]>
The bridge image build (root Dockerfile) compiles pkg/threshold + pkg/core
to dist/ before the Vite app build. Under the runner's TypeScript 7 the
deprecated moduleResolution:node (node10) is a hard error (TS5107), so
'Build & push' failed at 'pnpm -C pkg/threshold build'. Add the prescribed
ignoreDeprecations:6.0 escape (inherited by the .cjs.json configs via
extends) — no emit change. Unblocks ghcr.io/luxfi/bridge:main.
Co-authored-by: Hanzo Dev <[email protected]>
Squash of the local main lineage onto the canonical origin/main. Net tree only
(62 files): stale docs pages removed, tenant config + tests updated, minor Go
fixes, refreshed opnet-watcher build. No third-party white-label branding in
code or history.
Co-authored-by: Hanzo Dev <[email protected]>
Connect a wallet on every bridge ecosystem, not just EVM. wagmi stays the
EVM/Lux deposit signer; @luxwallet/connect adds the connect/identity layer
for Solana, Bitcoin, TON, XRP and Polkadot — chains wagmi cannot reach.
- useLuxWallet: non-EVM connect via getConnector(chain).connect() + SIWx login
- useBridgeWallet: composes wagmi + luxwallet, routes by chain family
- WalletConnect: family-aware — EVM picker vs non-EVM single-tap connect
- chains/registry: DEFAULT_CHAINS derived from @luxwallet/chains (one source
of truth for the selector); covers all six connectable ecosystems
- wallet-family: the one place mapping bridge family -> wallet stack
- SwapForm: source/dest address resolved across both stacks
- TON manifest for the tenant app; deps via file: (TODO -> npm)
Additive + non-breaking: existing EVM flow unchanged. No WalletConnect
projectId on the non-EVM path. MIT/Apache/ISC deps only, zero GPL.
87 tests pass (8 new); tsc clean for SDK + tenant app.
- Remove / / doc-comment mentions
- Remove 'liquid' from SupportedChains allowlist (-specific
chain identifier per CLAUDE.md, not a generic chain family)
- Update tenant config example strings and tests accordingly
- Trim BRAND-AUDIT.md to drop specific references while
keeping the foreign-brand rule
The tenant.Config primitive is the public contract that downstream
shim repos (liquidity/bridge, hanzo/bridge-shim, zoo/bridge-shim,
pars/bridge-shim) consume to validate their own tenant.yaml. Keeping
it under internal/ blocked that — shims couldn't import it.
This patch moves the package one directory level (internal/tenant →
pkg/tenant), updates the three call-sites in cmd/bridge, and changes
nothing else. All tests still pass.
Tests:
- ./pkg/tenant 26 tests, PASS
- ./cmd/bridge full suite PASS
User asked to confirm the rebuild is up to date with @hanzo/gui framework
and to run both Vite tenant + Next.js explorer locally to verify. Playwright
caught a regression: bumping @hanzo/gui to 7.2.2 (latest published) made
the bridge tenant render a blank page with 'createGui is not a function'.
Root cause: @hanzo/gui's published dist artefacts use the upstream Tamagui
symbol names verbatim (createHanzogui / HanzoguiProvider / getDefault-
HanzoguiConfig). The consumer-side scripts/patch-hanzogui-exports.cjs
ONLY runs when dist/ is empty — which was true for 7.0.0 (broken
publish) but is false for 7.2.x (real dist). So 7.2.2 exposes the
upstream Hanzogui* names and the bridge SDK code (which calls the
renamed createGui / GuiProvider / getDefaultGuiConfig) finds undefined
exports.
Fix: SDK accepts BOTH naming surfaces. pkg/bridge/src/mount.ts and
Bridge.tsx try the renamed symbol first (works on 7.0.0 + patch), fall
back to the upstream Hanzogui* name (works on 7.2.x dist). Throw a
clear error if neither is exported.
Playwright in-browser verification after the fix:
Title: 'LUX Bridge'
#bridge-root: 1 child rendered (Tamagui tree)
Console errors: 0 (only theme-token warnings, harmless)
UI rendering: header (LUX Bridge + Connect Wallet) ✓
swap form (FROM Lux Network → TO Ethereum) ✓
TRANSFERS panel ✓
footer (Network: mainnet · Docs · [email protected]) ✓
Also bumped:
- @hanzo/gui ^7.0.0 → ^7.2.2 (latest published)
- @luxfi/bridge 1.0.4 → 1.0.5
Plus an incidental fix to app/explorer/components/SwapHistory/StatusIcons.tsx
(pre-existing operator-precedence bug: 'a' + b ?? '' had its ?? branch
unreachable; wrapped className ?? '' in parens). Found while attempting
to run the legacy explorer locally; we're deprecating that whole app
per #387 but the type error blocked next dev / next build cold.
Tests:
- SDK pkg/bridge: 58/58 passing
- SDK typecheck: clean
- app/server: 77/77 passing (unchanged)
User feedback was that my first Cloud HSM pass had three architectural
weaknesses: (1) per-provider top-level kinds vs a single cloud_hsm
discriminator, (2) SA-JSON-in-KMS auth pattern, (3) missing first-class
algorithm + structured error codes. Plus the orthogonal point that for
bridge.lux.network specifically the right topology is m-chain (native
MPC) + optional f-chain (FHE attestation) and NO external cosigners.
This commit applies all of that.
SDK (@luxfi/bridge 1.0.4 → 1.0.5):
- BridgeCloudHsmConfig — single, uniform shape:
provider: 'gcp_kms' | 'aws_kms' | 'azure_key_vault' | 'vault_transit'
keyRef: <self-describing per-provider URI form>
algorithm: 'secp256k1_ecdsa_sha256' | 'ed25519' | 'rsa_pss_sha256'
identityHint?: <non-secret workload-identity scope hint>
Replaces the three separate gcpKms / awsKms / azureKv blocks from
the first pass. Tenants layer multiple HSMs via the cloudHsm array
on BridgeMPCConfig.
- BridgeFChainConfig — Lux Network native FHE attestation:
publicUrl: <f-chain cluster URL>
scheme?: 'ckks' | 'bgv' | 'bfv'
Lives alongside m-chain on the Lux primary network. Cosigns the
same txHash that m-chain signed via FHE-secured key material so
the message never crosses a plaintext threshold quorum. PQ-safe.
- Wire shape (bridge-api.ts CosignerIntent) collapses the three
cloud_hsm variants into one canonical record with the provider
sub-discriminator. Adds the fchain wire kind.
- useTransfers iterates cfg.mpc.cloudHsm[] + emits the optional
fchain intent.
Backend (app/server):
- CloudSigner interface matches user's exact spec:
readonly provider: CloudHsmProvider
sign(input: CloudSignInput): Promise<CloudSignResult>
where CloudSignInput / CloudSignResult carry the structured metadata
(keyRef, digest, algorithm, identityHint, requestId / signature,
publicKeyRef, provider, keyVersion, signedAt).
- CloudSignerError class with stable CloudSignerErrorCode union:
permission_denied | key_not_found | key_disabled
→ maps to CosignResult.status = 'rejected'
algorithm_mismatch | invalid_digest
→ maps to 'rejected' (terminal config errors)
rate_limited | network_error | provider_unavailable
→ maps to 'failed' (retryable infra errors)
statusForErrorCode() does the mapping; the dispatcher never has to
string-match on error messages again.
- Real GCP CKMS adapter:
✅ Workload-identity ONLY (no SA-JSON-in-KMS fallback). The
backend trusts the cloud's native identity layer.
✅ identityHint is non-secret context, not credentials.
✅ Algorithm allow-list — only secp256k1_ecdsa_sha256 wired today;
ed25519 / rsa_pss_sha256 reject early with algorithm_mismatch.
✅ Error mapping covers gRPC codes 5/7/8/9/14/16 + string
fallbacks. mapGcpError centralizes it.
- AWS KMS / Azure Key Vault / Vault Transit adapters are stubs that
return CloudSignerError 'provider_unavailable' with a clear marker.
Each stub has a full implementation sketch in comments so the next
dev (or me, next session) drops in @aws-sdk/client-kms / etc. and
wires through cleanly.
- f-chain (FHE attestation) stub — waiting on @luxfi/fchain workspace.
- Persistence: cloud_hsm rows persist as 'cloud_hsm:<provider>' in
CosignerStep.kind to avoid a Prisma migration. public_id = key_ref;
api_host = identity_hint; vault_id = algorithm. rowToIntent inverts
cleanly. First schema migration round will split into dedicated
columns; the compound kind is a stable round-trippable hack.
Tests: 77/77 passing.
pkg/bridge: 58/58 (unchanged)
app/server: 16 validator (now covers all 4 cloud providers + fchain)
+ 10 Fireblocks
+ 13 Utila
+ 7 persistence
+ 19 GCP KMS + Cloud HSM stubs + fchain
+ 12 new cloud-HSM validator cases
Lux Network topology (bridge.lux.network and all Lux-branded tenants):
- Primary: m-chain (native CGGMP21 / FROST threshold MPC)
- Optional: f-chain (FHE attestation, also native to Lux)
- NO external cosigners (Utila / Fireblocks / Cloud HSM) by default
- These cosigner blocks exist on BridgeMPCConfig for white-label
tenants whose policy requires institutional / cloud-HSM cosign.
User correction record:
> 'Use a provider sub-discriminator, not separate top-level kinds' ✓
> 'Do not fetch SA JSON from KMS at signing time' ✓
> 'Keep browser SDK config declarative only' ✓
> 'Separate SDK config from backend signer runtime config' ✓
> 'Normalize algorithms explicitly' ✓
> 'Shared interface should return structured metadata' ✓
> 'Preserve provider-specific error classification' ✓
> 'rely on just b-chain and m-chain ... f-chain for FHE' ✓ (fchain
config is optional + native; external cosigners are opt-in only)
User pointed out that @luxfi/brand should be used 'for all lux apps /
projects' — confirming the canonical brand sourcing principle. Audit
pass against the bridge stack found two leaks of Lux-specific identity
INTO the jurisdiction-neutral SDK:
1. `pkg/bridge/package.json` declared `@luxfi/brand` as a direct
dependency — but the SDK source never imports it. Dead dep and a
layering violation: Zoo / Hanzo / any white-label consumer of
`@luxfi/bridge` shouldn't pull Lux brand metadata into their bundle.
Dropped.
2. `pkg/bridge/src/app/lib/wagmi-config.ts` had a hardcoded SSR
fallback URL `https://bridge.lux.network` for the WalletConnect
metadata block. On client-side hydration this is overridden by
`window.location.origin`, but the first SSR-render frame was
leaking the Lux hostname into Zoo / Hanzo tenant builds.
Replaced with `deriveAppOrigin(cfg.apiHost)` — strips the leading
`api.` subdomain from the configured API host so the fallback is
tenant-derived. `api.bridge.lux.network` → `bridge.lux.network`,
`api.bridge.zoo.network` → `bridge.zoo.network`. Catch-all
fallback is `https://localhost` if the URL fails to parse.
3. `docs/LLM.md` gains a 'Canonical brand sourcing' subsection
documenting the principle:
- `@luxfi/brand` (~/work/lux/brand) is the ONLY source of Lux
brand identity for ALL Lux-ecosystem apps (bridge, exchange,
id, pay, etc.)
- SDK is jurisdiction-neutral; tenants supply brand at mount time
- App-specific endpoints (api.bridge.lux.network) are independent
of brand.json's appDomain (lux.exchange)
- Other Lux apps follow the same pattern; any hardcoded brand
string in a Lux app is a leak — file as a follow-up issue.
SDK 1.0.3 -> 1.0.4. Additive + neutralization only — no functional
behavior change for Lux consumers; Zoo / Hanzo SSR frames now show
their own hostname instead of bridge.lux.network.
Tests: 58/58 SDK unchanged.
Covers the SDK side of the layered cosigner contract while issue #386's
backend implementation is in progress:
1. Omits cosigners[] entirely when no layered providers configured
(backwards compat — no behavior change for existing tenants).
2. Forwards utila intent in snake_case, public IDs only.
3. Forwards fireblocks intent in snake_case, public IDs only.
4. Forwards both utila + fireblocks together (belt + suspenders).
5. Omits optional sub-fields (api_host, vault_id) when unset — no
'foo: undefined' on the wire.
Each test that forwards an intent also asserts the full request body
contains zero secret-like substrings (private_key, api_secret, jwt,
token, service_account_private_key) — defense in depth against any
future regression where a consumer's bridge.config.ts accidentally
forwards a secret.
Pairs with the 16 server-side validator tests in app/server/src/domain/
__tests__/cosigners.test.ts — together they pin the wire contract from
both ends.
Total SDK tests: 53 → 58. All passing.