broadcast_driver.go::humanizeBroadcastErr now recognizes the most
common XRP engine_result codes and renders them as one-line user-
facing messages, matching the EVM-side humanization pattern:
tecINSUFFICIENT_FUNDS / tecUNFUNDED → "Insufficient funds in release
address — fund the MPC address"
tecNO_DST → "XRP destination account is
not activated — recipient must
hold ≥10 XRP reserve"
tefPAST_SEQ → "XRP sequence already used —
bridge will requery and retry"
tem* → "XRP transaction was malformed
— bridge will retry"
telINSUF_FEE_P → "XRP network fee too low for
current load — bridge will retry"
The SDK + UI surface these from Swap.LastError so users see the
actionable cause (fund the wallet, activate the destination, etc.)
instead of the raw rippled error string.
Test: TestHumanizeBroadcastErr_XRPEngineResults covers all five new
mappings plus regression for the existing EVM mappings.
internal/txassembler/xrp.go:
- XRPProvider interface + per-network XRPNetwork config (LastLedgerWindow,
fee override, canonical-sig flag).
- Assembler.PreSignXRP: pulls Sequence + suggested fee from rippled,
embeds the release wallet's compressed-secp256k1 pubkey, returns
the 32-byte SHA-512Half (via internal/xrpl) the MPC quorum signs.
- Assembler.FinalizeXRP: parses (r, s) from the MPC's 65-byte R||S||V
hex (ignores recovery byte — XRPL doesn't need ecrecover), DER-encodes
with low-s canonicalization, attaches as TxnSignature, returns the
wire blob (hex) + canonical XRPL tx hash.
- XRPRequiredDrops: amount + fee + 10-XRP reserve, used by the gas
pre-check to refuse signing when the wallet can't cover.
- xrpFloatToDrops with the same string-format trick floatToWei uses,
avoiding IEEE noise at 6-decimal native XRP precision.
internal/mchain/client.go:
- Wallet now carries ECDSAPubKeyHex (compressed-secp256k1 hex). EVM
paths ignore it; XRP signing needs it on the wire.
- pickAddress(XRP) derives the canonical r-address from the ECDSAPubKey
via internal/xrpl.AddressFromPubKey instead of falling back to the
ETH slot. Fallback retained for legacy mocks without the pubkey field.
cmd/bridge/xrp_provider.go:
- HTTP wrapper over rippled JSON-RPC (account_info, fee). Returns
(Sequence, validated_ledger_index) and the open_ledger_fee/median
fee / base_fee chain. Treats actNotFound on balance lookup as
"wallet not activated → balance 0" so the gas pre-check correctly
reports an unactivated release wallet.
cmd/bridge/release_pool.go:
- ReleasePoolEntry gains Family + PubKeyHex fields (round-trips through
JSON; pre-XRP entries leave them empty).
- AcquireForFamily filters entries by family — the signing driver now
picks an XRP wallet for XRP destinations and an EVM wallet for EVM.
Falls back to family-agnostic Acquire when no per-family match.
- SizeByFamily + Reload helpers so a parallel XRP-pool bootstrap can
share the same store as the EVM pool without re-indexing.
cmd/bridge/signing_driver.go:
- signOne dispatches on mchain.AddressTypeFor(DestinationNetwork). XRP
routes to signOneXRP, which mirrors the EVM ceremony but: builds a
Payment via Assembler.PreSignXRP, runs an XRP gas pre-check (balance
vs amount+fee+reserve) instead of eth_getBalance, hands the
SHA-512Half digest to the MPC, then Assembler.FinalizeXRP emits the
signed wire blob.
- xrpGasPrecheck short-circuits to SwapStatusFailedInsufficientReleaseGas
with a humanized "balance=X drops, required=Y drops" reason. Probe
failures DON'T block the swap (best-effort, broadcast layer still
retries on transient rippled flakes).
cmd/bridge/main.go:
- New flags: --xrp-release-pool-size (default 5),
--xrp-release-pool-mint-network (default XRP_TESTNET),
--xrp-release-balance-threshold-drops (default 10 XRP).
- XRPProviderClient instantiated once, shared between the assembler
(for PreSignXRP) and the gas pre-check (via assembler.XRPProviderFor).
- XRP pool minted on top of the existing EVM pool (via a helper-pool
bound to the same store + Reload), so a single ReleasePool object
serves both families.
- /health surfaces pool sizes broken out by family.
Tests:
- xrpl: address derivation round-trip, bad-pubkey rejection, DER
encoding + low-s flip, PreSign digest matches SHA512Half(STX||body),
full PreSign→Finalize round-trip through the rubblelabs parser.
- broadcast: tesSUCCESS happy path, terQUEUED retryable, tecINSUFFICIENT_FUNDS
+ tecNO_DST fatal, temBAD_AMOUNT malformed, telINSUF_FEE_P retryable,
rippled high-level errors, classifyEngineResult table.
- txassembler: PreSignXRP populates Sequence / Fee / LastLedger,
fee floor + pin, bad-input rejection, FinalizeXRP round-trip,
XRPRequiredDrops math, xrpFloatToDrops table.
- mchain: XRP r-address derivation from ECDSAPubKey, legacy fallback
to ETH slot, bad-hex rejection.
- release pool: XRP-family Bootstrap + entry shape + PubKey roundtrip,
AcquireForFamily filtering, no-match returns ErrEmptyPool.
- signing driver: XRP happy path advances to broadcasting with
populated DestRawTx, insufficient balance short-circuits, missing
PubKeyHex rolls back with readable LastError.
- xrp_provider: account_info / fee / balance stubs, actNotFound → 0,
rippled error surfacing.
Wires the static release pool into the MPC signing driver and adds an
eth_getBalance pre-check that short-circuits swaps before the (slow)
ceremony when the release wallet can't cover destination-chain gas.
signing_driver.go:
- SetReleasePool / SetGasProbe inject the pool + balance probe at
startup. Without a pool, the driver falls back to extracting the
wallet ID from Swap.DepositAddress (v1 behaviour).
- signOne() now picks the release wallet from the pool when one is
configured AND has entries, persisting ReleaseWalletID +
ReleaseAddress on the Swap so the broadcast + refund drivers can
read them later. The chosen address goes into the assembler's
PreSign as SenderAddress so the unsigned tx targets the correct
nonce + signs against the pool wallet.
- AFTER PreSign (which gave us the exact GasPrice * GasLimit + Value),
the gas pre-check queries the release wallet's destination-chain
balance. If balance < required cost, the swap moves to
SwapStatusFailedInsufficientReleaseGas with a clear LastError
("Release wallet 0x… has insufficient balance: balance=… required=…
gasCost=… value=… short=…"). The MPC ceremony is skipped.
Probe failures are treated as best-effort: log + continue rather
than refuse to sign a swap we couldn't verify.
- New ShortCircuited counter distinct from Failures (operator
dashboards differentiate "operator needs to fund a pool wallet"
from "transient MPC / RPC flake").
main.go:
- --release-pool-size (default 10), --release-pool-mint-network
(default LUX_TESTNET), --release-balance-threshold-wei
(default 1e17 wei = 0.1 LUX), --disable-gas-precheck flag, plus
--mpc-dashboard-url / --mpc-dashboard-token / --mpc-dashboard-api-key
for the dashboard signing wiring landed in commit 1.
- Bootstrap path: instantiates the balance probe, then the
ReleasePool, then calls Bootstrap (which lazily mints the
remaining wallets up to pool size, idempotent across restarts).
Wires both probe and pool into the signing driver.
- /health endpoint exposes the pool size + threshold for ops triage.
gas_precheck_test.go covers the three core behaviours:
- insufficient balance → swap transitions to
failed_insufficient_release_gas with no signer call
- sufficient balance → swap proceeds normally to broadcasting
- probe error → swap proceeds (best-effort, broadcast retry still works)
Together with the earlier dashboard-sign + pool foundation commits,
this closes the testnet refund-loop issue: the bridge now (a) speaks
the real /v1/mpc/sign endpoint, (b) rotates through pre-funded
release wallets, and (c) refuses to spend the MPC ceremony on swaps
that would fail on broadcast.
Introduces a static pool of pre-funded MPC release wallets so a single
under-funded wallet doesn't block all subsequent swaps. Previous
behaviour reused the deposit wallet for the release tx — a fresh
keygen with zero destination-chain balance.
This commit adds the foundation:
- release_pool.go: ReleasePool with round-robin Acquire(), lazy
Bootstrap() that mints wallets up to a configured size via the
Keygener interface (*mchain.Client satisfies). Pool entries persist
across restarts via the new ReleasePoolStore interface.
- ReleasePoolStore implementations on both InMemoryStore (test/dev)
and ZapStore (prod, persisted under releasepool:NNNNNN keys, sorted
by zero-padded index for canonical iteration order).
- balance_probe.go: BalanceProbeClient — small JSON-RPC wrapper around
eth_getBalance that reuses the broadcast + depositcheck RPC URL
tables (with overrides). Used by both the pool's low-balance
alerter and by the gas pre-check landing in a follow-up commit.
Non-EVM networks return ErrFamilyNotSupportedForBalance.
- Swap.ReleaseWalletID + Swap.ReleaseAddress fields so the broadcast
+ refund drivers know which pool wallet signed each swap.
- SwapStatusFailedInsufficientReleaseGas — new terminal state for
swaps the gas pre-check refuses to enter the MPC ceremony for.
- release_pool_test.go covers Bootstrap idempotency (reload doesn't
re-mint), pool growth, keygen failure surfacing, round-robin
fairness, balance-probe call gating on the threshold being set, and
Entries() copy isolation.
No wiring into the signing driver yet — that's the next commit so
the signing-driver change is small and reviewable in isolation.
Sweep through configs, docs, and ignore files after the structural
collapse in the previous commit. Updates fall into three buckets:
1) Restore the Dockerfile for app/bridge/
- app/bridge/Dockerfile: new file, Vite SPA → ghcr.io/hanzoai/spa,
same shape as the pre-collapse legacy Dockerfile, build context
is repo root so @luxfi/bridge (workspace:*) resolves.
- cmd/bridge/Dockerfile: switch UI-build stage to the workspace
build context so the new app/bridge (which depends on the
sibling pkg/bridge package) actually installs.
- Makefile build-ui target: drop the per-app build context for
the same reason — context must be repo root.
2) Update workspace docs to the new layout
- README.md: rename @luxbridge/app row → @luxbridge/lux-tenant,
port 3000 → 3001 (the slim tenant's vite config uses 3001).
- docs/LLM.md: drop the bridge3 row, rename app/bridge row to
@luxbridge/lux-tenant, update dev port.
- docs/{BRIDGE-STATUS,LOCAL-SETUP,CI-CD-DOCKER-IMAGES}.md:
`docker build … ./app/bridge` → `… -f app/bridge/Dockerfile .`
so docs reflect the workspace-rooted build context.
- .github/workflows/publish.yml: drop @luxbridge/app and
@luxbridge/app-v3 from the private-packages comment.
3) Delete AI-slop status reports + test harness
- CI_STATUS_REPORT.md, DEPLOYMENT_STATUS.md, WORKFLOW_AUDIT.md,
WORKFLOWS_SUMMARY.md — stale and referenced bridge3.
- test-bridge-ui.js — top-level Puppeteer/Playwright probe of the
deleted legacy UI; not a workspace test.
- .dockerignore: drop the test-bridge-ui.js entry now that the
file no longer exists.
Out of scope (untouched): pkg/bridge/src/{Bridge,config,types,mount}
and pkg/bridge/src/app/ — Phase 3 R2 is working there. The
`@luxbridge/app` mentions in those files are historical comments
explaining the prior workspace cycle, not live references.
Mirrors the luxfi/explorer pattern: cmd/bridge/main.go embeds the SPA
build via go:embed and exposes /v1/bridge/* on a single port. Read paths
(networks, tokens, exchanges, limits) are answered natively from a YAML
config — no Postgres. MPC-heavy paths (quote, rate, swaps, explorer)
reverse-proxy to the legacy Node backend until the native port lands.
Image: cmd/bridge/Dockerfile produces ~25 MB distroless.
Config: cmd/bridge/networks.example.yaml.
Migration target: Hanzo Base (SQLite) + luxfi/mpc direct calls.