Files
bridge/docs/operator-runbook.md
zeekay c817afd5c0 bridge: the operator surface gets its own listener
The swap list answered anonymously with every swap id, the MPC signature
minted for it and the signed destination tx, on the port the Ingress
publishes. /metrics named the release wallets and reported which of them
could not sign. The deposit poll turned an anonymous request into a
source-chain RPC call with caller-chosen arguments.

All three move to a second listener, BRIDGE_ADMIN_ADDR, along with the
swap mutators, which still need BRIDGE_ADMIN_ROUTES on top. The edge
routes a port, so a port is the thing that can be left unrouted: k8s
publishes 8080 and gives the operator surface 8081, and the ServiceMonitor
scrapes there. A swap read by id keeps the swap's progress and drops the
signature and the raw tx; the list on the operator port carries both.

The legacy proxy exposed the same list twice. `/api/swaps/*` matched the
empty remainder and so forwarded GET /api/swaps to the backend's list;
`+` requires a sub-path. `/v1/bridge/swaps/*` forwarded arbitrary
sub-paths into /swaps/… after the prefix strip — the backend mounts its
swap router at /api/swaps, so that path space holds no routes and the
forwarder only ever produced a 404.
2026-09-01 19:39:22 -07:00

571 lines
21 KiB
Markdown

# 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) |
| Operator port | `:8081` (HTTP, not published by the Ingress) |
| 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 swap
/v1/bridge/swaps/:id fetch swap
```
Operator routes, on `BRIDGE_ADMIN_ADDR` and only there. They identify no
caller, and the Ingress publishes the public port alone:
```
/metrics Prometheus exposition
/v1/bridge/swaps list swaps, with signatures and signed dest txs
/v1/bridge/check-deposit poll a source-chain RPC for an address balance
/admin/swaps/:id/reset re-sign a swap (BRIDGE_ADMIN_ROUTES=true)
/admin/swaps/:id/inject-raw-tx push an operator's raw tx (same)
```
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)
```bash
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:
1. **ui-build** — `node:20-alpine`, `pnpm install` + `pnpm -C app/bridge build`
2. **go-build** — `golang:1.26.4-alpine`, compiles `cmd/bridge` with embedded SPA
3. **runtime** — `gcr.io/distroless/static-debian12:nonroot`, ~25 MB
Build from the repo root (build context must include the pnpm workspace):
```bash
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:
```bash
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`).
```bash
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
```bash
# 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
```bash
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` | The listener the Ingress publishes. |
| `BRIDGE_ADMIN_ADDR` / `--admin-addr` | `""` | The operator listener (`:8081` in k8s). Empty serves none of the operator routes — including `/metrics`. Give it a port the Ingress does not route. |
| `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.
```bash
# 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
```bash
kubectl -n lux-bridge port-forward svc/lux-bridge 8080:80 &
curl -sS localhost:8080/health | jq .
```
Sample response:
```json
{
"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.errors` rising → upstream source-chain RPC issues
- `broadcast.failures` rising → destination chain rejecting our txs
- `signing.errors` rising → MPC daemon misconfigured or down
### Logs
JSON one-line-per-event via `luxfi/log`. Stream:
```bash
kubectl -n lux-bridge logs -f deployment/lux-bridge | jq -c
```
Filter for errors:
```bash
kubectl -n lux-bridge logs deployment/lux-bridge --tail=1000 \
| jq -c 'select(.level=="error")'
```
### Metrics
Prometheus exposition at `/metrics` on the operator port. The series name
the MPC release wallets and report which of them cannot sign, so the scrape
target is the `admin` port, never the published one
(`k8s/bridge-servicemonitor.yaml`). Scraping by pod role instead:
```yaml
- 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
- source_labels: [__meta_kubernetes_pod_container_port_name]
regex: admin
action: keep
```
Reading it by hand:
```bash
kubectl -n lux-bridge port-forward svc/lux-bridge 8081:8081 &
curl -sS localhost:8081/metrics
```
---
## 7. Common operations
### Pause the pipeline (incident response)
Disable all background drivers without taking the API down:
```bash
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
```bash
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
```bash
kubectl -n lux-bridge rollout restart deployment/lux-bridge
```
### Override an upstream RPC at runtime
```bash
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:
```bash
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:
```bash
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 when `BRIDGE_DATA_DIR` is empty. Concurrency-safe,
zero ops cost, but every restart drops in-flight swap state. Dev only.
- **`ZapStore`** — used when `BRIDGE_DATA_DIR` is set. Backed by `luxfi/zapdb`
(Lux-flavored Badger v4). LSM-tree KV with WAL durability, atomic
`db.Update` transactions, 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:
```yaml
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:
```bash
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:
```bash
# 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 to
`internal/broadcast` to 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-url` controls
whether `bchain.Client` is constructed, but the handlers it backs
are read-only.
- Single-writer swap store. Horizontal scale-out requires migrating to
a shared external store.
---
## 11. Release-wallet signability monitoring
**Alert on `bridge_release_wallet_signable{network="..."} == 0`.** A
background poller (`WalletHealthPoller`, `cmd/bridge/wallet_health_poller.go`)
runs a harmless canary sign (arbitrary throwaway digest, discarded —
never a real tx) against every minted release wallet on a timer
(`--wallet-health-poll-interval`, default 10m) and caches the result.
This exists because the real threshold-MPC cluster's key shares can
silently desync on a long-lived wallet with **no node restart and no
config change** — seen directly on 2026-06-11, when a ZOO release
wallet went unsignable ~9h after keygen while a fresh wallet on the
same cluster signed fine. A real payout would have stalled the exact
same way; this poller catches it first. Companion series:
`bridge_release_wallet_sign_latency_ms{network="..."}` (canary sign
latency — a rising trend ahead of an outright failure is an early
warning) and `bridge_release_wallet_last_check_age_seconds{network="..."}`
(alert if this grows unbounded while `bridge_wallet_health_poller_running`
is 1 — the loop is alive but stuck on a specific network). A network
only appears once its release wallet has been minted and checked at
least once — absence isn't a 0.
If a wallet goes unsignable: don't retry in place. Re-mint the release
wallet (mpcd keygen is not idempotent, so there's no "repair" path)
and re-fund it.
Alert rules ship in `k8s/bridge-alerts.yaml` (PrometheusRule) and the
scrape target in `k8s/bridge-servicemonitor.yaml` (ServiceMonitor) —
both plug into the existing o11y / luxfi-monitoring stack
(dash.lux.network); no standalone Prometheus needed.
---
## 12. BTC release confirmation gate
A Bitcoin release is only marked `completed` once its tx is **mined** —
mempool admission parks the swap in `bridge_transfer_pending_confirmation`
and the broadcast driver polls mempool.space until confirmed. A release
that sits unconfirmed past `--btc-confirm-timeout` (default 30m) is
rebuilt at a bumped RBF feerate (+25%+1, compounding, floored via the
swap's persisted `last_fee_rate`); a fee-below-relay-floor reject on
submit takes the same rebuild path. Both draw from one ceiling
(`broadcast_rebuilds`, default 5) — past it the swap moves to `failed`
with the reason in `last_error`, and the deposit needs a sweep.
Watch `bridge_btc_confirm_timeouts_total` (each increment = one stuck
release rebuilt) and `bridge_broadcast_rebuilds_total` alongside it;
sustained growth means BTC network fees are outpacing the bump rate.
`--disable-btc-confirm-gate` restores legacy complete-on-admission.