broadcast_driver.go::humanizeBroadcastErr now recognizes the most
common XRP engine_result codes and renders them as one-line user-
facing messages, matching the EVM-side humanization pattern:
tecINSUFFICIENT_FUNDS / tecUNFUNDED → "Insufficient funds in release
address — fund the MPC address"
tecNO_DST → "XRP destination account is
not activated — recipient must
hold ≥10 XRP reserve"
tefPAST_SEQ → "XRP sequence already used —
bridge will requery and retry"
tem* → "XRP transaction was malformed
— bridge will retry"
telINSUF_FEE_P → "XRP network fee too low for
current load — bridge will retry"
The SDK + UI surface these from Swap.LastError so users see the
actionable cause (fund the wallet, activate the destination, etc.)
instead of the raw rippled error string.
Test: TestHumanizeBroadcastErr_XRPEngineResults covers all five new
mappings plus regression for the existing EVM mappings.
internal/txassembler/xrp.go:
- XRPProvider interface + per-network XRPNetwork config (LastLedgerWindow,
fee override, canonical-sig flag).
- Assembler.PreSignXRP: pulls Sequence + suggested fee from rippled,
embeds the release wallet's compressed-secp256k1 pubkey, returns
the 32-byte SHA-512Half (via internal/xrpl) the MPC quorum signs.
- Assembler.FinalizeXRP: parses (r, s) from the MPC's 65-byte R||S||V
hex (ignores recovery byte — XRPL doesn't need ecrecover), DER-encodes
with low-s canonicalization, attaches as TxnSignature, returns the
wire blob (hex) + canonical XRPL tx hash.
- XRPRequiredDrops: amount + fee + 10-XRP reserve, used by the gas
pre-check to refuse signing when the wallet can't cover.
- xrpFloatToDrops with the same string-format trick floatToWei uses,
avoiding IEEE noise at 6-decimal native XRP precision.
internal/mchain/client.go:
- Wallet now carries ECDSAPubKeyHex (compressed-secp256k1 hex). EVM
paths ignore it; XRP signing needs it on the wire.
- pickAddress(XRP) derives the canonical r-address from the ECDSAPubKey
via internal/xrpl.AddressFromPubKey instead of falling back to the
ETH slot. Fallback retained for legacy mocks without the pubkey field.
cmd/bridge/xrp_provider.go:
- HTTP wrapper over rippled JSON-RPC (account_info, fee). Returns
(Sequence, validated_ledger_index) and the open_ledger_fee/median
fee / base_fee chain. Treats actNotFound on balance lookup as
"wallet not activated → balance 0" so the gas pre-check correctly
reports an unactivated release wallet.
cmd/bridge/release_pool.go:
- ReleasePoolEntry gains Family + PubKeyHex fields (round-trips through
JSON; pre-XRP entries leave them empty).
- AcquireForFamily filters entries by family — the signing driver now
picks an XRP wallet for XRP destinations and an EVM wallet for EVM.
Falls back to family-agnostic Acquire when no per-family match.
- SizeByFamily + Reload helpers so a parallel XRP-pool bootstrap can
share the same store as the EVM pool without re-indexing.
cmd/bridge/signing_driver.go:
- signOne dispatches on mchain.AddressTypeFor(DestinationNetwork). XRP
routes to signOneXRP, which mirrors the EVM ceremony but: builds a
Payment via Assembler.PreSignXRP, runs an XRP gas pre-check (balance
vs amount+fee+reserve) instead of eth_getBalance, hands the
SHA-512Half digest to the MPC, then Assembler.FinalizeXRP emits the
signed wire blob.
- xrpGasPrecheck short-circuits to SwapStatusFailedInsufficientReleaseGas
with a humanized "balance=X drops, required=Y drops" reason. Probe
failures DON'T block the swap (best-effort, broadcast layer still
retries on transient rippled flakes).
cmd/bridge/main.go:
- New flags: --xrp-release-pool-size (default 5),
--xrp-release-pool-mint-network (default XRP_TESTNET),
--xrp-release-balance-threshold-drops (default 10 XRP).
- XRPProviderClient instantiated once, shared between the assembler
(for PreSignXRP) and the gas pre-check (via assembler.XRPProviderFor).
- XRP pool minted on top of the existing EVM pool (via a helper-pool
bound to the same store + Reload), so a single ReleasePool object
serves both families.
- /health surfaces pool sizes broken out by family.
Tests:
- xrpl: address derivation round-trip, bad-pubkey rejection, DER
encoding + low-s flip, PreSign digest matches SHA512Half(STX||body),
full PreSign→Finalize round-trip through the rubblelabs parser.
- broadcast: tesSUCCESS happy path, terQUEUED retryable, tecINSUFFICIENT_FUNDS
+ tecNO_DST fatal, temBAD_AMOUNT malformed, telINSUF_FEE_P retryable,
rippled high-level errors, classifyEngineResult table.
- txassembler: PreSignXRP populates Sequence / Fee / LastLedger,
fee floor + pin, bad-input rejection, FinalizeXRP round-trip,
XRPRequiredDrops math, xrpFloatToDrops table.
- mchain: XRP r-address derivation from ECDSAPubKey, legacy fallback
to ETH slot, bad-hex rejection.
- release pool: XRP-family Bootstrap + entry shape + PubKey roundtrip,
AcquireForFamily filtering, no-match returns ErrEmptyPool.
- signing driver: XRP happy path advances to broadcasting with
populated DestRawTx, insufficient balance short-circuits, missing
PubKeyHex rolls back with readable LastError.
- xrp_provider: account_info / fee / balance stubs, actNotFound → 0,
rippled error surfacing.
Introduces internal/xrpl, the bridge's XRP Ledger primitives package:
- AddressFromPubKey / AccountIDFromPubKey: derive the canonical r-address
from a 33-byte compressed-secp256k1 public key. Hash chain is
RIPEMD160(SHA256(pubkey)), base58check-encoded with the XRPL alphabet
+ 0x00 version byte. Round-trips through the rubblelabs codec.
- PreSign(PaymentUnsigned) → (32-byte digest, *SignCtx): builds a native
XRP Payment, embeds the signing pubkey on the wire, returns the
SHA-512Half of (0x53545800 || serialized_body) — exactly the message
the MPC quorum must sign.
- Finalize(DER, *SignCtx) → (signedBlob, txHash): attaches the DER
signature to TxnSignature, returns the full wire-format binary blob
(suitable for submit's tx_blob param as hex) and the canonical XRPL
tx hash (SHA-512Half of 0x54584E00 || blob).
- EncodeDERSignature(r, s): ASN.1 DER encode (r, s) with low-s
normalization (rejects high-s as XRPL's tfFullyCanonicalSig requires)
and the 0x00 leading-byte rule for INTEGERs with the MSB set.
- CanonicalizeS / SHA512Half exposed for tests + future TrustSet path.
Uses github.com/rubblelabs/ripple for the deep binary codec — every
field-id, type-byte, and length-prefix detail is the library's
responsibility. The bridge owns only the signing flow on top, so MPC
ECDSA can stand in for the library's local-key Sign helper.
Tests cover: canonical pubkey → r-address round-trip, rejection of
bad pubkey lengths/prefixes, DER encoding of standard and high-bit
cases, low-s flip for s > N/2, validation that high-s gets canonicalized
in EncodeDERSignature, PreSign digest matching independent SHA512Half
recomputation, full PreSign → Finalize round-trip via the parser.
Wires the static release pool into the MPC signing driver and adds an
eth_getBalance pre-check that short-circuits swaps before the (slow)
ceremony when the release wallet can't cover destination-chain gas.
signing_driver.go:
- SetReleasePool / SetGasProbe inject the pool + balance probe at
startup. Without a pool, the driver falls back to extracting the
wallet ID from Swap.DepositAddress (v1 behaviour).
- signOne() now picks the release wallet from the pool when one is
configured AND has entries, persisting ReleaseWalletID +
ReleaseAddress on the Swap so the broadcast + refund drivers can
read them later. The chosen address goes into the assembler's
PreSign as SenderAddress so the unsigned tx targets the correct
nonce + signs against the pool wallet.
- AFTER PreSign (which gave us the exact GasPrice * GasLimit + Value),
the gas pre-check queries the release wallet's destination-chain
balance. If balance < required cost, the swap moves to
SwapStatusFailedInsufficientReleaseGas with a clear LastError
("Release wallet 0x… has insufficient balance: balance=… required=…
gasCost=… value=… short=…"). The MPC ceremony is skipped.
Probe failures are treated as best-effort: log + continue rather
than refuse to sign a swap we couldn't verify.
- New ShortCircuited counter distinct from Failures (operator
dashboards differentiate "operator needs to fund a pool wallet"
from "transient MPC / RPC flake").
main.go:
- --release-pool-size (default 10), --release-pool-mint-network
(default LUX_TESTNET), --release-balance-threshold-wei
(default 1e17 wei = 0.1 LUX), --disable-gas-precheck flag, plus
--mpc-dashboard-url / --mpc-dashboard-token / --mpc-dashboard-api-key
for the dashboard signing wiring landed in commit 1.
- Bootstrap path: instantiates the balance probe, then the
ReleasePool, then calls Bootstrap (which lazily mints the
remaining wallets up to pool size, idempotent across restarts).
Wires both probe and pool into the signing driver.
- /health endpoint exposes the pool size + threshold for ops triage.
gas_precheck_test.go covers the three core behaviours:
- insufficient balance → swap transitions to
failed_insufficient_release_gas with no signer call
- sufficient balance → swap proceeds normally to broadcasting
- probe error → swap proceeds (best-effort, broadcast retry still works)
Together with the earlier dashboard-sign + pool foundation commits,
this closes the testnet refund-loop issue: the bridge now (a) speaks
the real /v1/mpc/sign endpoint, (b) rotates through pre-funded
release wallets, and (c) refuses to spend the MPC ceremony on swaps
that would fail on broadcast.
Introduces a static pool of pre-funded MPC release wallets so a single
under-funded wallet doesn't block all subsequent swaps. Previous
behaviour reused the deposit wallet for the release tx — a fresh
keygen with zero destination-chain balance.
This commit adds the foundation:
- release_pool.go: ReleasePool with round-robin Acquire(), lazy
Bootstrap() that mints wallets up to a configured size via the
Keygener interface (*mchain.Client satisfies). Pool entries persist
across restarts via the new ReleasePoolStore interface.
- ReleasePoolStore implementations on both InMemoryStore (test/dev)
and ZapStore (prod, persisted under releasepool:NNNNNN keys, sorted
by zero-padded index for canonical iteration order).
- balance_probe.go: BalanceProbeClient — small JSON-RPC wrapper around
eth_getBalance that reuses the broadcast + depositcheck RPC URL
tables (with overrides). Used by both the pool's low-balance
alerter and by the gas pre-check landing in a follow-up commit.
Non-EVM networks return ErrFamilyNotSupportedForBalance.
- Swap.ReleaseWalletID + Swap.ReleaseAddress fields so the broadcast
+ refund drivers know which pool wallet signed each swap.
- SwapStatusFailedInsufficientReleaseGas — new terminal state for
swaps the gas pre-check refuses to enter the MPC ceremony for.
- release_pool_test.go covers Bootstrap idempotency (reload doesn't
re-mint), pool growth, keygen failure surfacing, round-robin
fairness, balance-probe call gating on the threshold being set, and
Entries() copy isolation.
No wiring into the signing driver yet — that's the next commit so
the signing-driver change is small and reviewable in isolation.
The live lux-mpc v2026-05 daemon does NOT expose /sign on the internal
port (:6000); only /keygen lives there. Signing is on the dashboard
listener (port 8081) at POST /v1/mpc/sign with {message, encoding,
walletId, sessionId, value} returning {signature, r, s}. That endpoint
requires a pre-created session minted by POST /v1/mpc/wallets/{id}/sessions.
This commit:
- Adds DashboardURL + DashboardToken (Bearer) + DashboardAPIKey
(X-API-Key) fields to mchain.Client. Bearer wins when both are set.
- Adds internal/mchain/auth.go with EnsureDashboardSession() that
lazily mints + caches one signing grant per (walletID, orgID) per
process. Sessions live 24h with a 15-minute renewal skew.
- signViaDashboard() handles POST /v1/mpc/sign, including a single
403-retry path that drops the cached session and re-mints. The
dashboard sometimes returns only {r,s}; the client composes a
canonical 65-byte EVM signature with v=27 (the assembler's
ParseRSV tolerates the parity bit and any malleability would be
caught at broadcast).
- pending_approval sessions are rejected as a hard error (the bridge
cannot wait for a human approver mid-swap).
- SignForWalletWithOrg now routes through the dashboard when
DashboardURL is set; APIURL-only configs keep the legacy /sign
path for in-process mocks.
- New auth_test.go covers happy path, session caching across signs,
403-triggered renew, Bearer vs API-key precedence, pending_approval,
HTTP error surfacing, and r+s composition with zero-padding.
Builds on whispers/bridgev2 @ ab4adfd.
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.
Lands a side-by-side draft of the new single-binary Go explorer at
`ghcr.io/luxfi/explorer` — the canonical replacement for the legacy
Next.js `app/explorer/` (`@luxbridge/explorer`).
Includes:
- ConfigMap `luxfi-explorer-chains` — initial chain registry (lux +
ethereum), hot-reloadable via the binary's /v1/explorer/admin/chains
API once running.
- Deployment — replicas=1, strategy: Recreate (single PVC writer per
the SQLite+ZapDB embedded index docs), readiness + liveness probes
against /healthz, resources sized small (100m/256Mi req → 2c/2Gi limit).
- PVC 20Gi for the embedded chain index.
- Service ClusterIP :80 → :3000.
- Ingress on `bridge-explorer-v2.lux.network` for the soak window —
legacy keeps `bridge-explorer.lux.network`. Cut over after 1-week QA.
Marked DRAFT in the file header. New bridge dev confirms the image tag
(pin off `:latest` before the legacy delete step) and the RPC config
before `kubectl apply`.
Status update on #387 will go in the issue once this lands on main.
- README.md: replace stale 'Bridge UI → Bridge Server → MPC Nodes' diagram
with the v1.1.x topology (inlined UI, direct threshold integration,
layered cosigners). Remove dead links to docs that v1.1.12 deleted
(MPC-GO-INTEGRATION, CI-CD-DOCKER-IMAGES). Add pointer to issue #392
for the new bridge dev.
- docs/LLM.md: refresh 'Key Documentation Files' list to remove the
deleted MPC-GO/DEPLOYMENT/MIGRATION-TO-GO references; document the
Nextra site path (`docs/pages/*.mdx`); reframe the migration-files
history as a v1.1.12 deletion event with the commit hash.
- docs/content/docs/index.mdx: refresh the architecture diagram inside
the Nextra site itself so the public docs match the new topology.
Add the protocol class table (classical vs PQ-safe lattice) and the
optional-layered-cosigners line. The diagram now matches LLM.md and
the top-level README — three places, one story.
Closes the remainder of #391.
Lands the server-side foundation for the layered cosigner feature that
shipped in @luxfi/bridge v1.0.3. The SDK already forwards public-id-only
cosigners[] arrays on POST /api/swaps; this commit:
- Adds domain/cosigners.ts — public types match SDK wire shape EXACTLY
(snake_case, no drift); validator rejects secret-like fields up
front (api_secret, private_key, jwt, token, etc.); dispatch
entrypoint runs cosigners in parallel and surfaces per-step result.
- Adds /api/swaps POST validation — the validator runs BEFORE the
swap is persisted; a malformed cosigners[] returns 400 with a
precise reason, never reaches the DB.
- Threads cosigners through SwapData.handleSwapCreation as a logged
pass-through. TODO comment marks where the new dev needs to:
a. add Prisma schema for CosignerStep (or Swap.metadata JSON);
b. call dispatchCosigners(...) after native MPC sign completes;
c. gate broadcasting on all listed cosigners returning approved.
- Adds vitest to @luxbridge/server + 16 unit tests covering happy
paths (utila / fireblocks / both) AND rejections (unknown kind,
missing required fields, every secret-like leak vector, index
reporting in error messages).
What is wired:
✅ Wire-shape validation (reject secrets, accept valid intents)
✅ Pass-through through handleSwapCreation
✅ KMS-lookup interface with env-var fallback for local dev
✅ Dispatch entrypoint + parallel cosigner execution
✅ runUtila + runFireblocks STUB implementations with full TODO map
What is stubbed (issue #386 follow-ups):
⏳ Actual KMS round-trip in fetchCosignerSecret
⏳ @luxfi/utila transaction-approval flow (not the existing primary-
signer mode in domain/utila.ts)
⏳ fireblocks-sdk install + FireblocksSDK client + raw-sign flow
⏳ Prisma persistence + state-machine gating
Tests: 16/16 cosigner tests passing on @luxbridge/server. Bridge SDK
53/53 still passing. Pre-existing workspace-dep typecheck errors on
server (@luxfi/utila not built in this worktree) are unchanged by this
commit.
Delete the leftover Rust→Go MPC migration notes and the Docker Compose
deployment guide. Those describe an architecture from before the SDK
rationalization and confuse new readers (e.g. LOCAL-SETUP.md was still
claiming images weren't published yet; MPC-MODERNIZATION-SUMMARY.md was
a celebration page for migration work that finished long ago).
- BRIDGE-STATUS.md
- LOCAL-SETUP.md
- MPC-GO-INTEGRATION.md
- MPC-INTEGRATION-COMPLETE.md
- MPC-MODERNIZATION-SUMMARY.md
- MIGRATION-TO-GO-MPC.md
- DEPLOYMENT.md (GG18 → CGG21MP migration; both pre-CGGMP21)
- CI-CD-DOCKER-IMAGES.md (described pre-self-hosted-runner workflow)
Migration history is in git log + GH releases. Live setup lives in
docs/LLM.md (now the single canonical AI/onboarding doc) plus the
top-level README.md.
Kept:
- docs/LLM.md (canonical source of truth, refreshed in v1.1.11)
- docs/LUX-ID-INTEGRATION.md (still applicable; Casdoor-based auth)
Closes part of #391.
Fact-check pass against actual repo state, fixing five stale claims that
referenced an architecture that hasn't existed since phase 1.5:
1. app/bridge3/ (@luxbridge/app-v3) — DOES NOT EXIST. Folded into
pkg/bridge/src/app/ at 9b12b12 ('feat(pkg/bridge): inline bridge UI
under src/app/'). Bridge.tsx no longer lazy-imports any sibling
workspace package; the cycle that produced the blank-page bug is gone.
2. app/bridge/ — collapsed at 95a1981 ('phase2-r2: collapse to canonical
app/bridge tenant') from a multi-variant Next.js setup to a single
@luxbridge/lux-tenant (Vite + ~120 LOC).
3. Frontend stack — Vite + React 18.3.1 + Tamagui (@hanzo/gui v7), NOT
Next.js 14. SDK ships TypeScript source directly; consumer Vite
bundles it.
4. @luxfi/threshold — consumed BOTH by app/server and by the SDK
directly (pkg/bridge/src/app/lib/mpc-session.ts since phase 3 R3),
not server-only as the prior diagram showed.
5. Service ports — tenant UI is 3001 (Vite), not 3000.
Adds:
- System architecture ASCII diagram reflecting the inlined UI +
direct threshold + layered-cosigner topology.
- 'Optional layered cosigners' subsection documenting Utila +
Fireblocks layering pattern (since SDK v1.0.3).
- 'Explorer migration' section — app/explorer/ (Next.js) is on a
deprecation path; canonical replacement is ghcr.io/luxfi/explorer
(single Go binary embedding luxfi/indexer + luxfi/graph + luxfi/explore
via go:embed). Same pattern as LiquidGraph cutover in liquidity/universe.
- Publishing status — @luxfi/bridge + threshold still 404 on npm as of
2026-05-22; likely missing NPM_TOKEN secret OR unavailable lux-build
runner pool. v1.1.10 fail-fast guard will surface either clearly.
- 'History — what got folded together' subsection so the prior
architecture changes are findable from the docs (commit refs included).
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).
Roll up four workflow hardening fixes that were stuck on the R2.5 branch
(commits ffd0f79, 0a8cbe1, 5be700e, a88fc50) — picked up here without
their root-package version bumps:
- runs-on lux-build (self-hosted org runner; GitHub-hosted not authorised)
- fetch-depth: 1 in actions/checkout (avoid 'cannot fetch both sha and
refs/tags/v1.1.x' on self-hosted)
- bake token into NPM_CONFIG_USERCONFIG path (pnpm does NOT expand
${VAR} references in .npmrc — literal token must be on disk)
- accept NPM_TOKEN or NODE_AUTH_TOKEN, fail fast at auth step with a
clear ::error:: message if both are blank (better than opaque pnpm
publish failure)
Re-tagging v1.1.10 to retrigger the publish workflow with these fixes.
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.
Three nit fixes from red review of the @luxfi/brand wiring:
1. Destructure consumed fields only (shortName, primaryColor, supportEmail).
Cuts the TypeScript surface to what's actually used; prevents accidental
tenant-metadata leakage if a future SDK feature ever inspects the brand
block. Note: Rollup keeps the full JSON literal alive in the bundle even
after destructure (JSON modules aren't object-property tree-shaken), so
bundle bloat for unused brand fields is unchanged until @luxfi/brand
ships per-field named exports.
2. Restore canonical bridge endpoints. brand.appDomain is "lux.exchange"
(the exchange's domain) — deriving api.bridge.${appDomain} pointed the
bridge SDK at a host that doesn't exist. Bridge endpoints belong to
the bridge subdomain, independent of the brand package's appDomain.
3. Drop secondaryColor. brand.theme.{light,dark}.accent1 is a per-theme
foreground (white on dark, black on light), NOT a brand accent —
wiring it to --brand-secondary breaks hover-color on the opposite-theme
surface. Fall back to the SDK's compiled default
(var(--brand-secondary, #7a9ff5) in pkg/bridge/src/app/styles/theme.css)
until @luxfi/brand exposes a real brand-accent field.
Verified:
- typecheck clean
- vite build clean (754 modules, 4.3s)
- api.bridge.lux.network appears in bundle (canonical fallback active)
- DEADBEEF: 0 hits
Single surgical diff in app/bridge/src/bridge.config.ts.
- index.html favicon now uses @luxfi/logo's getFaviconSVG output
(white triangle on black rounded square) verbatim, replacing the
blue placeholder rect that was a build-template artifact.
- bridge.config.ts already destructures only org-level fields from
@luxfi/brand (shortName, primaryColor, supportEmail) — the comment
block now makes the exchange-vs-bridge separation explicit:
@luxfi/brand is the Lux EXCHANGE product's brand; the bridge has
its own endpoints, name, and docs URL. Only colors and the support
email are shared with the exchange via the org brand.
Env-parametric specs covering bridge.lux.network (and bridge.zoo.network
+ future tenants). Mirrors the wider Lux e2e env routing convention:
E2E_ENV=local (default) → http://localhost:3001 (auto-boots vite preview)
E2E_ENV=dev → https://bridge.lux-dev.network
E2E_ENV=test → https://bridge.lux-test.network
E2E_ENV=main → https://bridge.lux.network
BRIDGE_TENANT=zoo → https://bridge.zoo.network (mainnet only)
smoke.spec.ts (6 cases, ~16s):
1. Renders, title matches brand, SVG brand logo from @luxfi/logo
2. From-chain selector has ≥ 2 options
3. From-asset selector has ≥ 2 options after switching to Ethereum
(default chain Lux only has the native LUX asset)
4. Connect-wallet button visible + clickable (no real wallet connect)
5. Typing an amount updates the "You receive" estimate via useSwap
6. Production gate: served HTML + JS bundles free of `DEADBEEF`
(the dev-only stub MPC display address)
multi-tenant.spec.ts (skips unless E2E_ENV=main):
- For each tenant in the registry, probe DNS, navigate, assert
document.title and the --brand-primary CSS variable set by
applyBrandMetadata reflect the tenant's pinned brand block.
Playwright config auto-boots `vite preview` on :3001 from app/bridge/
when E2E_ENV=local, so `pnpm test:smoke` is one-step after `pnpm build`.
README documents the build prereq and the runner contract.
Out of scope (TODOs in code):
- Real wallet connect (Playwright wallet mocking is a separate PR)
- Real backend swap submit (prod backend not reliably reachable from CI)
- Visual regression / screenshot diffs (separate PR)
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.
The runtime shim (window.__ENV / /__ENV.js) reads BRIDGE_* names from
the container env at boot. The previous VITE_BRIDGE_* prefix only made
sense when the build was Vite-time inlining the values, which is no
longer the case (and was the bug behind manifest+runtime drift). One
canonical name across compose+k8s+entrypoint.
Pair commit for the runtime env shim. The previous commit made the
SDK read window.__ENV from /__ENV.js; this commit makes the serving
image overwrite /__ENV.js from real env at boot.
Switch base from ghcr.io/hanzoai/spa (scratch, no shell — can't run
a docker-entrypoint script) to nginx:alpine, which already has the
/docker-entrypoint.d/*.sh hook for exactly this case. The hook
templates /__ENV.js via a heredoc — sh-only, no envsubst dep — so
the image only needs nginx:alpine, nothing else.
Tenants copy app/bridge/docker-entrypoint.sh verbatim and change the
env var names to match the keys their bridge.config.ts looks up.
Verified end-to-end on the host: running the entrypoint with
BRIDGE_* set produces a /__ENV.js with the values substituted in;
running it with nothing set produces the same template the build
ships (empty strings, SDK falls back to import.meta.env).
Vite inlines `import.meta.env.VITE_*` at build time, so setting those
vars at runtime on a deployed image is a no-op — the bundle already
carries baked URLs. Compose/k8s `environment:` blocks targeting
VITE_BRIDGE_* are cosmetic in that world.
Layer a tiny runtime shim on top:
1. /__ENV.js (this commit ships the build-time template under
public/) is loaded synchronously in <head> before the main
bundle. The serving image overwrites it at boot from real env.
2. bridge.config.ts reads window.__ENV first, then falls back to
import.meta.env.VITE_* for dev and as a safety net.
This decouples the deployable image from its environment: one image,
N envs. Tenants (zoo, future white-labels) copy the public/__ENV.js
template + the env() helper pattern verbatim — only the key names
change.
The dist bundle (search: `window.__ENV`) wires the runtime read,
confirmed via Vite preview + Playwright eval — `window.__ENV` is an
Object with all six expected keys at page load. The pattern works
with or without the docker-entrypoint that ships in a follow-up
commit; an image that fails to template /__ENV.js still gets a
working SPA via build-time fallback.
Replace the five hardcoded Lux strings/colors in the Lux tenant build
with reads from @luxfi/brand's brand.json:
name "Lux Bridge" -> `${shortName} Bridge`
primaryColor "#5b8def" -> brand.primaryColor (#000000)
secondaryColor "#7a9ff5" -> theme.dark.accent1
supportEmail "[email protected]" -> brand.supportEmail
apiHost / docsUrl literal lux.network -> derived from appDomain/docsDomain
Use the `@luxfi/brand/brand.json` subpath rather than the package's
main entry: the npm-published @luxfi/[email protected] omits its .d.ts shipping
artefacts, but brand.json is included and TypeScript infers full literal
types under `resolveJsonModule`. This also keeps the package as the
single source of truth — a brand block bump in @luxfi/brand propagates
here on the next install with no string copies.
Each lookup keeps a hard fallback string so the build never breaks when
@luxfi/brand omits a field in a future version. The fallbacks match the
pre-cutover hardcodes (lux.network defaults).
Tenant build verified:
pnpm --filter @luxbridge/lux-tenant typecheck clean
pnpm --filter @luxbridge/lux-tenant build clean (bundles brand.json)
pnpm --filter @luxfi/bridge typecheck clean (SDK unchanged)
SDK sweep clean: no hardcoded Lux brand overrides under pkg/bridge/src/app/.
The two remaining matches are:
- pkg/bridge/src/app/styles/theme.css header comment naming the SDK
- pkg/bridge/src/app/lib/chains.ts:29 `name: 'Lux Network'` chain identity
on `lux:96369` (structurally identical to `'Ethereum'` on `evm:1`).
DEADBEEF audit unchanged: 1 occurrence, gated behind `import.meta.env.DEV`
in pkg/bridge/src/app/hooks/useWallet.ts:32.