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.
Pulls in wagmi 2.15.5 (matches lux/exchange/apps/web), viem 2.30.5,
@tanstack/react-query 5.90.20 as direct dependencies of @luxfi/bridge.
Also wires @luxfi/threshold as a workspace dep for the MPC layer used by
useTransfers (separate commit).
New module pkg/bridge/src/app/lib/wagmi-config.ts:
- buildWagmiConfig(cfg) — pure function, creates a wagmi Config scoped to
the BridgeApp mount with injected + Coinbase + optional WalletConnect
connectors (WC only when wallet.walletConnectProjectId is set, so
Reown's appkit doesn't 403 on missing project id for white-label
deploys that haven't provisioned one).
- bridgeIdToWagmiChainId / wagmiChainIdToBridgeId — boundary helpers
between the SDK's namespaced chain ids ("evm:1") and wagmi's numeric
ids. Non-EVM bridge ids return null — those legs sign via MPC, not
wagmi.
- Chain selection respects wallet.supportedChainIds (filter) and
wallet.defaultChainId (hoisted to chains[0], wagmi's default for the
unconnected state).
9 tests cover the config builder + the chain-id boundary mappers.
Phase 3 R2 swapped <button>/<input> primitives onto @hanzo/gui's Button
and Input components in SwapForm, WalletConnect, and AssetInput, but the
runtime bootstrap was never added. The library's primitives read from
globalThis.__guiConfig at render time and throw Error("Err0") when null,
and the JSX tree also needs a GuiProvider for theme context — without
both, the SDK mounts to a blank page.
Wire both inside the SDK so consumers don't need to know about them:
- mount.ts: call createGui(getDefaultGuiConfig()) before React render,
idempotent (skips when globalThis.__guiConfig already populated).
- Bridge.tsx: wrap <BridgeApp /> in <GuiProvider defaultTheme="dark">.
@hanzogui/config-default added as a direct dependency (was transitive
only). Imports cast to unknown to bridge the source/types mismatch
introduced by the monorepo's postinstall rename of upstream
createHanzogui/HanzoguiProvider → createGui/GuiProvider on .ts files
but not the shipped .d.ts.
Red flagged a P1 documentation gap: downstream tenants of @luxfi/bridge
(starting with zooai/bridge) need the same react-native-web alias +
TAMAGUI_TARGET define that app/bridge/vite.config.ts uses, or Rolldown
fails to resolve react-native from Tamagui's runtime. Adding:
- pkg/bridge/README.md: new "Consuming this SDK from a Vite app" stanza
with the canonical resolve.alias + dedupe + define block
- app/bridge/vite.config.ts: header note pointing downstream consumers
at the README
No code changes. No new dependencies.
Mechanical primitive rotation across the inlined bridge UI:
- 4 <button> sites -> @hanzo/gui Button
* WalletConnect: Connect Wallet button
* WalletConnect: Connected address pill (disconnect)
* SwapForm: Reverse-chains button (the central ⇄)
* SwapForm: Submit/bridge button
- 1 <input> site -> @hanzo/gui Input
* AssetInput: amount field (uses onChangeText, the Tamagui-native
string handler, instead of the React onChange(e) signature)
Inline-style props (style={...}) are preserved verbatim on the new
components -- they round-trip through Tamagui's web-mode style merge.
Phase 3 R2.5 will lift those into theme tokens.
Out-of-scope per the R2 dispatch:
- <select> (ChainSelector, AssetInput asset picker) -- stays native
- Card / Stack / Text / Header / Footer -- still plain HTML
- title="..." HTML tooltips dropped: Tamagui Button's stack-based
types do not expose the DOM title attribute. aria-label is
preserved on every site so screen-reader behavior is unchanged.
Verification:
- cd pkg/bridge && pnpm typecheck -> clean
- cd app/bridge && pnpm build -> clean (750 modules)
- Bundle size (app/bridge index chunk): 25.99 kB -> 106.83 kB raw
(gzip 9.01 -> 38.86 kB). Delta is the Tamagui runtime; expected
and within the dispatch's "~150 KB acceptable" budget.
The SDK now ships with @hanzo/gui + @hanzogui/{core,helpers,constants,
themes,animate,animate-presence,button,input} as direct dependencies
(moved out of the optional peerDependencies block). Phase 3 R2 starts
swapping native primitives onto Tamagui-based @hanzo/gui components,
so consumers no longer have to install those packages themselves.
Three plumbing changes make this actually bundle in a consumer:
1. scripts/patch-hanzogui-exports.cjs (vendored from lux/exchange):
the published @hanzo/[email protected] + @hanzogui/*@7.0.0 packages ship
without a populated dist/, so their package.json main/module/exports
point at folders that do not exist. The patch rewrites those entries
to point at the included src/ at install time. Root postinstall hook
runs it deterministically on every pnpm install.
2. app/bridge/vite.config.ts: alias react-native -> react-native-web,
dedupe react/react-dom/react-native-web, set TAMAGUI_TARGET=web and
__DEV__=false. Minimum viable Tamagui wiring (mirrors the wiring
lux/exchange ships in a much larger config).
3. app/bridge/package.json: add react-native-web@^0.19.0 dependency
so the alias above resolves.
Result: pnpm install in a fresh checkout produces a working build with
zero manual setup. `pnpm build` in app/bridge succeeds.
Bridge.tsx no longer lazy-imports @luxbridge/app — it imports the
inlined `BridgeApp` from `./app/` directly. The workspace dep on
`@luxbridge/app` is removed from pkg/bridge/package.json, and the
type shim `src/luxbridge-app.d.ts` plus the tsconfig path alias are
gone with it.
Phase 2 produced a 91-byte JS bundle because its slimmed `app/bridge`
package re-exported `App: FC = () => null` to satisfy this lazy
import. With the cycle severed, that workaround is unnecessary and
the SDK's bundle is what a tenant build actually consumes — see the
new app/lux-bridge consumer (next commit) emitting a 25.96 KB chunk
with real `BridgeApp`, `createRoot`, and all UI strings.
package.json deltas:
- version: 1.0.0 -> 1.0.1
- dependencies: drop @luxbridge/app + @luxbridge/settings (both
workspace-only)
- peerDependencies: add @hanzo/gui ^7 + the @hanzogui/* v7
primitive set as OPTIONAL peers. Phase 3 R2 will swap the
inline-styled primitives in src/app/components/ for these; until
then they're optional so `pnpm add @luxfi/bridge` doesn't drag
in 30 unused packages.
tsconfig.json: drop the `@luxbridge/app` path mapping.
README.md: replace the workspace-internals diagram with the actual
inline layout under src/app/.
Phase 1.5 — sever the @luxbridge/app workspace cycle that Phase 2
exposed as a P0 blank-page bug. The SDK now ships its own bridge UI
inline; there is no runtime dependency on any sibling tenant package.
Layout:
pkg/bridge/src/app/
BridgeApp.tsx top-level <BridgeApp/>
components/ Header, SwapForm, ChainSelector, AssetInput,
WalletConnect, TransferStatus, Card
hooks/ useWallet, useSwap, useTransfers
lib/ chains, assets, format
styles/theme.css minimal CSS variables (brand-* hooks)
Features covered:
- Header with brand block + wallet connect
- Swap form: From/To chain selector, asset input + select, quote
panel (rate / fee / ETA), reverse button, submit
- Wallet connect button + connected-address pill
- Transfer status feed with MPC-phase rendering (pending → signing
→ broadcasting → completed)
Design constraints:
- PQ-safe: no classical-crypto assumptions anywhere. The MPC signer
layer (wired in Phase 3 R3) plugs Ringtail-lattice + ECDSA-CMP
hybrid; nothing here defeats post-quantum.
- Leaderless / permissionless: chain + asset registries are static
client-side data, not bound to any central authority. Quotes are
deterministic display mocks; the real backend (also leaderless)
arrives in Phase 3 R3.
- Phase 3 R2 swap target: components are seam-isolated so swapping
inline-styled `<div>` / native `<select>` for @hanzo/gui v7
primitives is mechanical. The shape of BridgeApp.tsx stays stable.
Stats: 15 new files, 1336 LoC. Renders identical output to the
former @luxbridge/app App component for the four MVP features.
Align the SDK Protocol union with the canonical thresholdd protocol set
(cggmp21, frost, pulsar, corona, bls, doerner) plus magnetar from the
study/ research variant. lss and ringtail are removed entirely — lss
never had a leaderless-safe round and ringtail folded into the pulsar/
corona lattice family.
Classical: cggmp21 (ECDSA secp256k1), frost (EdDSA ed25519), bls, doerner (2-of-2).
Post-quantum: pulsar (MLWE), corona (RLWE), magnetar (research variant).
- pkg/bridge/src/types.ts: BridgeMPCConfig.protocol widened with JSDoc
- pkg/threshold/src/types.ts: Protocol type widened with matching JSDoc
- pkg/threshold/src/client.test.ts: 'lss' callsites → 'cggmp21'
- pkg/bridge/src/__tests__/config.test.ts: parametric it.each test
walking all 7 protocols, confirms setConfig({mpc:{protocol}}) accepts each
Verified: pnpm typecheck + pnpm test green on pkg/bridge (14/14) and
pkg/threshold (8/8).
- BridgeMPCConfig.protocol now mirrors @luxfi/threshold Protocol union:
'lss' | 'cggmp21' | 'frost' | 'bls' | 'ringtail'
- setConfig throws on conflicting clientId / iamOrg between top-level
legacy fields and auth.{clientId,orgSlug} so misconfigured tenants
fail loudly at boot rather than silently picking one source.
- vitest wired into pkg/bridge with 7 tests covering precedence guard.
Extend the @luxfi/bridge SDK contract so tenant apps wire identical
auth/KMS/wallet/MPC config across `<Exchange>` and `<Bridge>`. The new
blocks intentionally mirror the canonical `<Exchange>` prop shape from
@luxfi/exchange — same field names, same semantics.
New optional types in pkg/bridge/src/types.ts:
- BridgeAuthConfig (provider, issuer, clientId, idHost?, orgSlug?)
- BridgeKMSConfig (url)
- BridgeWalletConfig (walletConnectProjectId?, defaultChainId?, supportedChainIds?)
- BridgeMPCConfig (publicUrl, privateUrl?, protocol?)
BridgeConfig gains optional `auth`, `kms`, `wallet`, `mpc` fields.
Legacy `clientId` / `iamOrg` flagged @deprecated but preserved — no
existing consumer breaks.
Bridge.tsx + mount.ts comments updated to note the full config (incl.
new blocks) is seeded synchronously via setConfig before children read.
No runtime code change required — setConfig already stores the whole
object, so new fields automatically flow through to getConfig consumers.
README updated with a complete tenant example wiring all four new
blocks, plus per-block field tables.
Typecheck: clean (tsc --noEmit).
* added server url and version to VITE_ env
impl getAssetPrice()
simplified store
fixed total value display;
added loading state to network cb
* Fixed hmr issue (now using link() function in rout.tsx vs import);
Initial debounced and animated ReceiveCard;
set 1rem to 16px in lux-global-non-next.css