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.
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.
External MPC custodians can now be layered ON TOP of the native Lux
threshold network as additional cosigners. The bridge backend enforces
2-of-2 (native + listed cosigner) before releasing settlement. Use when
tenants are already on Utila or Fireblocks for institutional custody and
need regulated-cosigner gating without giving up the native threshold
property.
Browser-safe by construction: the SDK declares only PUBLIC identifiers
(orgId, clientId, apiKey id, vault ids). The Utila JWT and the
Fireblocks secret key live on app/server/ (sourced from KMS at boot).
No secret material is ever shipped in the page bundle.
Surface:
BridgeMPCConfig {
publicUrl?, privateUrl?, protocol? (existing native MPC)
utila?: BridgeUtilaConfig (new, optional)
fireblocks?: BridgeFireblocksConfig (new, optional)
}
publicUrl is now optional too — pure-external custody (utila/fireblocks
only, no native threshold) is supported. The runMpcSignSession helper
throws if it's called without publicUrl, and useTransfers gates the
client-side sign session on its presence.
Per-swap, the SDK forwards a cosigners[] array to POST /api/swaps
carrying just the public IDs. The backend pairs each entry with its
KMS-held secret half and completes the cosign. Both blocks may be set
together (belt + suspenders) — backend enforces all listed.
Tests: 53/53 passing. Typecheck clean. No new runtime deps in pkg/bridge
(utila/fireblocks SDKs live on the server, not the browser bundle).
useTransfers replaces the setTimeout phase progression with the real
flow:
1. POST ${cfg.apiHost}/api/swaps with the order (Idempotency-Key
header set to the local transfer id, server snake_case body).
2. Subscribe via GET /api/swaps/:id polling at 2s; map the server's
free-form status string → TransferPhase via statusToPhase()
(signing/broadcasting/completed/failed detection).
3. On entry into the signing phase, start an MPC threshold session
via runMpcSignSession() when the server record carries the
keyId + messageHash. Progress is patched onto the Transfer.mpc
field so the UI can render real signer activity.
4. On unmount or clear(): abort every per-transfer controller so
polls + MPC sessions stop cleanly.
Destination address defaults to the connected wallet (self-send across
chains is the common case); tenants needing sweep-to-treasury flows
pass an explicit address. New cases marked failed cleanly: no wallet
connected, unsupported chain pair, server hard-error.
submit() kept as a back-compat alias for initiate() so SwapForm.tsx
(off-limits — Phase 3 R2.5 is in parallel on components/) doesn't have
to retouch. The richer destinationAddress field is optional.
BridgeApp.tsx wraps its inner tree in <WagmiProvider> +
<QueryClientProvider>. The Config is built once per mount from
BridgeConfig.wallet via buildWagmiConfig(); the QueryClient defaults
disable refetch-on-focus/reconnect (bridge quotes are request-scoped
and shouldn't refresh behind the user's back).
4 tests cover initiate POST shape, the wallet-not-connected failure
mode, the unsupported-chain-pair failure mode, and clear() aborting
polls.
useSwap now hits ${cfg.apiHost}/api/quote (GET, query params per the
server's app/server/src/routes/quote.ts) with a 300ms debounce. Maps
bridge chain ids → server internal_name (ETHEREUM_MAINNET / LUX_MAINNET
/ etc, suffix-swapped for testnet/devnet env). In-flight requests are
aborted via AbortController on every keystroke so the backend isn't
flooded.
New SwapState fields: quoting (boolean) + quoteError (string | null).
quoteError surfaces 5xx + network errors to the UI rather than blanking
the quote silently. AbortError is suppressed (newer keystroke).
The previous static USD-rate matrix is gone — pricing now comes from
the server's getTokenPrice oracle. The Quote shape is preserved
(outAmount/feeUsd/destGas/etaText/rate) so SwapForm doesn't need to
change; minOut is added for slippage protection on submission.
5 tests cover debounce, same-chain noop, invalid input, server error,
and reverse-swap state.
Also flips vitest config to happy-dom so hook tests can mount under
a real DOM-ish environment (jsdom would also work; happy-dom starts
faster on cold init).
useWallet now wraps wagmi's useAccount + useConnect + useDisconnect +
useSignMessage + useSwitchChain. Connector preference: WalletConnect
(when wallet.walletConnectProjectId is supplied) → injected (MetaMask,
Rabby, etc.) → Coinbase Wallet → first available.
Adds disconnect(), switchChain(bridgeId), signMessage(msg) to
WalletState — capabilities the SwapForm submission flow + future MPC
co-signing will need.
DEV fallback preserved: when import.meta.env.DEV is true AND no
connector is registered, the hook still throws a clear error
("install MetaMask, or configure wallet.walletConnectProjectId") rather
than silently faking a connection. The display-only DEADBEEF address
literal is kept only for the gated DEV branch — production builds
tree-shake it out (the gate is statically evaluable).
7 tests cover the wagmi delegation and the EVM-only switchChain guard.
bridge-api.ts wraps the Express bridge server's /api/{quote,swaps,...}
routes (see app/server/src/routes). Pure functions, no React. Errors
throw BridgeApiError({ status, body }) so callers can surface or
retry. chainIdToInternalName maps SDK chain ids → server enum
("evm:1" → "ETHEREUM_MAINNET", suffix-rewritten for testnet/devnet).
mpc-session.ts wraps @luxfi/threshold's ThresholdClient with a
progress-reporting wrapper used by useTransfers to surface real
MPC ceremony state to the UI instead of a setTimeout placeholder.
Protocol defaults to cggmp21 (secp256k1, EVM-compatible); respects
BridgeMPCConfig.protocol when set (pulsar/corona/magnetar for the
post-quantum path).
14 tests across both modules: fetch shape, query/body construction,
error mapping, JSON-RPC happy-path + failure + abort.