The merge from upstream restored ./bridge as a tracked file — upstream still
carried it — so the ignore rule added alongside the earlier untracking had
nothing to act on and the next local build dirtied the tree again. Removed from
the index once more; the file stays on disk.
The six source files were not gofmt-clean and a formatter pass reordered their
imports. Nothing else changed in them.
Co-authored-by: Hanzo Dev <[email protected]>
Starting the bridge without MPC configured killed the process with a nil pointer
dereference in release-pool bootstrap. The guard it was supposed to hit was
already there, and already said the useful thing: "cannot grow 0→10 without
Keygener (mpc client not configured)".
It could not fire. mchainClient is declared as *mchain.Client and assigned only
when MPC is configured, so without it the variable is a nil pointer — and a nil
pointer passed into an interface parameter produces an interface that is not
nil, because it carries a type. `kg == nil` is false, the next line calls a
method on nothing, and the process dies where a sentence had been written for
exactly this case.
Five call sites now pass keygener(mchainClient), which returns a genuinely nil
interface when there is no client. Running without MPC is an ordinary local
case; it now says so and carries on to the next pool instead of taking the
process down.
The proxy branch forwards /api/swaps/+ whole, and among those legs is
mpcsign/:swapId, which asks the MPC to sign for a swap id the caller names. The
backend router it arrives at holds no authentication of any kind
(app/server/src/routes/swaps.ts), and no hook in the SPA calls it — the client
reaches getsig, payout and transfer. So it was an operator action reachable by
anyone on the port the ingress publishes. It moves to the operator listener,
declared before the wildcard because routes match in registration order.
The rig now builds the SPA catch-all main.go installs at /*, which changes what
the listener tests can honestly assert. Three of them read status == 404, and
production never answers 404 on an unrouted path — the catch-all answers 200
with HTML first, so those assertions held only because the rig was missing a
route. They assert the substance instead: no swap id or signed destination tx
in the body, no scrape, no deposit poll. Each still fails when its route is put
back on the public 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.
The flag's default was envOr("BRIDGE_ADMIN_ROUTES", "") != "", which asks
whether the variable is set, not what it says. Writing false, 0, off or no —
the way an operator records in a manifest that a switch is off — set it, and
so mounted /admin/swaps/:id/reset and /admin/swaps/:id/inject-raw-tx, which
take any swap id and no credential.
envBool was already in this file, already handled every spelling, and was
already used by the flag directly below. Only this call site did not reach for
it, so the fix is the expression rather than any new code.
The regression test is on the shape, not on envBool, since envBool was never
the thing that was wrong: a boolean flag in this file may not be built from a
set-ness test at all, and the test names the file and line when one is.
luxd serves chain routes under /v1/chain, and the node, cli and sdk releases
already ship it. The deployed gateways still answer the old prefix, so remote
reads break until they are redeployed; the estate moves together rather than
sitting half converted.
Lines that record an observation - a probe, a status code, a past release -
keep the prefix they were written against, as do URLs on hosts that are not
ours. Avalanche endpoints move to /ext/bc, which is the path that host serves.
The rename to /v1/chain is real in luxd source, but the deployed fleet has not
taken it yet: probed just now, api.lux.network, api.lux-test.network,
api.zoo.network and api.hanzo.network each answer 200 on /v1/bc/C/rpc and 404 on
/v1/chain/C/rpc. Composing /v1/chain against those hosts reaches nothing, which
is the failure the rename set out to prevent.
The move belongs with the node redeploy, not ahead of it.
luxd serves chain routes under /v1/chain; the /v1/bc prefix it replaced is
gone, so anything composing it reaches nothing. Lines that record an
observation - a probe, a status code, a past release - keep the prefix they
were written against, as do URLs on hosts that are not ours.
Both handlers rewind or overwrite any swap by id and authenticate
nobody. Their own comments said "operator-only ... no auth ... do not
expose externally", but they were registered beside the public routes,
so what actually kept them unreachable was that they sit at the app
root while the edge forwards /v1/bridge and /api. That is a property of
the ingress rules rather than of this program.
BRIDGE_ADMIN_ROUTES mounts them. Off by default, so an unrouted path
answers 404 because the route does not exist.
Tested as a pair: absent by default, present when enabled. Either alone
would pass if the flag mounted nothing. Both use a seeded swap, since
the handler answers an unknown id with the same 404 the router gives an
unmounted route.
Two things, and the first is the one that mattered.
THE TWIN. app/server had the same default-to-eth I removed from the Go
side an hour ago — twice, in keygen and in the deposit check. Fixing one
language and leaving the other is the "one fact, two places, one fixed"
shape this session keeps turning up, and app/server is the one actually
deployed at bridge-api.lux.network. Both now refuse: minting a wallet
for a network with no known address format produces an address on the
wrong chain, and checking one with the EVM checker reads an 0x address
on a chain that has none and reports "no deposit" forever rather than
reporting that it cannot look.
CARDANO CAN NOW BE WATCHED, so it goes back into the offered set. A
checker over Koios — public, read-only, no key — on both sides. The test
that refused Cardano an hour ago passes it now, which is the test doing
its job in both directions rather than only saying no.
Two details worth keeping: an address that has never been used is ABSENT
from a Koios response rather than present with zero, so an empty array
is "nothing here" and not an error. And lovelace arrives as a decimal
STRING because the value exceeds what a JSON number carries exactly —
compared as integers, since a float parse introduces rounding exactly at
the threshold where a deposit is judged sufficient.
AddressTypeFor returned AddressTypeETH for any network it did not name,
on the reasoning in its own comment: "safe because every
BridgeVM-supported chain is at minimum an EVM". That stopped being true
at Bitcoin. An unlisted non-EVM network silently became an EVM one, so
custody minted an 0x address for a chain that has never seen one, the
watcher looked for an EVM deposit that could not exist, and the
broadcaster reached for the EVM arm.
It returns (type, known) now, and the compiler walked every caller:
keygen refuses to mint for an unknown family, the deposit checker
refuses to look, the broadcaster refuses to send, pool routing returns
no pool, and the withdrawal gate HOLDS an unknown family rather than
waving it through — a family nobody has named is the one to hold.
A test asserted the old behaviour by name —
TestKeygen_UnknownNetwork_DefaultsToETH. It encoded the bug as the
specification, which is how a bug survives a test suite.
Cardano was mapped to AddressTypeSOL here, the same defect fixed in
mpc-wallet.ts earlier today. Same fact, two places, one fixed. It has
its own AddressTypeADA now.
AND THE ONE I CAUSED. Adding nine networks earlier, I filled the network
rows and the asset rows and missed internal/depositcheck's RPC table —
seven of eight EVM additions had no deposit RPC, so a Celo deposit would
have been watched for on a chain the watcher could not reach. That is
precisely the failure the five-table shape produces, committed by
someone who had just written a test against it.
Fixed, and made structural:
TestEveryOfferedNetworkIsCompleteAcrossTheTables walks the config and
requires an address family, a deposit RPC and an asset row for every
network offered. Verified by mutation — dropping Celo's RPC names it.
That test refuses Cardano, correctly. Its address encoder is right and
internal/mchain names the family, but depositcheck has no Cardano
checker, so a deposit to a correct address is observed by nobody and the
swap waits forever on money that already arrived. Held back with the
reason in the config rather than shipped and discovered.
You were right that the crypto is already there. Cardano signs with the
same Ed25519 key Solana does, through FROST, which luxfi/threshold and
luxfi/mpc support natively. The key was never the problem.
The address was. Solana's address IS the public key, base58. Cardano's
is blake2b-224 of that key inside a typed, network-tagged bech32
envelope. NETWORK_ADDRESS_TYPE mapped CARDANO_MAINNET to 'sol' — the
comment beside it said "placeholder" — so the bridge handed out a
Solana-shaped string and told people to send ADA to it. That is offered
on bridge-api.lux.network today.
cardano-address.ts builds the CIP-19 enterprise address: payment
credential only, no staking part, which is the right shape for custody —
a base address would commit to a staking credential the committee does
not hold.
blake2b-224 is NOT blake2b-512 truncated. Blake2b takes its digest
length into the parameter block, so they differ from the first byte, and
Node's fixed-length blake2b512 cannot be used. There is a test asserting
exactly that, so anyone substituting a truncating implementation finds
out immediately rather than after the funds move.
Also asserted: the address is not the public key (the bug this
replaces), the network tag distinguishes mainnet from testnet, a key
that is not 32 bytes is refused rather than hashed into a plausible
address for a key that does not exist, and encode/decode agree.
The type union gained 'ada' rather than the map being widened to string,
so the next family added has to be handled in the switch instead of
falling through to 'eth' — which is how Cardano would have got an
Ethereum address if the default had caught it.
25 networks in the config now. Nineteen EVM, all assemblable; Bitcoin,
Solana, TON, XRP, Polkadot and Cardano correctly excluded from the EVM
assembler.
NOT VERIFIED against a live Cardano node — the encoder is
self-consistent and matches CIP-19 as written, but no address produced
here has been used on chain. Worth one testnet deposit before mainnet
funds move through it.
An empty teleporter is a mode, not a gap. Ethereum and Polygon carry
empty ones and cross fine, because the committee holds an address on the
chain and a deposit to it is the lock. I had read those empty strings as
"cannot bridge" and called three real networks fictional on the strength
of it. They are real; what was missing was our row, not their existence.
Custody already spans all of these — CGGMP21 on secp256k1 for every EVM,
FROST on Ed25519 for substrate — and the address type defaults to `eth`,
so every EVM network added here gets a correct deposit address with no
new key material and no new ceremony.
Added: Celo, Zora, Blast, Linea, Gnosis, Fantom, Aurora (EVM, chain ids
from the live registry rather than memory) and Polkadot (substrate, no
EIP-155 id, SS58 via encodeSubstrateAddress). Nineteen EVM networks are
now offered and all nineteen assemble.
Each carries its native asset row. A network in four of the five tables
a crossing needs is one whose gap is found after the user has sent
funds, so TestEveryOfferedNetworkHasItsNativeAsset now walks the config
and refuses that state.
CARDANO IS DELIBERATELY LEFT OUT, and this is the one to look at:
mpc-wallet.ts maps CARDANO_MAINNET to address type `sol`, with the
comment "placeholder — needs Ed25519". Both are Ed25519 so the key is
fine, but a Cardano address is bech32 addr1... and a Solana address is a
base58 public key. Anyone bridging to Cardano is handed a Solana-shaped
address to send ADA to. It is offered on bridge-api.lux.network today.
Adding it here would have spread a funds-losing bug rather than fixed
it; it goes in when it has an encoder.
Arbitrum, Optimism and Avalanche were in the picker on
bridge.lux.network and the assembler had never heard of them. A swap to
any of the three is accepted, takes the user's funds, and then cannot
build the release — the failure lands after the money has moved, which
is the worst place in the flow to find a gap.
The cause was a second copy of data that already existed. The networks
file declares `chainId` and `type: evm` per network; evmChainIDs said it
again in Go, and the two drifted. configureEVM now reads the config, so
a network the operator offers is a network the assembler knows by
construction. The table stays as a fallback for a config that declares
no id, and only for that.
A network with no usable id stays unregistered rather than guessing. An
EIP-155 id is signed into the transaction: a wrong one produces a
signature valid on some OTHER chain, so refusing beats inventing.
TestEveryOfferedEVMNetworkCanBeAssembled walks the config and fails on
any EVM network the assembler lacks or disagrees with about the id.
Verified by mutation — dropping Arbitrum reports it by name. The
companion test checks the other direction: Bitcoin, Solana, TON and XRP
must NOT be registered as EVM, because giving a chain an EIP-155 id it
does not have is inventing a fact about it.
TestEstateChainIDsAreConfigured now passes nil networks, keeping the
fallback table as its subject rather than silently testing the config.
The bridge settled to Lux and Zoo but not to the other three chains Lux
runs, so the set it offered was smaller than the set the exchange lists.
Each is an EVM L1 whose custody address is the same secp256k1 key at the
same address it already derives, so a crossing needs no new key material.
A network reaches a user through five tables that are edited separately —
address family, deposit endpoint, broadcast endpoint, assembler endpoint,
asset row — and a chain in four of them fails after the user has already
sent funds. estate_test reads all five and the served config, so the next
chain either arrives everywhere or fails here.
The assembler's chain ids move out of main into evm.go: an EIP-155 id is
what goes into the signature, and a wrong one is valid somewhere else.
Native symbols are the ones each chain's own registry reports — Hanzo's
coin is AI. Zoo was serving live but missing from the checked-in config.
Co-authored-by: Hanzo Dev <[email protected]>
Both handlers rewind or overwrite any swap by id and authenticate
nobody. Their own comments said "operator-only ... no auth ... do not
expose externally", but they were registered beside the public routes,
so what actually kept them unreachable was that they sit at the app
root while the edge forwards /v1/bridge and /api. That is a property of
the ingress rules rather than of this program.
BRIDGE_ADMIN_ROUTES mounts them. Off by default, so an unrouted path
answers 404 because the route does not exist.
Tested as a pair: absent by default, present when enabled. Either alone
would pass if the flag mounted nothing. Both use a seeded swap, since
the handler answers an unknown id with the same 404 the router gives an
unmounted route.
Two things, and the first is the one that mattered.
THE TWIN. app/server had the same default-to-eth I removed from the Go
side an hour ago — twice, in keygen and in the deposit check. Fixing one
language and leaving the other is the "one fact, two places, one fixed"
shape this session keeps turning up, and app/server is the one actually
deployed at bridge-api.lux.network. Both now refuse: minting a wallet
for a network with no known address format produces an address on the
wrong chain, and checking one with the EVM checker reads an 0x address
on a chain that has none and reports "no deposit" forever rather than
reporting that it cannot look.
CARDANO CAN NOW BE WATCHED, so it goes back into the offered set. A
checker over Koios — public, read-only, no key — on both sides. The test
that refused Cardano an hour ago passes it now, which is the test doing
its job in both directions rather than only saying no.
Two details worth keeping: an address that has never been used is ABSENT
from a Koios response rather than present with zero, so an empty array
is "nothing here" and not an error. And lovelace arrives as a decimal
STRING because the value exceeds what a JSON number carries exactly —
compared as integers, since a float parse introduces rounding exactly at
the threshold where a deposit is judged sufficient.
AddressTypeFor returned AddressTypeETH for any network it did not name,
on the reasoning in its own comment: "safe because every
BridgeVM-supported chain is at minimum an EVM". That stopped being true
at Bitcoin. An unlisted non-EVM network silently became an EVM one, so
custody minted an 0x address for a chain that has never seen one, the
watcher looked for an EVM deposit that could not exist, and the
broadcaster reached for the EVM arm.
It returns (type, known) now, and the compiler walked every caller:
keygen refuses to mint for an unknown family, the deposit checker
refuses to look, the broadcaster refuses to send, pool routing returns
no pool, and the withdrawal gate HOLDS an unknown family rather than
waving it through — a family nobody has named is the one to hold.
A test asserted the old behaviour by name —
TestKeygen_UnknownNetwork_DefaultsToETH. It encoded the bug as the
specification, which is how a bug survives a test suite.
Cardano was mapped to AddressTypeSOL here, the same defect fixed in
mpc-wallet.ts earlier today. Same fact, two places, one fixed. It has
its own AddressTypeADA now.
AND THE ONE I CAUSED. Adding nine networks earlier, I filled the network
rows and the asset rows and missed internal/depositcheck's RPC table —
seven of eight EVM additions had no deposit RPC, so a Celo deposit would
have been watched for on a chain the watcher could not reach. That is
precisely the failure the five-table shape produces, committed by
someone who had just written a test against it.
Fixed, and made structural:
TestEveryOfferedNetworkIsCompleteAcrossTheTables walks the config and
requires an address family, a deposit RPC and an asset row for every
network offered. Verified by mutation — dropping Celo's RPC names it.
That test refuses Cardano, correctly. Its address encoder is right and
internal/mchain names the family, but depositcheck has no Cardano
checker, so a deposit to a correct address is observed by nobody and the
swap waits forever on money that already arrived. Held back with the
reason in the config rather than shipped and discovered.
You were right that the crypto is already there. Cardano signs with the
same Ed25519 key Solana does, through FROST, which luxfi/threshold and
luxfi/mpc support natively. The key was never the problem.
The address was. Solana's address IS the public key, base58. Cardano's
is blake2b-224 of that key inside a typed, network-tagged bech32
envelope. NETWORK_ADDRESS_TYPE mapped CARDANO_MAINNET to 'sol' — the
comment beside it said "placeholder" — so the bridge handed out a
Solana-shaped string and told people to send ADA to it. That is offered
on bridge-api.lux.network today.
cardano-address.ts builds the CIP-19 enterprise address: payment
credential only, no staking part, which is the right shape for custody —
a base address would commit to a staking credential the committee does
not hold.
blake2b-224 is NOT blake2b-512 truncated. Blake2b takes its digest
length into the parameter block, so they differ from the first byte, and
Node's fixed-length blake2b512 cannot be used. There is a test asserting
exactly that, so anyone substituting a truncating implementation finds
out immediately rather than after the funds move.
Also asserted: the address is not the public key (the bug this
replaces), the network tag distinguishes mainnet from testnet, a key
that is not 32 bytes is refused rather than hashed into a plausible
address for a key that does not exist, and encode/decode agree.
The type union gained 'ada' rather than the map being widened to string,
so the next family added has to be handled in the switch instead of
falling through to 'eth' — which is how Cardano would have got an
Ethereum address if the default had caught it.
25 networks in the config now. Nineteen EVM, all assemblable; Bitcoin,
Solana, TON, XRP, Polkadot and Cardano correctly excluded from the EVM
assembler.
NOT VERIFIED against a live Cardano node — the encoder is
self-consistent and matches CIP-19 as written, but no address produced
here has been used on chain. Worth one testnet deposit before mainnet
funds move through it.
An empty teleporter is a mode, not a gap. Ethereum and Polygon carry
empty ones and cross fine, because the committee holds an address on the
chain and a deposit to it is the lock. I had read those empty strings as
"cannot bridge" and called three real networks fictional on the strength
of it. They are real; what was missing was our row, not their existence.
Custody already spans all of these — CGGMP21 on secp256k1 for every EVM,
FROST on Ed25519 for substrate — and the address type defaults to `eth`,
so every EVM network added here gets a correct deposit address with no
new key material and no new ceremony.
Added: Celo, Zora, Blast, Linea, Gnosis, Fantom, Aurora (EVM, chain ids
from the live registry rather than memory) and Polkadot (substrate, no
EIP-155 id, SS58 via encodeSubstrateAddress). Nineteen EVM networks are
now offered and all nineteen assemble.
Each carries its native asset row. A network in four of the five tables
a crossing needs is one whose gap is found after the user has sent
funds, so TestEveryOfferedNetworkHasItsNativeAsset now walks the config
and refuses that state.
CARDANO IS DELIBERATELY LEFT OUT, and this is the one to look at:
mpc-wallet.ts maps CARDANO_MAINNET to address type `sol`, with the
comment "placeholder — needs Ed25519". Both are Ed25519 so the key is
fine, but a Cardano address is bech32 addr1... and a Solana address is a
base58 public key. Anyone bridging to Cardano is handed a Solana-shaped
address to send ADA to. It is offered on bridge-api.lux.network today.
Adding it here would have spread a funds-losing bug rather than fixed
it; it goes in when it has an encoder.
Arbitrum, Optimism and Avalanche were in the picker on
bridge.lux.network and the assembler had never heard of them. A swap to
any of the three is accepted, takes the user's funds, and then cannot
build the release — the failure lands after the money has moved, which
is the worst place in the flow to find a gap.
The cause was a second copy of data that already existed. The networks
file declares `chainId` and `type: evm` per network; evmChainIDs said it
again in Go, and the two drifted. configureEVM now reads the config, so
a network the operator offers is a network the assembler knows by
construction. The table stays as a fallback for a config that declares
no id, and only for that.
A network with no usable id stays unregistered rather than guessing. An
EIP-155 id is signed into the transaction: a wrong one produces a
signature valid on some OTHER chain, so refusing beats inventing.
TestEveryOfferedEVMNetworkCanBeAssembled walks the config and fails on
any EVM network the assembler lacks or disagrees with about the id.
Verified by mutation — dropping Arbitrum reports it by name. The
companion test checks the other direction: Bitcoin, Solana, TON and XRP
must NOT be registered as EVM, because giving a chain an EIP-155 id it
does not have is inventing a fact about it.
TestEstateChainIDsAreConfigured now passes nil networks, keeping the
fallback table as its subject rather than silently testing the config.
The bridge settled to Lux and Zoo but not to the other three chains Lux
runs, so the set it offered was smaller than the set the exchange lists.
Each is an EVM L1 whose custody address is the same secp256k1 key at the
same address it already derives, so a crossing needs no new key material.
A network reaches a user through five tables that are edited separately —
address family, deposit endpoint, broadcast endpoint, assembler endpoint,
asset row — and a chain in four of them fails after the user has already
sent funds. estate_test reads all five and the served config, so the next
chain either arrives everywhere or fails here.
The assembler's chain ids move out of main into evm.go: an EIP-155 id is
what goes into the signature, and a wrong one is valid somewhere else.
Native symbols are the ones each chain's own registry reports — Hanzo's
coin is AI. Zoo was serving live but missing from the checked-in config.
Co-authored-by: Hanzo Dev <[email protected]>
Port of f3e25de from preserve/lux-bridge-golive onto main's
architecture. A BTC release now parks in
bridge_transfer_pending_confirmation on mempool admission and only
completes once mined; a release unconfirmed past --btc-confirm-timeout
(or fee-rejected at submit) is reset for re-sign at a bumped RBF
feerate — PreSign takes MinFeeRateSatPerVB as a floor over the live
estimate, and the paid rate persists on the swap (LastFeeRate) so each
rebuild strictly out-bids the last (BIP-125). Rebuild cycles share one
ceiling (default 5); past it the swap fails loudly. The broadcast
client answers confirmation status from the same mempool.space base it
posts to; /metrics gains bridge_btc_confirm_checks_total /
_timeouts_total / bridge_broadcast_rebuilds_total.
Port of the wallet-health work from preserve/lux-bridge-golive onto
main's architecture: canary-sign poller (10m default) against every
minted release wallet, cached snapshot surfaced on /metrics as
bridge_release_wallet_signable / _sign_latency_ms / _last_check_age,
PrometheusRule + ServiceMonitor for the o11y stack, and the operator
runbook section (re-mint, don't retry). Catches the June key-share
desync failure mode before a real payout does.
tokens/exchanges/limits/profileGET (the /v1/bridge/{tokens,exchanges,
limits,profile} handlers the SDK's config bootstrap depends on),
SetProfile, stripPathPrefix (the legacy Express-proxy Director), and
rpcErrToJSONRPC were all untested.
Worth calling out: stripPathPrefix's empty-path case (a request to
exactly the /v1/bridge prefix with nothing after it) must rewrite to
"/", not "" -- an empty URL.Path on an outbound proxied request is
invalid and would break against most backends. Also covers
rpcErrToJSONRPC's fallback contract: a *bchain.RPCError with Code==0
(a transport failure that never got a real JSON-RPC code) must still
map to -32603, not propagate 0, which isn't a defined JSON-RPC error
code.
New cmd/bridge/api_test.go, 13 tests.
The .network host was retired 2026-07-27 and no longer resolves at all, so every
reference to it named an address that cannot answer. The brand host is .cloud,
matching kms.zoo.cloud and the rest of the white-label convention.
Left alone deliberately, because rewriting them would invert what they say: the
LLM.md line that RECORDS the retirement, the e2e spec that ASSERTS
kms.lux.network must not resolve, and the recorded applies + cluster backups,
which are faithful accounts of what was applied and are not config.
Currency bump: golang builder images -> 1.26.5 (latest stable).
Adds ENV GOTOOLCHAIN=auto to each Go builder stage. The official golang
images set GOTOOLCHAIN=local, so a builder image older than the module's
go directive is a hard mid-build failure. The pin stays the hermetic fast
path; auto is the safety net for the next go.mod bump.
No go.mod go directive was changed. Builder images move; directives stay.
Co-authored-by: Hanzo Dev <[email protected]>
GOPRIVATE switches off the module proxy AND checksum verification together.
Covering github.com/luxfi/* meant every luxfi module was fetched straight from
git and trusted without verification — which is how moved tags got captured into
module caches and served from them indefinitely, making the same commit build on
one machine and fail on another.
Nearly all of luxfi is public and on proxy.golang.org, so it belongs under
normal verification. The private org is lux-private.
Twelve luxfi repos are still private AND still 404 on the proxy, so they are
listed individually rather than by wildcard — coverage now matches what actually
needs a direct fetch, and every other luxfi module is verified against
sum.golang.org again:
bridge broker compliance erc20-go fasthttp graph hsm indexer
mpc ordering password pool
The right end state is those repos moving to lux-private; until then the
explicit list keeps the exemption honest instead of blanket.
Co-authored-by: Hanzo Dev <[email protected]>
luxd serves exactly one HTTP prefix — /v1 (node server/http/server.go
baseURL). /ext/ was removed some releases ago, so every Lux and Zoo URL
in this repo was pointing at a 404.
Measured 2026-07-27:
api.lux.network/v1/bc/C/rpc 200 eth_chainId 0x17871 (96369)
api.lux.network/ext/bc/C/rpc 404
api.lux-test.network/v1/bc/C/rpc 200 eth_chainId 0x17870 (96368)
api.zoo.network/v1/bc/C/rpc 200 eth_chainId 0x30e08 (200200)
api.zoo-test.network/v1/bc/C/rpc 200 eth_chainId 0x30e09 (200201)
control api.lux.network/v1/bc/ZZZNOPE/rpc 404
ZOO_MAINNET/ZOO_TESTNET were doubly wrong: legacy prefix AND a "Z" chain
alias that 404s on both prefixes. Zoo's EVM answers under bc/C.
Avalanche endpoints deliberately keep /ext/ — that is their API, not
ours (measured: api.avax.network/ext/bc/C/rpc 200 0xa86a, /v1 404).
B-chain/T-chain URLs move to /v1 as well; BridgeVM is still undeployed
(re-verified today: /v1/bc/B/rpc 404 while /v1/bc/C/rpc 200, so the 404
is "no such chain", not "no such prefix").
zap-proto/zip is the one zip. The hanzoai/zip fork is behind it and has no
unique surface left: canonical supersedes its closers with a context-aware
OnShutdown, keeps ZAPRegistry in the zaprpc subpackage, and exports the
primitives its App.Mount wrapped.
applyBrandMetadata() fixes these at runtime for browsers; also rewrite the
served HTML so non-JS consumers (crawlers, social-card unfurlers) never see the
upstream Lux brand on a white-label deployment. No tenant → unchanged.
The app/bridge SPA baked @luxfi/brand at build time and the Go server served
brand at /envs.js while the SPA loads /__ENV.js — so a white-label tenant
(e.g. zooai/bridge-shim) rendered the Lux brand. Complete the stated
'jurisdiction-neutral, tenant supplies brand' design:
- bridge.config.ts: brand name/favicon/primaryColor/supportEmail/docsUrl are now
window.__ENV-overridable (logoUrl already was); @luxfi/brand stays the
fallback so the Lux tenant is byte-unchanged when no override is set.
- frontend.go: serve /__ENV.js (the endpoint the SPA actually loads) projecting
the tenant brand+IAM into the BRIDGE_* keys; applyBrandMetadata() then themes
title/favicon/logo/color at mount. Empty when no tenant → Lux fallback.
- main.go: pass the tenant config into NewFrontend.
The binary now imports github.com/luxfi/bridge/pkg/tenant (tenant-config);
the narrow COPY list predated that dep, so go build failed with
'no required module provides package .../pkg/tenant'.
luxfi module tags are mutable internal releases; the public Go proxy pins the
first-seen copy, so a re-tag (e.g. luxfi/[email protected]) makes proxy and github
diverge and a direct-fetch build fails go.sum verification. Resolve hanzoai +
luxfi direct so build and go.sum agree.
- Dockerfile: guarded GH_TOKEN ARG + GOPRIVATE=github.com/hanzoai so the
go-build stage can fetch the one private dep (hanzoai/zip). Build-time only;
final stage is FROM scratch so the token never ships.
- add cmd-bridge-image.yml: publishes ghcr.io/luxfi/bridge:<tag>-server (the
single-binary Go/tenant image), distinct from the SPA plain-semver line.
White-label shims (zooai/bridge-shim) base their FROM on this -server image.
Squash of the local main lineage onto the canonical origin/main. Net tree only
(62 files): stale docs pages removed, tenant config + tests updated, minor Go
fixes, refreshed opnet-watcher build. No third-party white-label branding in
code or history.
Co-authored-by: Hanzo Dev <[email protected]>
Makes XRP withdrawal safe to enable (testnet on; mainnet staged-gated) by closing
the fund-loss + gate-bypass vectors a red/blue loop surfaced over three rounds.
DESTINATION TAG (else a tag-less payout to an exchange pool is lost):
- internal/xrpl: ValidateDestination (reject X-addresses + validate r-address);
parseAccount recover-wrapper (fixes a rippledata base58 panic-DoS reachable in
the signing goroutine); tag set on the tx BEFORE SigningHash so it is committed
into the SHA-512Half the MPC signs (unstrippable, proven).
- internal/txassembler: XRPSpec.DestinationTag *uint32 -> PaymentUnsigned.
- cmd/bridge: Swap.DestinationTag, createSwapReq accepts+range-checks it [0,2^32),
signOneXRP threads it + re-validates the destination (terminal-fail on bad addr).
AUTHORITATIVE WITHDRAWAL GATE (was cosmetic — read only by the SPA list):
- config.go: WithdrawalEnabled/DepositEnabled now enforced server-side. Case-
INSENSITIVE (network,asset) match + default-DENY for wired non-EVM families
(BTC/SOL/TON/XRP/DOT) so an asset-case variant ('xrp'), empty asset, or network-
case variant ('bitcoin_testnet') can't dodge a disabled row into a release pool
(gate key now aligns with the network-only, case-folded signer routing). EVM
stays default-on.
- Enforced at CREATE (swapsCreateNative 403), SIGN (signOne skip = kill-switch),
BROADCAST (broadcastOne holds already-signed in-flight swaps), and the operator
INJECT path (swapsInjectRawTxNative 403). signOne also gains a defer-recover
backstop so one swap's panic can't crash the daemon.
Tests: tag carried+committed+on-wire; X-addr/bad-addr/panic-input rejected; tag
range boundaries; gate variant-bypass closed; create/sign/broadcast/inject all
honor the gate; kill-switch holds then resumes. cmd/bridge + xrpl + txassembler
green, full module green, vet clean.
KNOWN FOLLOW-UPS (pre-existing, bridge-wide, NOT XRP-specific; tracked):
- /admin/swaps/:id/{reset,inject-raw-tx} have no auth middleware (bounded by
64-bit random swap IDs + the now-gated inject). Gate behind operator token/mTLS.
- GET /v1/bridge/swaps has no tenant scoping (IDOR). Both are mainnet-readiness
items; mainnet XRP withdrawal stays isWithdrawalEnabled:false until addressed +
a live testnet tag-carrying-payout proof.
Red review of 2dd8da86 found the real blocker: XRP outbound has no DestinationTag
(internal/txassembler/xrp.go builds Payment without one; Swap/API have no tag field),
so a payout to an exchange's pooled deposit address is credited to nobody and is
irreversible. Set XRP isWithdrawalEnabled:false on both nets until the tag is plumbed
end-to-end + X-addresses are rejected — deposit-only, matching the BTC precedent.
Also from the review:
- per-XRP minUSD:30 floor — a fresh per-swap deposit address must hold the >=10-XRP
AccountRoot base reserve or it never confirms (token-denominated min is the robust
follow-up; USD floor is the interim proxy).
- Correct the depositcheck + deposit_watcher trust-model docstrings: depositcheck.Check
is the SOLE runtime gate that advances a swap to a release-pool payout — the single
upstream RPC is load-bearing/trusted, not 'secondary verification'. Documents the
systemic multi-RPC-quorum hardening follow-up so operators point only at trusted RPCs.
Inbound checkXRP is unchanged (red verified it solid). cmd/bridge + depositcheck green.
XRP was the one of {BTC,SOL,TON,XRP,ETH} with a genuine in-repo gap: outbound
(release pool + assembler + rippled submit) was complete, but inbound deposit
detection was a stub (ErrUnsupportedNetwork) and XRP was absent from all config.
- depositcheck.checkXRP: rippled account_info against the last *validated* ledger;
balance read as decimal drops (1 XRP = 1e6 drops), compared via the shared
compareBalance(decimals=6). An unfunded account (actNotFound) maps to the
(false,nil) 'no deposit yet' contract, not an error. strict:true so account is
read as an address. Same canonical rippled endpoints as the outbound broadcaster.
- networks.{mainnet,testnet}.yaml: XRP_{MAINNET,TESTNET} network + XRP token,
deposit+withdrawal enabled. XRP settles on secp256k1 (same curve as the live
ETH/BTC pipeline) so it has no mpcd FROST/Ed25519 gap like SOL/TON; r-address
derives from the ecdsa_pub_key mpcd already returns. Empty release wallets fail
payouts safe until an operator funds them (>=10 XRP reserve + fee).
- 7 new XRP deposit tests (funded/boundary/insufficient/actNotFound x2/rpc-error/
malformed); retire the obsolete TestCheck_XRP_NotImplemented. depositcheck 36/36.
- Remove / / doc-comment mentions
- Remove 'liquid' from SupportedChains allowlist (-specific
chain identifier per CLAUDE.md, not a generic chain family)
- Update tenant config example strings and tests accordingly
- Trim BRAND-AUDIT.md to drop specific references while
keeping the foreign-brand rule
We don't use gcr.io across lux/hanzo/zoo. Switch the bridge runtime
stage from gcr.io/distroless/static-debian12:nonroot to FROM scratch,
copying ca-certs, tzdata, and passwd/group from the builder.
The third focused slice of the Protocol/PQ wire-through. Connects the
mchain Protocol enum (PR z/mchain-protocol-enum, PR#5) and the
dashboard sign-path plumbing (PR z/mchain-protocol-wire, PR#6) into the
signing driver so swaps can actually request pulsar-m-65 / corona /
magnetar on the wire — gated by a profile-aware policy function the
daemon's main.go supplies.
Adds:
ProtocolSigner interface — strict-PQ-capable. *mchain.Client
satisfies it natively after PR#6. Wider than CurveSigner —
SignForWalletOnProtocol takes (curve, protocol) so the daemon
routes the session to the matching threshold-protocol stack.
SigningDriver.ProtocolFor func(curve) Protocol
Profile-driven dispatcher. The daemon's main.go sets this from the
active bridge.BridgeProfile:
LuxStrictPQ → pulsar-m-65 (everything PQ)
BridgeClassicalCompat → ProtocolDefault (let mpcd pick by curve)
nil → curve-only path (legacy behavior)
The driver itself is policy-free — it asks ProtocolFor "what
should this swap use?" and trusts the answer. Profile-gating logic
stays in main.go where it belongs.
Dispatcher order in signOnCurve:
1. ProtocolFor returns non-default + signer satisfies ProtocolSigner
→ SignForWalletOnProtocol (PQ path with hint on the wire)
2. signer satisfies CurveSigner
→ SignForWalletOnCurve (existing curve dispatch)
3. fallback to SignForWallet (legacy secp256k1 only — mocks)
Backward compat: when ProtocolFor is nil OR returns ProtocolDefault,
the dispatcher behaves identically to its pre-PR self. Verified by
TestSigning_ProtocolFor_Nil_PreservesLegacyPath +
TestSigning_ProtocolFor_DefaultFallsBackToCurve.
Tests:
TestSigning_ProtocolFor_RoutesPQ — pulsar-m-65 on wire
TestSigning_ProtocolFor_DefaultFallsBackToCurve — no upgrade
TestSigning_ProtocolFor_Nil_PreservesLegacyPath — legacy unchanged
Full cmd/bridge suite (3.1s) green.
Builds on PR#5 (Protocol enum) + PR#6 (signViaDashboard wire). After
all three merge, the next slice is the actual main.go wiring that
inspects the active BridgeProfile and constructs the appropriate
ProtocolFor closure — that's a small change but it touches daemon
flags + profile-detection, wants its own PR.
Operator-facing configuration assets from origin/whispers/bridgev2.
No code changes — purely declarative configs and ops docs.
cmd/bridge/networks.mainnet.yaml (NEW, 270 LOC)
cmd/bridge/networks.testnet.yaml (refresh, +186 LOC)
Per-network deposit + release wallet configuration for the
cmd/bridge daemon. mainnet config is the first production-ready
file in tree.
compose.mainnet.yml (NEW, +78 LOC)
compose.testnet.yml (refresh, +56 LOC)
Compose stacks for the standalone mainnet/testnet deployments
— Postgres + bridge-server + bridge-ui, MPC/KMS/IAM env blocks
set per environment.
docs/operator-deploy-phase-1-4.md (NEW, 168 LOC)
Phase 1.4 deploy runbook: what cmd/bridge expects from
BridgeVM, mpcd, KMS, and IAM at boot, and how to wire them in
a real prod cluster. Pairs with REQUIREMENTS.md §13 to bridge
spec → deploy.
Build + cmd/bridge tests (5.0s) clean.
NOT included in this PR (saved for follow-ups):
- k8s/bridge-deployment.yaml — 199 LOC delta; review separately
so deployment edits aren't bundled with new config files
- REQUIREMENTS.md R6 spec refresh — track in its own doc PR
- app/bridge/src/bridge.config.ts Lux mainnet/testnet wagmi
additions — small but a tenant-frontend change, separate slice