User asked for native ~/work/lux/mpc + ~/work/lux/kms integration and
'pure b-chain/m-chain via mpc/kms software frontend'. Delivers the
canonical-Lux-services HTTP clients on the bridge backend.
Three new client modules in app/server/src/clients/:
lux-iam.ts LuxIAMClient — OAuth2 client_credentials minter against
Lux IAM (https://iam.lux.network). Per-audience cache
with early refresh; one cache slot per (aud=lux-kms,
aud=lux-mpc) downstream service. invalidate() on 401.
Mirrors ~/work/lux/kms/pkg/iamclient/client.go in TS.
lux-kms.ts LuxKMSClient.getSecret(path) — reads from ~/work/lux/kms
(https://kms.lux.network) at
/v1/kms/orgs/{org}/secrets/{path...}. Bearer JWT auth via
LuxIAMClient. Unwraps either raw text/* or JSON envelope
{ value | secret } responses. KMSError carries httpStatus
+ path for the caller to switch on.
lux-mpc.ts LuxMPCClient — the canonical m-chain entrypoint. Speaks
the same wire protocol as ~/work/lux/kms/pkg/mpc/client.go
(~/work/lux/mpc/cmd/mpcd HTTP API). Methods:
keygen({ vaultId, name, keyType, protocol })
→ POST /v1/vaults/{id}/wallets (DKG)
sign({ walletId, keyType, message })
→ POST /v1/transactions (type=sign,
base64 payload)
status() → GET /v1/status
Supports the full Lux protocol set: cggmp21 / frost /
bls / sr25519 / doerner / pulsar / corona / magnetar.
Per-call 120s timeout matches mpcd's threshold ceiling.
────────────────────────────────────────────────────────────────────────
fetchCosignerSecret — now KMS-backed
────────────────────────────────────────────────────────────────────────
domain/cosigners.ts::fetchCosignerSecret routes through LuxKMSClient
when LUX_KMS_URL + LUX_IAM_* are set. Path layout:
utila: bridge/cosigners/utila/{envSafe(org_id)}/sa_pem
fireblocks: bridge/cosigners/fireblocks/{envSafe(api_key)}/secret_pem
404 from KMS → quietly falls back to UTILA_COSIGNER_PEM__/
FIREBLOCKS_COSIGNER_PEM__ env vars (dev-only). Any other KMS error is
rethrown so the dispatcher surfaces it as a 'failed' (retryable)
CosignResult. _resetKmsClientForTests() lets tests rebuild the cached
client after mutating process.env.
────────────────────────────────────────────────────────────────────────
docs/LLM.md
────────────────────────────────────────────────────────────────────────
- New 'Native Lux services' section: documents IAM + KMS + MPC env
config and the audience scoping. Production MUST set the LUX_KMS_*
block; env-var fallback is dev-only.
- New 'Lux software frontends' section: lists the three operator UIs
(~/work/lux/mpc/dashboard Next.js, ~/work/lux/kms/frontend Vite) and
notes the f-chain frontend does NOT exist yet.
- New 'f-chain frontend — what it would look like' section: scaffolds
the proposed @luxfi/fchain-dashboard layout (cluster status, pubkey
registry, encrypt-session history, reshare history, health probe)
with the exact /v1/fhe/* endpoints each widget would consume. Note
that the bridge tenant's TRANSFERS panel currently surfaces per-swap
fchain attestation status (no separate UI needed for that scope).
────────────────────────────────────────────────────────────────────────
tests — 18 new
────────────────────────────────────────────────────────────────────────
+ clients/__tests__/lux-iam.test.ts (7 cases)
+ clients/__tests__/lux-kms.test.ts (6 cases)
+ clients/__tests__/lux-mpc.test.ts (8 cases — keygen / sign / status
/ 401 invalidate / abort)
+ domain/__tests__/cosigners-kms-secret-fetch.test.ts (7 cases —
KMS happy path, env-var fallback, 404 fallthrough, 5xx propagate,
cloud_hsm/fchain defensive guard)
Updated two pre-existing tests (cosigners-utila + cosigners-fireblocks
env-var-missing cases) to match the new 'cosigner secret unavailable'
error string from the refactored fetchCosignerSecret.
────────────────────────────────────────────────────────────────────────
final counts
────────────────────────────────────────────────────────────────────────
pkg/bridge SDK: 58 / 58
app/server: 95 → 123 (+28)
Typecheck: clean
Every cosigner adapter (utila / fireblocks / gcp_kms / aws_kms /
azure_key_vault / vault_transit / fchain) is a real network call.
fetchCosignerSecret routes through real Lux KMS.
Native m-chain signing wired via LuxMPCClient (caller integration in
domain/mpc-modern.ts is the next planned step — clients are now in
place and tested).
'no stubs all real killer no filler' — every TODO in cosigners.ts replaced
with a real, network-touching adapter. Cloud HSM layer is now production-
grade across all four providers; f-chain attests via the Lux FHE daemon.
Dependencies added to app/server/package.json:
@aws-sdk/client-kms AWS SDK v3, IAM role / IRSA workload identity
@azure/keyvault-keys CryptographyClient with sign('ES256K'|'PS256', …)
@azure/identity DefaultAzureCredential (MSI / WI / Azure CLI)
(no client lib for Vault — direct HTTP via fetch + X-Vault-Token)
(no client lib for f-chain — direct HTTP to /v1/fhe/encrypt)
────────────────────────────────────────────────────────────────────────
AWS KMS — real
────────────────────────────────────────────────────────────────────────
- KMSClient({ region }) — region from identityHint or parsed from key ARN
- SignCommand(KeyId, Message: digest, MessageType: DIGEST, SigningAlgorithm)
- AWS_ALGORITHM_MAP: secp256k1_ecdsa_sha256 → ECDSA_SHA_256,
rsa_pss_sha256 → RSASSA_PSS_SHA_256
- ed25519 NOT in map → algorithm_mismatch rejected before any RPC
- mapAwsError covers AWS exception names + HTTP status:
AccessDeniedException / 403 → permission_denied
NotFoundException / 404 → key_not_found
KMSInvalidStateException / Disabled → key_disabled
InvalidKeyUsageException → algorithm_mismatch
ThrottlingException / 429 → rate_limited
DependencyTimeout / 5xx → provider_unavailable
other → network_error
────────────────────────────────────────────────────────────────────────
Azure Key Vault — real
────────────────────────────────────────────────────────────────────────
- CryptographyClient(keyRef, DefaultAzureCredential())
- AZURE_ALGORITHM_MAP: secp256k1_ecdsa_sha256 → 'ES256K',
rsa_pss_sha256 → 'PS256'
- Returns key version from response.keyID (audit trail per CosignerStep row)
- mapAzureError covers Azure code + statusCode:
Forbidden / AuthenticationFailed / 403 → permission_denied
KeyNotFound / 404 → key_not_found
KeyDisabled → key_disabled
UnsupportedKeyOperation → algorithm_mismatch
TooManyRequests / 429 → rate_limited
ServiceUnavailable / 5xx → provider_unavailable
────────────────────────────────────────────────────────────────────────
Vault Transit — real
────────────────────────────────────────────────────────────────────────
- POST {VAULT_ADDR}/v1/{mount}/sign/{name} with X-Vault-Token
- Auto-splits keyRef on '/keys/' so tenants pin a non-default mount
- Body shape per algorithm:
secp256k1_ecdsa_sha256: hash_algorithm=sha2-256, prehashed=true,
marshaling_algorithm=asn1 (DER, parity with AWS/GCP)
rsa_pss_sha256: hash_algorithm=sha2-256, signature_algorithm=pss
ed25519: no prehash (Vault rejects prehashed ed25519)
- Parses 'vault:v{N}:<base64>' response shape — extracts both signature
bytes and key_version (returned as externalId='v3' etc.)
- mapVaultError maps status + body substrings:
401/403 / 'permission denied' → permission_denied
404 / 'no such key' → key_not_found
'key version disabled' → key_disabled
'unsupported' / 'invalid input' → algorithm_mismatch
429 / 'rate limit' → rate_limited
5xx / 'unavailable' / 'sealed' → provider_unavailable
────────────────────────────────────────────────────────────────────────
f-chain (Lux FHE attestation) — real
────────────────────────────────────────────────────────────────────────
- POST {publicUrl}/v1/fhe/encrypt with { plaintext_hex, scheme? }
- Endpoint contract from luxfi/fhe::cmd/fhed/main.go (the FHE daemon
already running in the Lux ecosystem; no new server to deploy)
- 30s timeout (FCHAIN_COSIGNER_TIMEOUT_MS env override) with AbortController
- Stores the FHE ciphertext as CosignerStep.signature (0x-prefixed hex)
— the cosign property: only f-chain (with its FHE key shares) could
produce that ciphertext for that txHash; downstream verifier obtains
f-chain's public key via /v1/fhe/publickey and checks equivalence
- 401/403 → rejected (policy denial)
- 5xx → failed (retryable infra)
- network/abort → failed
────────────────────────────────────────────────────────────────────────
Other touches
────────────────────────────────────────────────────────────────────────
- fetchCosignerSecret narrows kind 'fireblocks' explicitly so the union
with cloud_hsm + fchain typechecks
- cosigners-gcp-kms.test.ts: removes the four 'not yet implemented'
marker assertions (those now live as positive assertions in the new
cosigners-aws-azure-vault-fchain.test.ts)
Test counts:
SDK pkg/bridge: 58/58 unchanged
Server app/server: 77 → 95 (+18)
16 wire-shape validator
10 Fireblocks
7 persistence + dispatch-for-swap
13 Utila
13 GCP KMS + cloudHsmDigest + algorithm allow-list
18 NEW: 6 AWS + 3 Azure + 3 Vault Transit + 5 f-chain + 1 ed25519 reject
Typecheck: clean on both packages
No STUBs remain. Every adapter is a real network call with a real error
mapping. Workload-identity-only by design (no SA-JSON-key fallback).
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.
Replaces the runUtila STUB with a real evm_personal_sign attestation
flow against @luxfi/utila Connect-RPC. After this commit the layered
cosigner feature is feature-complete for BOTH providers — no stubs
remain in app/server/src/domain/cosigners.ts.
Flow:
1. Build per-tenant gRPC client via createGrpcClient + serviceAccount-
AuthStrategy with { email: intent.org_id, privateKey: () => secretPem }
— mirrors the env-singleton client in domain/utila.ts but isolates
each tenant's auth context so multiple tenants cosign in parallel
without singleton contention.
2. initiateTransaction({
parent: vaults/<intent.vault_id>,
details: { details: { case: 'evmPersonalSign', value: {
fromAddress: intent.client_id, messageHex: opts.txHash
} } },
priority: NORMAL, note: 'lux-bridge cosign swap=<id>'
})
vault_id is REQUIRED — utila scopes initiateTransaction by parent
vault. Missing vault_id returns 'failed' before any RPC.
3. Poll getTransaction(name) every 1.5s, partition Utila's
TransactionState_Enum (proto path:
pkg/utila/src/lib/gen/utila/api/v1/transactions_pb.ts) into:
approved SIGNED=4, AWAITING_PUBLISH=5, PUBLISHED=6,
MINED=7, CONFIRMED=13
rejected DECLINED=9, CANCELED=11, DROPPED=12, EXPIRED=15
failed FAILED=8, REPLACED=10
If state is in 'approved' but evmMessage.signature isn't populated
yet (Utila publishes the sig in a later state transition), keep
polling — don't bail. Intermediates (AWAITING_APPROVAL=2,
AWAITING_POLICY_CHECK=14, AWAITING_SIGNATURE=3) also keep polling.
4. 60s timeout ceiling (UTILA_COSIGNER_TIMEOUT_MS env override).
Dynamic read on each call so config picks up without a restart.
5. Return CosignResult with signature converted from Uint8Array proto
bytes to 0x-prefixed hex; externalId = Utila transaction.name.
Edge cases covered by 13 tests (mocked @luxfi/utila):
- happy: SIGNED + signature → approved
- happy: CONFIRMED (later state, sig already attested) → approved
- happy: SIGNED-without-sig keeps polling, picks up next tick
- rejected: DECLINED, CANCELED, EXPIRED, DROPPED — all map to rejected
- failed: FAILED proto state
- failed: initiateTransaction throws (auth: bad jwt)
- failed: initiateTransaction returns no transaction.name
- failed: timeout (AWAITING_* indefinitely)
- failed: missing vault_id → fail BEFORE any RPC call
- failed: env-var fallback unset → clear KMS-TODO error
- wire-shape assertion: initiateTransaction is called with the right
parent + evmPersonalSign + fromAddress + messageHex + note
Test infra:
- vitest.config.ts gains a resolve.alias mapping @luxfi/utila to its
workspace src/grpc-client.ts. The published 3.0.0 rollup-built
dist/ is what npm consumers see, but the workspace package's
local rollup config has a long-standing outDir-vs-file-path issue
that prevents in-place builds. Vitest resolves the TS source
directly and runtime production is unaffected.
Tests: 91 → 104 total (58 SDK + 46 server).
Server breakdown: 16 validator + 10 Fireblocks + 7 persistence + 13 Utila.
After this commit there are no STUBs in the cosigner module. The
remaining work on #386 is the KMS round-trip in fetchCosignerSecret
(env-var fallback works for dev; production needs KMS path read).
User correction: 'utila was already 100% complete... please make sure
it's done' — addressed by replacing the runUtila STUB with the real
implementation that reuses the @luxfi/utila Connect-RPC machinery
already in the workspace.
Closes the persistence + state-machine gating gap from v1.1.12. After
this commit, layered cosigners are 2-of-2 enforced end-to-end on the
backend.
Prisma:
+ model CosignerStep — one row per intent. Public IDs only:
kind 'utila' | 'fireblocks'
public_id org_id (utila) or api_key (fireblocks)
api_host?, vault_id?
status 'pending' (default) | 'approved' | 'rejected' | 'failed'
signature?, reason?, external_id?
+ Swap.cosigner_steps relation (1:N)
Indexed on swap_id + status for the dispatch query.
Persistence path:
handleSwapCreation(...)
└─ persistCosignerIntents(swapId, cosigners) — Prisma ,
one create() per intent, all status='pending'. No-ops when no
intents listed (backwards compat — zero cost for native-MPC-only
tenants).
Dispatch path (issue #386 trust-boundary enforcement):
handlerUpdateMpcSignAction(swapId, txHash, ...)
└─ dispatchCosignersForSwap(swapId, signature, txHash)
├─ findMany({ swap_id, status: 'pending' })
├─ rowToIntent(...) — invert persistence
├─ dispatchCosigners(...) — runs Fireblocks (real) / Utila (stub)
├─ update each step with terminal state
└─ aggregate: 'all_approved' | 'any_rejected' | 'any_failed' | 'no_steps'
If aggregate is rejected/failed:
- Swap.status → SwapStatus.Failed
- Swap.fail_reason → 'Layered cosigner rejection: <kind>: <reason>'
- Never advances to UserPayoutPending; user payout step is unreachable
without 2-of-2 (native + every listed cosigner).
- cosigner_steps included in the response payload for traceability.
If aggregate is no_steps (native-only swap):
Existing code path runs unchanged — proves out the additive, opt-in
design from the SDK side.
Tests: 7 new persistence + dispatch tests (vi.hoisted Prisma mock).
- SDK: 58/58 unchanged
- Server: 26 → 33 (16 validator + 10 Fireblocks + 7 persistence)
What's still STUB after this commit:
- runUtila — waiting on the first Utila tenant to model the
InitiateTransaction subtype against their TAP policy
- fetchCosignerSecret KMS round-trip — env-var fallback works for
local dev; production needs the KMS path read
The Prisma schema change requires `prisma migrate dev` (in dev) +
`prisma migrate deploy` (in CI) to apply the CosignerStep table to
existing databases. The migration file itself is left for the new
dev to generate against the target Postgres instance — autogenerated
migrations need a connected DB and shouldn't be committed blind.
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.
The patch-hanzogui-exports.cjs script is the only supported way to
make `pnpm install && pnpm exec vite build` succeed against the broken
@hanzo/[email protected] publish. Swallowing its exit code with `|| true`
hides the only signal we have that the workaround stopped applying —
e.g. if a future pnpm release changes traversal semantics or upstream
republishes the SDK and the patcher trips on an unexpected layout, the
install would still report success and the failure would surface much
later at the vite build step with a confusing module-resolution error.
Verified `rm -rf node_modules && pnpm install --frozen-lockfile`
completes end-to-end with the script exiting 0
(`patched 240 package(s); fixed 41 source file(s)`). Red Phase 3 R2 nit.
bridge-ui ships as a prebuilt Vite SPA inside ghcr.io/hanzoai/spa
(see app/bridge/Dockerfile final stage), not a Next.js dev container.
The compose + k8s manifests were still describing the old Next shape:
containerPort 3000, `npm run dev`, /app/.next volume mount, and
NEXT_PUBLIC_* env vars. None of that exists in the image anymore.
- compose.testnet.yml / compose.mainnet.yml: port 3001:80 (was 3001:3000);
drop command + .next mount; swap NEXT_PUBLIC_* for VITE_BRIDGE_* env
(the names app/bridge/src/bridge.config.ts reads).
- k8s/mpc-deployment.yaml: containerPort 80 (was 3000); Service
targetPort 80 (was 3000 — broken since the static-SPA cutover); swap
NEXT_PUBLIC_* for VITE_BRIDGE_* on the Deployment env.
- compose.yml: keep local hot-reload UX by mounting the repo root and
running `pnpm -C app/bridge dev --host 0.0.0.0 --port 3001` against
node:22-alpine. Dev port moves 3000 -> 3001 to match the published
SPA convention across envs.
- scripts/start-local.sh: update printed UI URL to :3001.
compose.infra.yml and compose.local.yml don't define bridge-ui — left
untouched. scripts/launch-mpc-bridge.sh already prints :3001 and has no
Next-specific config; check-ci-status.sh only references bridge-ui as a
GHCR package name (image preserved per spec). Both verified clean.
Validated:
- docker compose -f compose.{yml,testnet,mainnet,infra,local}.yml config -q
- kubectl --dry-run=client apply -f k8s/mpc-deployment.yaml
Adds a third matrix entry to docker.yml so app/bridge/Dockerfile is
built and pushed to ghcr.io/luxfi/bridge-ui on every main/dev/test push
and v* tag. Multi-arch (linux/amd64 + linux/arm64) and tag policy are
inherited from the shared hanzoai/.github docker-build.yml reusable
workflow (same as the existing bridge + bridge/server entries).
The bridge-ui image is referenced by compose.yml, compose.testnet.yml,
compose.mainnet.yml, and k8s/mpc-deployment.yaml. Without this job the
image was orphaned — the Makefile build-ui target was the only producer
and no workflow invoked it.
The Lux tenant config now imports getColorSVG() from @luxfi/logo
(https://github.com/luxfi/logo) and inlines it as a data URL on the
BrandConfig.logoUrl field. Replaces the prior env-var-only path with
a deterministic, single source of truth — the @luxfi/logo package.
Consumers can still override at deploy time via VITE_BRIDGE_LOGO_URL.
No other bridge code touches Lux branding — the SDK stays
jurisdiction-neutral.
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.