17 KiB
cmd/bridge — Operator Runbook
Operating the unified Lux Bridge binary (cmd/bridge). One image,
one port, embedded SPA + native /v1/bridge/* API + background drivers
(deposit watcher, MPC signing, broadcast).
This binary replaces the legacy bridge-server (Express + Prisma) and
bridge-ui (separate Vite SPA) deployment split.
1. Overview
| Aspect | Value |
|---|---|
| Binary | cmd/bridge |
| Image | ghcr.io/luxfi/bridge:<tag> |
| Listen port | :8080 (HTTP) |
| Health endpoint | GET /health (200 JSON) |
| Config file | /etc/bridge/networks.yaml |
| Data directory | /var/lib/lux-bridge (zapdb / Lux Badger v4) |
| Logger | luxfi/log (JSON, one line per event) |
| HTTP framework | hanzoai/zip on Fiber v3 / fasthttp |
| Swap store | luxfi/zapdb (embedded KV, exclusive dir lock) |
Routes:
/ embedded SPA + SPA-routing fallback
/envs.js runtime config (window.ENV)
/icon.svg, /logo.svg per-host brand assets (disk override possible)
/health service health + driver stats
/v1/bridge/networks supported chains (from networks.yaml)
/v1/bridge/tokens tokens per chain
/v1/bridge/quote price quote
/v1/bridge/limits per-token min/max swap caps
/v1/bridge/swaps create / list swaps
/v1/bridge/swaps/:id fetch / update swap
/v1/bridge/check-deposit ops diagnostic (poll source-chain RPC)
/metrics Prometheus exposition
Background drivers (all on by default, all toggleable):
| Driver | Reads from | Writes to | Default cadence |
|---|---|---|---|
| DepositWatcher | source-chain RPC | swap.State advancement |
15s |
| SigningDriver | MPC daemon | swap.DestRawTx |
5s |
| BroadcastDriver | dest-chain RPC | swap.State = completed |
5s |
2. Build & ship
Local build (smoke test)
go build -o /tmp/lux-bridge ./cmd/bridge
/tmp/lux-bridge --config cmd/bridge/networks.example.yaml --addr :8080
curl -sS localhost:8080/health | jq .
Docker image
The canonical Dockerfile is cmd/bridge/Dockerfile. Three stages:
- ui-build —
node:20-alpine,pnpm install+pnpm -C app/bridge build - go-build —
golang:1.26.3-alpine, compilescmd/bridgewith embedded SPA - runtime —
gcr.io/distroless/static-debian12:nonroot, ~25 MB
Build from the repo root (build context must include the pnpm workspace):
docker build \
-f cmd/bridge/Dockerfile \
--build-arg VERSION=$(git rev-parse --short HEAD) \
-t ghcr.io/luxfi/bridge:$(git rev-parse --short HEAD) \
-t ghcr.io/luxfi/bridge:latest \
.
Push:
docker push ghcr.io/luxfi/bridge:$(git rev-parse --short HEAD)
docker push ghcr.io/luxfi/bridge:latest
3. Deploy to k8s
Manifest: k8s/bridge-deployment.yaml. Apply after the MPC StatefulSet
from k8s/mpc-deployment.yaml is healthy (the bridge talks to
mpc-api-svc.lux-bridge.svc:8081).
kubectl apply -f k8s/bridge-deployment.yaml
kubectl -n lux-bridge rollout status deployment/lux-bridge
The manifest creates: ConfigMap/lux-bridge-config, Deployment/lux-bridge,
Service/lux-bridge, Ingress/lux-bridge, HorizontalPodAutoscaler/lux-bridge,
PodDisruptionBudget/lux-bridge.
Required pre-existing resources
| Resource | Created by |
|---|---|
Namespace/lux-bridge |
k8s/mpc-deployment.yaml |
Secret/bridge-secrets |
KMS sync (see mpc-deployment header) |
Service/mpc-api-svc |
k8s/mpc-deployment.yaml |
| TLS cluster issuer | cert-manager install |
Rollout
# Pin a specific tag (do not run on :latest in prod)
kubectl -n lux-bridge set image deployment/lux-bridge \
bridge=ghcr.io/luxfi/bridge:<git-sha>
# Watch the rollout
kubectl -n lux-bridge rollout status deployment/lux-bridge --watch
Rollback
kubectl -n lux-bridge rollout undo deployment/lux-bridge
kubectl -n lux-bridge rollout history deployment/lux-bridge
The deployment uses RollingUpdate with maxUnavailable: 0 — a bad image
will not take down traffic; old pods stay up until new pods become Ready.
4. Configuration
All flags accept either a CLI argument or the matching env var. Env vars are the standard way to configure inside the container.
Required-ish
| Env / Flag | Default | Notes |
|---|---|---|
BRIDGE_CONFIG / --config |
/etc/bridge/networks.yaml |
Loaded from ConfigMap |
BRIDGE_ADDR / --addr |
:8080 |
|
BRIDGE_DATA_DIR / --data-dir |
"" |
Empty = in-memory swap store (lossy on restart). Set to a writable path on a PV for durability — e.g. /var/lib/lux-bridge. |
BRIDGE_PROFILE / --profile |
classical-compat |
strict-pq for internal Lux↔Lux only |
Backend wiring
| Env / Flag | Default | Behavior when empty |
|---|---|---|
BRIDGE_BACKEND_URL / --backend |
"" |
Reverse proxy to legacy Node backend disabled — all /v1/bridge/* use native handlers |
BRIDGE_BCHAIN_URL / --bchain-url |
"" |
BridgeVM native handlers disabled — swap/quote stays on the legacy proxy if --backend is also set |
BRIDGE_MPC_URL / --mpc-url |
"" |
MPC keygen disabled — swaps with use_deposit_address=true return 503 |
BRIDGE_MPC_TOKEN / --mpc-token |
"" |
Required for the live mpcd daemon — every endpoint except /health is bearer-protected |
BRIDGE_MPC_IDENTITY_FILE / --mpc-identity-file |
"" |
Convenience: derives the bearer token via SHA-256(seed‖"mpc-internal-api"). Use in dev; prod sets BRIDGE_MPC_TOKEN explicitly |
BRIDGE_MPC_ORG_ID / --mpc-org-id |
bridge |
Multi-tenant key the MPC daemon multiplexes by |
Source RPC overrides
BRIDGE_SOURCE_RPC_OVERRIDES / --source-rpc-overrides overrides
specific source-chain RPCs at runtime when the package defaults
(rpc.sepolia.org etc.) are stale or rate-limited. Format:
NETWORK1=URL1,NETWORK2=URL2
Used by both the deposit watcher (source) and the broadcast driver (destination) — many networks are both a source and a destination.
Example:
BRIDGE_SOURCE_RPC_OVERRIDES="ETHEREUM_SEPOLIA=https://ethereum-sepolia-rpc.publicnode.com,BITCOIN_TESTNET=https://mempool.space/testnet/api"
Driver toggles + cadence
| Env / Flag | Default | Effect when set |
|---|---|---|
--disable-deposit-check |
off | /v1/bridge/check-deposit returns 404 |
--disable-deposit-watcher |
off | Swaps never advance from user_deposit_pending |
--disable-signing-driver |
off | Swaps stall in bridge_transfer_pending |
--disable-broadcast-driver |
off | Signed txs never broadcast |
--deposit-watcher-interval |
15s | Polling cadence (source-chain RPC load) |
--signing-interval |
5s | MPC signing polling cadence |
--broadcast-interval |
5s | Broadcast polling cadence |
--broadcast-timeout |
15s | Per-RPC broadcast call timeout |
--deposit-check-timeout |
10s | Per-RPC source-poll timeout |
--bchain-timeout |
10s | Per-RPC b-chain call timeout |
--mpc-timeout |
120s | Per-MPC call timeout (matches mpc-wallet.ts) |
Static assets
| Env / Flag | Default | Effect |
|---|---|---|
BRIDGE_STATIC_DIR / --static |
"" |
Override the embedded SPA from disk (dev only) |
5. Updating networks.yaml
The supported chains, tokens, limits, and brand are all driven from
networks.yaml — mounted from the lux-bridge-config ConfigMap.
# Edit the ConfigMap in place
kubectl -n lux-bridge edit configmap lux-bridge-config
# Restart pods to pick up the change (the binary reads the file once at boot)
kubectl -n lux-bridge rollout restart deployment/lux-bridge
Adding a network requires no code change and no DB migration. The
in-binary token registry (internal/tokens) covers detection +
calldata for known assets; networks.yaml controls what the SPA
and /v1/bridge/networks advertise.
6. Health, logs, metrics
Health
kubectl -n lux-bridge port-forward svc/lux-bridge 8080:80 &
curl -sS localhost:8080/health | jq .
Sample response:
{
"status": "ok",
"version": "<git-sha>",
"backend_proxy": false,
"bchain_rpc": true,
"mpc_keygen": true,
"deposit_check": true,
"deposit_watcher": true,
"signing_driver": true,
"broadcast_driver": true,
"profile": "BRIDGE_CLASSICAL_COMPAT_UNSAFE",
"post_quantum_end_to_end": false,
"watcher_stats": { "ticks": 12, "checks": 0, "advances": 0, "errors": 0 },
"signing_stats": { "ticks": 12, "signs": 0, "errors": 0 },
"broadcast_stats": { "ticks": 12, "attempts": 0, "successes": 0,
"failures": 0, "skipped_no_raw_tx": 0, "list_errors": 0 }
}
Use the driver stats blocks to spot misbehavior:
watcher.errorsrising → upstream source-chain RPC issuesbroadcast.failuresrising → destination chain rejecting our txssigning.errorsrising → MPC daemon misconfigured or down
Logs
JSON one-line-per-event via luxfi/log. Stream:
kubectl -n lux-bridge logs -f deployment/lux-bridge | jq -c
Filter for errors:
kubectl -n lux-bridge logs deployment/lux-bridge --tail=1000 \
| jq -c 'select(.level=="error")'
Metrics
Prometheus exposition at /metrics. Add to your scrape config:
- job_name: lux-bridge
kubernetes_sd_configs:
- role: pod
namespaces: {names: [lux-bridge]}
relabel_configs:
- source_labels: [__meta_kubernetes_pod_label_app]
regex: lux-bridge
action: keep
7. Common operations
Pause the pipeline (incident response)
Disable all background drivers without taking the API down:
kubectl -n lux-bridge set env deployment/lux-bridge \
BRIDGE_DISABLE_DEPOSIT_WATCHER=true \
BRIDGE_DISABLE_SIGNING_DRIVER=true \
BRIDGE_DISABLE_BROADCAST_DRIVER=true
Note: env-var names mirror the CLI flags. To reverse, drop the vars with
kubectl set env … BRIDGE_DISABLE_…-.
Drain before maintenance
kubectl -n lux-bridge scale deployment/lux-bridge --replicas=0
# do work
kubectl -n lux-bridge scale deployment/lux-bridge --replicas=2
The PodDisruptionBudget keeps at least 1 pod up during cluster drains.
Force a config reload
kubectl -n lux-bridge rollout restart deployment/lux-bridge
Override an upstream RPC at runtime
kubectl -n lux-bridge set env deployment/lux-bridge \
BRIDGE_SOURCE_RPC_OVERRIDES="ETHEREUM_SEPOLIA=https://ethereum-sepolia-rpc.publicnode.com"
Add a new token mapping
The binary's in-memory tokens.DefaultRegistry() covers the common
bridged assets. To add a new ERC-20 mapping that the deposit watcher +
tx assembler should recognize, edit internal/tokens/tokens.go and
ship a new image — there is no runtime token-registration endpoint yet.
networks.yaml Token entries control SPA listings and per-token caps
only; they do not wire detection or calldata. Detection +
calldata live in internal/tokens.
8. Troubleshooting
Pod won't start: bridge profile invalid
BRIDGE_PROFILE must be strict-pq or classical-compat. Anything
else exits 1 (this is intentional — typo = wrong security posture).
mpc_keygen: false in /health
BRIDGE_MPC_URL is unset or unreachable. Check:
kubectl -n lux-bridge get svc mpc-api-svc
kubectl -n lux-bridge get statefulset mpc-node
Without MPC keygen, swap creates with use_deposit_address=true
return 503.
signing_driver: false despite MPC enabled
mchainClient is nil → BRIDGE_MPC_URL was empty at boot. Set it,
roll the deployment. If MPC is reachable but signing still errors,
check the bearer token (BRIDGE_MPC_TOKEN) and the org id
(BRIDGE_MPC_ORG_ID).
Source-chain checks failing
Default RPCs (rpc.sepolia.org, etc.) get rate-limited. Override with
BRIDGE_SOURCE_RPC_OVERRIDES. Track watcher_stats.errors in
/health to confirm the fix.
Watcher / signing / broadcast all running but no progress
Check the swap is actually in the right state. Use the swap CRUD:
curl -sS localhost:8080/v1/bridge/swaps/<id> | jq .state
State machine:
user_deposit_pending
→ bridge_transfer_pending (watcher: deposit confirmed)
→ bridge_transfer_pending_signing (signing: MPC asked)
→ bridge_transfer_pending_broadcast (signing: tx signed)
→ completed (broadcast: pushed onchain)
9. Persistence + backups
The swap store (internal SwapStore interface) has two implementations:
InMemoryStore— used whenBRIDGE_DATA_DIRis empty. Concurrency-safe, zero ops cost, but every restart drops in-flight swap state. Dev only.ZapStore— used whenBRIDGE_DATA_DIRis set. Backed byluxfi/zapdb(Lux-flavored Badger v4). LSM-tree KV with WAL durability, atomicdb.Updatetransactions, MVCC concurrency control. Takes an exclusive directory lock — only one bridge pod can hold the data dir at a time.
Single-writer constraint
Because zapdb takes an exclusive dir lock, the k8s Deployment is pinned to:
replicas: 1
strategy: {type: Recreate}
Two pods can't share /var/lib/lux-bridge. The Recreate strategy ensures
the old pod fully terminates (and releases the lock) before the new one
mounts the PVC. This costs zero-downtime rollouts — expect ~10s of API
gap during a deploy. The deposit watcher's 15s poll cadence absorbs this
without losing real progress.
Scaling beyond one pod requires migrating swap state to a shared external store (Postgres, hanzoai/base SQLite served behind an API, etc.). Not on the roadmap yet — single-replica covers the bridge's expected load.
Disk layout
Inside /var/lib/lux-bridge:
000000.vlog value log (immutable)
000001.vlog ...
000002.vlog currently being written
000003.sst level-0 sorted string table
MANIFEST the LSM tree's index
KEYREGISTRY encryption-at-rest key registry (we don't use)
LOCK the exclusive directory lock
Typical size: hundreds of bytes per swap. 1 GB holds millions of swaps. The 20Gi PVC in the manifest is generous head-room.
Backups
zapdb supports a streaming backup primitive. For first-deploy ops, a filesystem snapshot of the PV (cloud snapshot, Velero, etc.) while the pod is paused is sufficient.
Pause-snapshot-resume:
kubectl -n lux-bridge scale deployment/lux-bridge --replicas=0
# wait for pod termination, then snapshot the PV however your cloud does it
kubectl -n lux-bridge scale deployment/lux-bridge --replicas=1
In-flight signing/broadcast that was mid-RPC will rerun on restart — the state machine is idempotent (the watcher re-detects the same deposit; signing checks for an existing signature before re-asking MPC; broadcast checks for the dest tx hash before re-sending).
Drift recovery
If the LOCK file is stale (e.g. a node crash left the file behind),
the new pod's NewZapStore() will fail with Cannot acquire dir lock. Inspect the data dir, confirm no other process holds it, then:
# inside the pod — preserves all data, just drops the lock file
rm /var/lib/lux-bridge/LOCK
Restart the pod after.
10. Known gaps
These are tracked separately and not operationally fixable:
- Non-EVM broadcasters (BTC, SOL, TON, XRP, DOT) return
ErrFamilyNotImplemented. Add per-chain RPC clients tointernal/broadcastto extend coverage. - EIP-1559 gas pricing not implemented in
internal/txassembler. EVM txs assemble with legacy (gasPrice) pricing. Most destination chains still accept legacy txs. - Substrate SS58 address encoding not implemented.
- LP-333 BridgeVM client methods are stubbed —
--bchain-urlcontrols whetherbchain.Clientis constructed, but the handlers it backs are read-only. - Single-writer swap store. Horizontal scale-out requires migrating to a shared external store.