The header named the signing protocol — `MPC · CGGMP21` — and a transfer row badged it again. Neither is something a visitor can do anything with, and the header is the surface everybody reads. The copy now says what a person needs: which network, that a deposit address is involved, that no single party can release the funds. The cluster still runs whatever BRIDGE_MPC_PROTOCOL selects. The pickers could not be operated from a keyboard. Every key was answered on the menu and nothing ever focused the menu, so opening with Enter left the arrows dead. They are answered on the trigger now, which holds focus for the whole interaction, with aria-activedescendant naming the row the cursor is on. Each picker takes a label bound to it or a name read with it, and the prop type admits no third shape. The brand primary is #000000 and the SDK painted its selected row, its live phase dot, the refuel toggle and the header's only call to action with it — black on a black page. Marking a thing is the neutral scale's job. The accent stays as the published brand hook; nothing inside the SDK reads it. The rest is the page reading like the exchange it sits beside: a flat ground, panels separated by an edge rather than a shadow, one type scale, field names in one case, and hints raised out of the ink you have to lean in for. @luxfi/bridge 1.0.9.
34 KiB
Lux Bridge
Project: Lux Bridge Organization: Lux Network
Project Overview
The Lux Bridge is a decentralized cross-chain bridge infrastructure that enables secure, trustless asset transfers between multiple blockchain networks using Multi-Party Computation (MPC) technology. It serves as the primary interoperability layer for the Lux ecosystem, connecting 15+ blockchain networks.
Package layout — public vs internal
Two scopes, one canonical SDK entrypoint. Downstream consumers (Lux, Hanzo,
Zoo, any white-label) import @luxfi/bridge and nothing else.
| Path | npm name | Scope | Published |
|---|---|---|---|
pkg/bridge/ |
@luxfi/bridge |
public SDK | pending — see §Publishing |
pkg/core/ |
@luxfi/core |
public shared types | yes (last 10.0.5, 2025-05-09) |
pkg/threshold/ |
@luxfi/threshold |
public MPC SDK | pending — see §Publishing |
pkg/utila/ |
@luxfi/utila |
public utila client | yes (last 3.0.0, 2024-11-21) |
pkg/settings/ |
@luxbridge/settings |
private workspace | no |
pkg/ui/ |
@luxbridge/ui-automation |
private workspace | no |
app/bridge/ |
@luxbridge/lux-tenant |
private workspace | no |
app/explorer/ |
@luxbridge/explorer |
private workspace; migration to ghcr.io/luxfi/explorer planned — see §Explorer migration |
no |
app/server/ |
@luxbridge/server |
private workspace | no |
Rules:
@luxfi/bridgeis the ONLY package consumers import. It re-exportsmountBridge,Bridge, and the config/brand types. Internals are hidden.@luxbridge/*packages are workspace-only. They are not published and must not be imported by anything outside this repo.@luxfi/{core,threshold,utila}are generic Lux building-block libraries — bridge-agnostic, may be reused elsewhere in the Lux ecosystem.
Canonical brand sourcing — @luxfi/brand is the only source
Every Lux-ecosystem tenant app — bridge, exchange, id, pay, etc. — pulls
its brand identity from one package: @luxfi/brand (source repo:
~/work/lux/brand). No Lux-specific brand strings, colors, logos, or
support email addresses are baked into the SDK or any other downstream
artefact.
How the bridge tenant does it (app/bridge/src/bridge.config.ts):
import luxBrandJson from '@luxfi/brand/brand.json'
const { shortName, primaryColor, supportEmail } = luxBrandJson.brand
export const bridgeConfig: BridgeConfig = {
apiHost: env('BRIDGE_API_HOST', 'https://api.bridge.lux.network')!,
brand: {
name: `${shortName ?? 'Lux'} Bridge`, // composed at runtime
primaryColor: primaryColor ?? '#000000',
supportEmail: supportEmail ?? '[email protected]',
},
// …
}
Rules:
- The SDK (
pkg/bridge/) is jurisdiction-neutral. It declares no Lux-specific brand. Tenants supply brand at mount time viaBridgeConfig.brand. The SDK has no direct dependency on@luxfi/brand— that'd be a layering violation (Zoo, Hanzo, or any white-label tenant must be able to consume@luxfi/bridgewithout pulling Lux brand metadata into their bundle). - App-specific endpoints (
api.bridge.lux.network,docs.lux.network/bridge) belong to the bridge product and are independent of@luxfi/brand'sappDomain(lux.exchange— that's the exchange product's domain). - SSR / no-window fallbacks in the SDK derive from
cfg.apiHostrather than hardcoding a Lux URL — seederiveAppOrigininpkg/bridge/src/app/lib/wagmi-config.ts.
Other Lux apps follow the same pattern: ~/work/lux/exchange reads
the full brand block; ~/work/lux/id reads name + primaryColor +
appDomain; etc. If you find a hardcoded brand string in any Lux app,
it's a leak — file it as a follow-up issue and route the value through
@luxfi/brand.
History — what got folded together
app/bridge3/(formerly@luxbridge/app-v3) was folded intopkg/bridge/src/app/at commitabed909("feat(pkg/bridge): inline bridge UI under src/app/", phase 1.5). The old lazy-loaded workspace cycle that produced the blank-page bug is gone.Bridge.tsxno longer references any sibling workspace package.app/bridge/was collapsed to a single canonical tenant build at6bc55b2("phase2-r2: collapse to canonical app/bridge tenant"). It is now@luxbridge/lux-tenant(~120 LOC:bridge.config.ts,main.tsx,index.html+ Vite config), not the multi-variant Next.js app shown in stale diagrams.
SDK mount pattern
import { mountBridge } from '@luxfi/bridge'
mountBridge({
config: {
apiHost: 'https://api.bridge.lux.network',
env: 'mainnet',
brand: { name: 'Lux Bridge', primaryColor: '#0066ff' },
},
})
Mirrors mountExchange from the upstream exchange SDK. One declarative entry,
build-time brand config, no hostname detection inside the SDK.
What the SDK's chrome may say, and what it paints with
Two rules the UI is built to, both of which were violated and are now structural rather than advisory:
The page never names the signing protocol. The header used to carry an
MPC · CGGMP21 chip and a transfer row used to badge t.mpc.protocol. A
protocol name is not something a visitor can act on, and the header is the one
surface everybody reads. What a person needs is which network they are on, that
a deposit address is involved, and that no single party can release funds — all
of which the copy now says in those words. BRIDGE_MPC_PROTOCOL still selects
what the cluster runs; it just does not select what the page says. The rendered
page, its attributes and every open menu are checked against the protocol
vocabulary in the browser, not by grep over source.
The SDK's own chrome does not paint with --bridge-accent. The token is
bound to --brand-primary, and Lux's brand primary is #000000 — so the
selected option's label, the live phase dot, the refuel toggle and the header's
only call to action were all being drawn black on a black page. Marking a thing
is the neutral scale's job (--bridge-text / --bridge-text-muted /
--bridge-text-subtle), which is legible whatever a tenant sets. The accent
stays defined as the published brand hook for host pages; nothing inside the
SDK reads it.
Two smaller ones that follow from those. Every picker takes either label (a
<label> bound to the trigger) or name (read, not drawn) — the prop type
admits no third shape, so a nameless control does not compile. And every key a
picker answers is handled on the trigger, which holds focus for the whole
interaction, with aria-activedescendant naming the row the cursor is on;
handling them on the menu meant the arrows were dead, because nothing ever
focused the menu.
Publishing
.github/workflows/publish.yml fires on v* tag push, runs on the
self-hosted lux-build runner. Runs pnpm publish -r --access public --no-git-checks — pnpm walks the workspace, skips private: true
packages, and publishes any @luxfi/* package whose version is not yet
on npmjs.org. Bump versions in PRs; tag once merged.
Status as of 2026-05-22: the workflow is wired and hardened (fail-fast
on missing NPM_TOKEN/NODE_AUTH_TOKEN; token baked into the
NPM_CONFIG_USERCONFIG path because pnpm doesn't expand ${VAR} in
.npmrc) but @luxfi/bridge and @luxfi/threshold are still 404 on
npmjs.org. Confirmed by @luxfi/core 10.0.5 and @luxfi/utila 3.0.0
both pre-dating every v1.1.x tag — no v1.1.x release has ever shipped
to npm. Most likely missing NPM_TOKEN secret on the luxfi/bridge repo
(Settings → Secrets and variables → Actions) OR an unavailable
lux-build runner pool. The v1.1.10 fail-fast guard will surface either
clearly on the next run.
Explorer migration
app/explorer/ (@luxbridge/explorer, Next.js standalone) is on a
deprecation path. The canonical replacement is ghcr.io/luxfi/explorer
— a single Go binary at ~/work/lux/explorer that:
- Indexes EVM chains via
luxfi/indexer - Exposes per-chain GraphQL via
luxfi/graph - Serves the embedded SPA from
luxfi/exploreviago:embed
Routes (served from one binary, port-flexible):
| Path | Purpose |
|---|---|
/ |
SPA (embedded; SPA-routing fallback) |
/envs.js |
Runtime window.ENV = {…} |
/v1/indexer/{slug}/* |
Per-chain explorer REST API |
/v1/graph/{slug}/{subgraph}/graphql |
Per-chain, per-subgraph GraphQL |
/v1/explorer/realtime |
WebSocket realtime hub |
Migration plan: deploy luxfi/explorer alongside app/explorer/,
hostname-cutover (bridge-explorer.lux.network) on K8s, delete the
Next.js app after one-week soak.
The bridge backend (app/server/) does not depend on app/explorer/;
the migration is UI-only.
Architecture Summary
Core Technology Stack
- SDK:
@luxfi/bridge— TypeScript source-published; React 18 + Tamagui (@hanzo/guiv7 +@hanzogui/*peer-optional primitives); wagmi 2 + viem 2@tanstack/react-query5 for wallet;@luxfi/thresholdfor native MPC threshold sign sessions
- Tenant apps: Vite + React 18.3.1 (
app/bridge/=@luxbridge/lux-tenant,app/explorer/=@luxbridge/explorer) - Backend: Node.js + Express + Prisma (
app/server/= bridge API atapi.bridge.lux.network) - MPC Framework: 2-of-3 threshold signature scheme using
github.com/luxfi/mpc(Go); client-side session helper via@luxfi/threshold(TS) - Smart Contracts: Solidity (OpenZeppelin), deployed on multiple chains
- Infrastructure: Docker, Kubernetes, PostgreSQL, NATS, Consul, Vault (KMS)
- Authentication: Lux ID (Casdoor) for unified auth
- Runtime config:
window.__ENV(templated fromBRIDGE_*env at container boot by nginx docker-entrypoint) with build-timeimport.meta.env.VITE_*fallback. One image, N envs.
System Architecture
┌──────────────────────────────────┐
Tenant app │ @luxfi/bridge (pkg/bridge/) │
(app/bridge/, │ exports: Bridge, mountBridge, │
app/explorer/) │ BridgeConfig, types │
imports SDK + │ │
brand: │ Bridge UI is INLINED at │
@luxfi/bridge ─▶ │ pkg/bridge/src/app/ │
@luxfi/brand │ (no @luxbridge/app-v3, no │
│ workspace cycle, no lazy load) │
│ │
│ Direct deps: │
│ @hanzo/gui, @luxfi/threshold, │
│ wagmi, viem, react-query │
└─────────────┬────────────────────┘
│
┌────────────────┴─────────────────┐
▼ ▼
┌───────────────────────┐ ┌──────────────────────┐
│ app/server │ │ @luxfi/threshold │
│ Bridge API │ │ MPC threshold SDK │
│ Express + Prisma │ │ (consumed by BOTH │
│ api.bridge.lux.network│ │ SDK + server) │
└──────────┬────────────┘ └──────────┬───────────┘
│ │
▼ ▼
┌─────────────────────┐ ┌──────────────────┐
│ b-chain │ │ m-chain (public │
│ Lux primary network │ │ MPC, m-chain) │
│ consensus + state │ │ │
└─────────────────────┘ │ + optional │
│ private cluster │
│ (treasury fees) │
│ │
│ + optional │
│ layered cosign: │
│ Utila │
│ Fireblocks │
└──────────────────┘
MPC Network Configuration
- Node Count: 3 nodes (2-of-3 threshold) on m-chain (public MPC)
- Ports:
- HTTP API: 6000-6002
- gRPC: 9090-9092
- NATS: 4223
- Consul: 8501
- Security: TLS 1.3, mutual authentication, HSM integration
- Protocols:
cggmp21(default),frost,bls,doerner(classical);pulsar(MLWE),corona(RLWE),magnetar(lattice research variant) — PQ-safe, leaderless, permissionless-safe by design.
Standalone mode — running the bridge without the Lux platform
The bridge can boot with the absolute minimum: Postgres + bridge-server + bridge-ui. Every other Lux service (IAM / KMS / m-chain MPC / cosigners) is optional at runtime — the backend graceful-degrades when the corresponding env block is unset and picks up the integration the moment the block appears.
docker compose -f compose.standalone.yml up --build
# bridge UI http://localhost:3001
# bridge API http://localhost:5000/healthz
# postgres localhost:5433 (user=bridge, pass=bridge, db=bridge)
What's intentionally NOT in compose.standalone.yml (but is in
compose.local.yml):
| Service | Why standalone can skip it |
|---|---|
| Casdoor (Lux ID) | bridge does not require auth in dev mode |
| Vault (KMS) | fetchCosignerSecret falls back to env-var pattern when BRIDGE_KMS_URL is unset |
| NATS | only mpc-service.ts consumes it; backend logs a warn and skips its initialise() |
| Consul | same — mpc-service.ts only |
| 3-node MPC cluster | LuxMPCClient is constructed on demand; native MPC sign requires it but cosigner-layered swaps don't |
To enable any of the optional layers, set the matching env block on the
bridge-server service in compose.standalone.yml (commented stubs are
already there). The bridge picks them up on next request — no restart
required for cosigner config, only BRIDGE_MPC_URL requires a restart (cached at server start).
ZAP serialization — canonical home is ~/work/zap, npm scope @zap-proto
The bridge currently uses plain JSON over HTTP for every wire call (mpcd, KMS, IAM, cosigner adapters, f-chain). It does not depend on any binary RPC codec. If a future surface needs Cap'n-Proto-style binary RPC (e.g. mpcd → bridge events stream), use ZAP — the canonical Lux / Hanzo zero-copy app protocol.
| Aspect | Canonical |
|---|---|
| Source repo | ~/work/zap (not ~/work/lux/zap or ~/work/hanzo/zap — those are forks / legacy clones being consolidated upstream) |
| GitHub org | zap-proto |
| npm scope | @zap-proto/* |
| TS package | @zap-proto/zap (lives at ~/work/zap/zap-js) — pure-TS codec, no third-party Cap'n Proto deps. The package.json once listed capnp-es in dependencies but no source file imports it; that dep is stale and slated for removal in the next ZAP TS publish |
| WASM binding | @zap-proto/wasm (~/work/zap/zap-wasm) |
| Go / Rust | wire-compatible siblings; same ZAP framing |
Rules for the bridge (and any other downstream consumer):
- Never
pnpm add capnp-esto add binary RPC. If we need Cap'n-Proto- style framing, use@zap-proto/zap(pure TS, no third-party dep). The whole point of ZAP is to be brand-neutral and wire-compatible across Go / Rust / TS without pulling in the upstream Cap'n Proto JS ecosystem. - The
@zap-proto/*scope is brand-neutral. Don't publish@luxfi/zap-*or@hanzoai/zap-*variants — they'd fork the wire format and break cross-stack RPC. - Source of truth for the wire format:
~/work/zap/README.md(Go) +~/work/zap/zap-js/src/protocol.ts(TS). Match either.
Foreign-brand bridge tenants — out of this repo
The canonical Lux cross-chain bridge protocol lives at ~/work/lux/teleport
(Warp 2.0 envelopes + m-chain custody, LP-021v2 / LP-7330). The
user-facing UI + SDK is ~/work/lux/bridge (this repo) and ships ONE
Lux-branded tenant (app/bridge/). Foreign-brand bridge surfaces
(Hanzo, Zoo, Pars, third-party) MUST live in their own repos and own
their brand assets there — typically a thin shim repo (e.g.
zooai/bridge-shim) that consumes @luxfi/bridge plus its own brand
package. No foreign-brand tenant manifests, logos, or color tokens
belong in luxfi/bridge. The SDK is the integration point; tenants
are thin and live where their brand lives.
Native Lux services — m-chain (MPC) + KMS + f-chain
The bridge backend talks to the canonical Lux ecosystem services via
HTTP clients in app/server/src/clients/:
lux-iam.ts—LuxIAMClient.mint(audience)mints OAuth2client_credentialsJWTs against Lux IAM (https://iam.lux.network). Per-audience cache with early refresh. The bridge mints one token foraud=lux-kmsand another foraud=lux-mpc; both rotate independently.lux-kms.ts—LuxKMSClient.getSecret(path)reads from~/work/lux/kms(https://kms.lux.cloud). Bearer JWT auth, path layoutbridge/cosigners/{utila|fireblocks}/{publicId}/{sa_pem|secret_pem}. Replaces every env-var fallback indomain/cosigners.ts::fetchCosignerSecret.lux-mpc.ts—LuxMPCClient.keygen({...})/.sign({...})/.status()againstmpcd(https://mpc.lux.network). This is THE m-chain entrypoint — the canonical Lux MPC daemon at~/work/lux/mpc, speaking the same wire protocol as the Go client at~/work/lux/kms/pkg/mpc/client.go. Supports the full Lux protocol set (CGGMP21 / FROST / BLS / SR25519 / Pulsar / Corona / Magnetar / Doerner). Per-call 120s timeout matches mpcd's threshold-completion ceiling.
Configuration via env (bridge backend startup):
BRIDGE_IAM_ISSUER https://iam.lux.network
BRIDGE_KMS_URL https://kms.lux.cloud
BRIDGE_KMS_ORG lux
BRIDGE_IAM_CLIENT_ID lux-bridge
BRIDGE_IAM_CLIENT_SECRET (from KMS at boot OR --kms-bootstrap-secret)
BRIDGE_MPC_URL https://mpc.lux.network
When BRIDGE_KMS_URL is unset (local dev), fetchCosignerSecret falls
back to per-tenant env vars. Production deployments MUST set the KMS
env block — env-var fallback is dev-only.
Lux software frontends (administrative UIs)
Three Lux ecosystem services ship their own administrative UIs separate from the bridge tenant SDK:
| Service | Frontend repo / path | Stack | Domain |
|---|---|---|---|
| m-chain (mpcd) | ~/work/lux/mpc/dashboard/ (@luxfi/mpc-dashboard) |
Next.js 15 + React 19 + zustand | mpc.lux.network/dashboard |
| KMS | ~/work/lux/kms/frontend/ (@hanzo/kms-frontend) |
Vite + React | kms.lux.cloud |
| f-chain (fhed) | DOES NOT EXIST — see below | — | — |
The bridge does not embed these dashboards; they're separate operator surfaces.
f-chain frontend — what it would look like
~/work/lux/fhe (the FHE daemon fhed) currently has no dedicated UI.
The bridge tenant's TRANSFERS panel surfaces per-swap f-chain
attestation status (a CosignerStep row when the bridge backend
processes a swap with fchain: { publicUrl, scheme } config), but
that's per-bridge-tenant, not a cluster-wide operator surface.
A standalone f-chain dashboard, when built, would mirror the mpc-dashboard layout and surface:
- Cluster status —
GET /v1/fhe/cluster/status: peer count, FHE scheme registry, share health per node. - Public key registry —
GET /v1/fhe/publickey: published FHE public keys per scheme (CKKS / BGV / BFV), with an export button. - Encryption sessions — recent
/v1/fhe/encryptcalls and their ciphertext bindings (txHash → ciphertext_hex), filterable by requesting service (bridge / pay / id / ...). - Reshare history —
/v1/fhe/cluster/reshareevents. - Health probe —
GET /v1/fhe/healthrolled up to per-node status.
Recommended scaffold (when scheduled):
~/work/lux/fhe/dashboard/ # new Next.js app, mirror of mpc/dashboard
package.json @luxfi/fchain-dashboard
app/ # Next.js app router
components/cluster/ # ClusterStatus, PeerList
components/keys/ # PublicKeyRegistry, ExportButton
components/sessions/ # EncryptHistory, CiphertextViewer
lib/fchain-client.ts # mirrors lux-mpc.ts / lux-kms.ts shape
Optional layered cosigners (since SDK v1.0.3)
External MPC custodians can be layered ON TOP of the native threshold network as additional cosigners. The bridge backend enforces 2-of-2 (native + layered) 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 SDK declares config only; the secret half (Utila JWT / Fireblocks
secret key) lives on app/server/ (sourced from KMS at boot). No secret
material is ever shipped in the page bundle.
mountBridge({
config: {
apiHost: 'https://api.bridge.lux.network',
env: 'mainnet',
mpc: {
publicUrl: 'https://mpc.lux.network',
protocol: 'cggmp21',
// optional — layered cosigner #1
utila: { orgId: 'tenant-x', clientId: 'lux-bridge' },
// optional — layered cosigner #2 (both may be enabled together)
fireblocks: { apiKey: 'pub-key-id', vaultAccountId: '0' },
},
},
})
Per-swap, the SDK forwards a cosigners[] array of public identifiers
(orgId, clientId, apiKey, vault ids) to POST /api/swaps. The
backend pairs each entry with a KMS-held secret and completes the cosign
on behalf of the tenant.
Supported Networks & Assets
Blockchain Networks (15+)
Layer 1: Lux, Ethereum, BSC, Polygon, Fantom Layer 2: Arbitrum, Optimism, zkSync Era, Polygon zkEVM, Base, Blast Other: TON, Solana (coming soon), Cosmos (via IBC)
Supported Assets
Stablecoins: USDT, USDC, DAI, ZUSD Native Tokens: LUX, ETH, BNB, MATIC Wrapped Assets: ZETH, ZBNB, ZPOL, ZTON (ERC20B standard)
A bridge pair is a WRAP, not a trade
app/server/src/domain/settings/swap-pairs.ts is the single declaration of what
routes exist. Take its connected components and all 33 are one underlying under
several names — {BTC, LBTC, WBTC, ZBTC, cbBTC}, {ETH, LETH, WETH, ZETH},
{LUX, ZLUX}, {DAI, LUSD, USDC, USDT, ZUSD}. No edge in the table joins two
different assets. So the conversion rate is 1, exactly, both directions, and
only the fee comes off it. domain/quote.ts convert() is the one place that
computes it; getQuote, getRate and the stored swap row all project from it.
Do not reintroduce a price lookup on this path. It used to be
amount * sourcePrice / destinationPrice against Coinbase, with getTokenPrice
answering 1 on failure. Coinbase lists 29 of the 38 symbols the bridge asks
about, so a third of lookups returned an invented $1.00 and a pair got one real
price and one fabricated one. Measured in production: 1000 ZLUX quoted 900,000
LUX, 1000 LZOO quoted 47,142,857 ZOO, 1000 LBTC quoted 64,267,132 cbBTC — and on
an exit handlerUtilaPayoutAction sets payoutAmount from the stored quote and
bridgeMints it, so those were unbacked mints, not display errors. A ratio built
from one real price and one invented one also inverts when the legs swap, which
is why the same route was wrong in opposite directions at once.
There is no price at which wrapping is not 1:1, so consulting a feed at all was
the error. getTokenPrice survives for the display-only USD fee and returns
undefined, never 1, when nobody quotes an asset.
Two invariants hold this together, both pinned in
domain/__tests__/quote.test.ts:
- The peg classes are pinned, not derived. Recovering them means stripping an
L/Z/W/cbprefix and comparing the remainder — inferring an asset's identity from the spelling of its ticker is the exact reasoning that caused the bug (it also readsLUXas a wrapped "UX"). A change to the partition is a change to the claim and wants human review. convert()is pure.handleSwapCreationcalls it beforecreateMPCWalletForDeposit(which mints a real custody address) andprisma.swap.create, so that an unroutable pair is refused before anything that outlives the request. That ordering is only safe while it stays pure.
The pair relation is symmetric and is read that way, not written twice. 33
rows name a destination that does not name them back (BTC: [LBTC], while
LBTC: [WBTC, ZBTC, cbBTC]), so a one-directional read offered a route inbound
and refused it outbound. 169 declared edges, 202 usable routes. Adding the
reverse entries by hand would just re-open the gap the next time an asset lands.
The client does not keep a copy of the table — /api/settings already
carries it, from the same table the server validates against. It used to
overwrite the response with a local copy that had drifted (missing DOT and XRP
entirely; no native SOL or TON as destinations), making six routable assets
unreachable in the UI.
Delivery (as of 2026-08-08) — CI cannot ship this repo
app/server changes reach production as: build → ghcr.io/luxfi/bridge-server
→ bump the digest in ~/work/lux/universe/deploy/lux-bridge/bridge-server.yaml
→ hanzo-cd syncs (apps.hanzo.ai/installation-id: hanzo-cd). Production runs a
digest-pinned image with imagePullPolicy: IfNotPresent.
Three things are stale or broken in that path:
- The self-hosted pool
lux-build-amd64has had no runner since 2026-07-30 (last real execution:evo-luxfi-*). Jobs queue and cancel at the 24h timeout — that is what thecancelled 24h0m2sruns are. Every recent luxfi success is onubuntu-latest, not the pool. server.ymlonly runskubectl rollout restart, which delivers nothing against a digest-pinned deployment, and names no namespace (production islux-bridge).app/server/k8s/bridge-deployment.yamlnamesghcr.io/luxfi/bridge/server:latestwithAlways. Production isghcr.io/luxfi/bridge-server@sha256:…. The repo manifest does not describe production.docker:buildanddocker:pushinapp/server/package.jsonalso disagree with each other on the image name.
Key Smart Contracts
Core Contracts
Bridge.sol- Main bridge contract with deposit/withdraw logicLuxVault.sol- Asset vault for Lux NetworkETHVault.sol- Ethereum vault contractZooVault.sol- Multi-asset aggregated vaultLERC4626.sol- Tokenized vault shares
Wrapped Token Contracts (ERC20B)
All wrapped tokens follow the ERC20B standard with mint/burn capabilities:
- Location:
/contracts/contracts/zoo/ - Examples: Z.sol, ZETH.sol, ZUSD.sol, ZBNB.sol
API Endpoints
REST API (Port 5000)
GET /api/v1/status- Bridge operational statusGET /api/v1/chains- Supported blockchainsGET /api/v1/assets- Supported tokensPOST /api/v1/quote- Get transfer quotePOST /api/v1/transfer- Initiate transferGET /api/v1/transfer/{id}- Transfer status
WebSocket API
- Subscribe to transfer updates
- Real-time status notifications
- Event streaming for bridge operations
Essential Commands
Development
# Clone and install
git clone https://github.com/luxfi/bridge
cd bridge
pnpm install
# Install MPC tools
make install-mpc
# Start infrastructure
make up
# Start MPC nodes
make start-mpc-nodes
# Start services
cd app/bridge && pnpm dev # UI on :3001
cd app/server && pnpm dev # API on :5000
Infrastructure Management
# Infrastructure
make up # Start all infra services
make down # Stop all services
make logs # View aggregated logs
# MPC Operations
make start-mpc-nodes # Start 3-node MPC network
make stop-mpc-nodes # Stop MPC nodes
make generate-mpc-keys # Generate new MPC keys
lux-mpc-cli status # Check MPC status
# Development
pnpm dev # Start development servers
pnpm build # Build for production
pnpm test # Run test suite
pnpm lint # Run linters
# Deployment
make deploy-testnet # Deploy to testnet
make deploy-mainnet # Deploy to mainnet
Architecture
The bridge uses a multi-layered architecture:
- Presentation Layer: React/Next.js UI for user interaction
- API Layer: Node.js server handling business logic
- MPC Layer: Distributed nodes for threshold signatures
- Blockchain Layer: Smart contracts on multiple chains
- Infrastructure Layer: Supporting services (DB, messaging, KMS)
Data Flow
- User initiates transfer on source chain
- Bridge contract locks assets in vault
- Event emitted and detected by MPC nodes
- MPC nodes reach consensus and generate signature
- Signed message relayed to destination chain
- Destination contract mints wrapped tokens
- User receives tokens on destination chain
Key Technologies
- Multi-Party Computation (MPC): Distributed key management
- Threshold Signatures: 2-of-3 security model
- Smart Contracts: Solidity with OpenZeppelin
- Message Queue: NATS for inter-node communication
- Service Discovery: Consul for dynamic configuration
- Key Management: HashiCorp Vault
- Authentication: Casdoor (Lux ID)
- Database: PostgreSQL for state management
- Container Orchestration: Docker/Kubernetes
Development Workflow
Local Development
- Start infrastructure:
make up - Initialize MPC network:
make start-mpc-nodes - Deploy contracts:
cd contracts && npx hardhat deploy - Start API server:
cd app/server && pnpm dev - Start tenant UI:
cd app/bridge && pnpm dev(Vite on :3001) - SDK dev: edit
pkg/bridge/src/— tenant Vite picks up changes live through the workspace symlink. No publish needed for local iteration.
Testing
- Unit tests:
pnpm test:unit - Integration tests:
pnpm test:integration - Contract tests:
cd contracts && npx hardhat test - E2E tests:
pnpm test:e2e
Deployment
- Build artifacts:
pnpm build - Deploy contracts:
make deploy-{network} - Update configuration
- Deploy services via CI/CD
- Verify deployment:
make verify-deployment
Configuration
Environment Variables
# Network
NETWORK=mainnet|testnet|local
CHAIN_ID=31337
# Database
DATABASE_URL=postgresql://user:pass@localhost:5433/bridge
# MPC
MPC_NODE_COUNT=3
MPC_THRESHOLD=2
# KMS (Vault)
VAULT_ADDR=http://localhost:8200
VAULT_TOKEN=<token>
# Authentication (Lux ID)
CASDOOR_ENDPOINT=http://localhost:8000
Service Ports
- Bridge UI (tenant
app/bridge/): 3001 (Vite dev/preview server) - Bridge API (
app/server/): 5000 (Express) - Bridge explorer (
app/explorer/, legacy): 3002 (Next.js) - Bridge explorer (
ghcr.io/luxfi/explorer, replacement): port-flexible - MPC Nodes: 6000-6002 (HTTP), 9090-9092 (gRPC)
- PostgreSQL: 5433 (bridge), 5434 (auth)
- Vault (KMS): 8200
- Lux ID: 8000
- NATS: 4223
- Consul: 8501
Security Best Practices
- Never commit secrets - Use environment variables
- Validate all inputs - Prevent injection attacks
- Use secure communication - TLS 1.3 everywhere
- Implement rate limiting - Prevent DDoS
- Regular security audits - Contract and code reviews
- Monitor everything - Logs, metrics, alerts
- Incident response plan - Be prepared for emergencies
Monitoring & Debugging
Health Checks
- Bridge API:
curl http://localhost:5000/health - MPC Nodes:
curl http://localhost:6000/health - Infrastructure:
http://localhost:8501/ui(Consul)
Debugging Commands
# Check MPC status
lux-mpc-cli status --url http://localhost:6000
# View logs
tail -f logs/mpc-node-*.log
# Database queries
psql $DATABASE_URL -c "SELECT * FROM transfers WHERE status='pending';"
# NATS monitoring
nats-top -s localhost:4223
Documentation
Comprehensive Documentation Site (Nextra)
- Location:
~/work/lux/bridge/docs/(Next.js + Nextra) - Pages:
docs/pages/*.mdx - Build:
cd docs && pnpm install && pnpm build - Dev:
cd docs && pnpm dev→ http://localhost:3000
Key Documentation Files
docs/LLM.md— this file, canonical AI/onboarding doc (single source of truth)docs/LUX-ID-INTEGRATION.md— Casdoor-based unified auth integrationpkg/bridge/README.md— consumer-facing SDK docs (mountBridge,BridgeConfig)- Top-level
README.md— quick-start + architecture pointer (kept consistent with this file)
The legacy migration .md files (BRIDGE-STATUS, LOCAL-SETUP, the four MPC
migration notes, DEPLOYMENT, CI-CD-DOCKER-IMAGES) were deleted in v1.1.12
(commit b04e805, issue #391) — they described an architecture from
before the SDK rationalization and confused new readers. Migration
history lives in git log and the GH release notes.
Context for All AI Assistants
This file (LLM.md) is symlinked as:
AGENTS.mdCLAUDE.mdQWEN.mdGEMINI.md
All files reference the same knowledge base. Updates here propagate to all AI systems.
Rules for AI Assistants
- ALWAYS update LLM.md with significant discoveries
- NEVER commit symlinked files (AGENTS.md, CLAUDE.md, etc.) - they're in .gitignore
- NEVER create random summary files - update THIS file
Note: This file serves as the single source of truth for all AI assistants working on this project.