bridge: an unknown network is unknown, not Ethereum

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.
This commit is contained in:
2026-08-30 17:10:41 -07:00
parent 15e43615f1
commit cea8ef5ad6
12 changed files with 163 additions and 54 deletions
+5 -1
View File
@@ -125,7 +125,11 @@ func (c Config) findToken(network, asset string) (Token, bool) {
// stays default-ON (its many tokens carry no explicit flag). The network is
// upper-cased to match mchain.AddressTypeFor's exact-uppercase routing table.
func gatedFamily(network string) bool {
return mchain.AddressTypeFor(strings.ToUpper(network)) != mchain.AddressTypeETH
// An unknown network is gated. It used to read as EVM and pass, which is
// the wrong direction for a gate: a family nobody has named is exactly the
// one to hold, not the one to wave through.
t, known := mchain.AddressTypeFor(strings.ToUpper(network))
return !known || t != mchain.AddressTypeETH
}
// WithdrawalEnabled is the authoritative server-side gate behind the
+6 -1
View File
@@ -51,7 +51,12 @@ var estate = []struct {
// non-EVM addition from inheriting that default silently.
func TestEstateDerivesEthAddresses(t *testing.T) {
for _, c := range estate {
if got := mchain.AddressTypeFor(c.network); got != mchain.AddressTypeETH {
got, known := mchain.AddressTypeFor(c.network)
if !known {
t.Errorf("%s has no address family — custody would mint for an unknown chain", c.network)
continue
}
if got != mchain.AddressTypeETH {
t.Errorf("%s derives %v, want eth — custody address would be malformed", c.network, got)
}
}
+42
View File
@@ -5,6 +5,8 @@ import (
"strings"
"testing"
"github.com/luxfi/bridge/internal/depositcheck"
"github.com/luxfi/bridge/internal/mchain"
"github.com/luxfi/bridge/internal/txassembler"
)
@@ -97,3 +99,43 @@ func TestEveryOfferedNetworkHasItsNativeAsset(t *testing.T) {
}
}
}
// Every network offered must be complete across every table a crossing
// touches, not just the one whoever added it happened to edit.
//
// A network reaches a user through several independently-edited tables. Present
// in all but one, it is selectable, takes the deposit, and fails at the missing
// step — after the funds have moved. That is exactly what happened when the
// nine networks below were added: the network rows and the asset rows were
// filled and the deposit-RPC table was not, so a Celo deposit would have been
// watched for on a chain the watcher could not reach.
func TestEveryOfferedNetworkIsCompleteAcrossTheTables(t *testing.T) {
cfg, err := LoadConfig("networks.mainnet.yaml")
if err != nil {
t.Fatalf("load: %v", err)
}
assets := map[string]bool{}
for _, tk := range cfg.Tokens {
assets[tk.Network] = true
}
for _, n := range cfg.Networks {
// The address family. Without it custody mints on the wrong chain,
// and until today an unnamed network silently became EVM.
if _, known := mchain.AddressTypeFor(n.InternalName); !known {
t.Errorf("%s: no address family — custody cannot mint a correct deposit address", n.InternalName)
}
// The deposit RPC. Without it a deposit is never seen and the swap
// waits forever on money that has already arrived.
if depositcheck.RPCURLFor(n.InternalName) == "" {
t.Errorf("%s: no deposit RPC — a deposit to it would never be observed", n.InternalName)
}
// At least one asset, or there is nothing to move.
if !assets[n.InternalName] {
t.Errorf("%s: no asset rows", n.InternalName)
}
}
}
+7 -12
View File
@@ -264,14 +264,13 @@ networks:
txExplorerTpl: https://polkascan.io/polkadot/extrinsic/{addr}
addrExplorerTpl: https://polkascan.io/polkadot/account/{addr}
- internalName: CARDANO_MAINNET
displayName: Cardano
nativeCurrency: ADA
type: cardano
isFeatured: false
avgCompletion: 2m
txExplorerTpl: https://cardanoscan.io/transaction/{tx}
addrExplorerTpl: https://cardanoscan.io/address/{addr}
# CARDANO is not offered yet, and the gap is the deposit side rather than
# the address. The CIP-19 encoder exists (app/server cardano-address.ts,
# verified against its own vectors) and internal/mchain names the ADA
# family — but internal/depositcheck has no Cardano checker and no RPC, so
# a deposit to a correct address would be watched for by nobody and the
# swap would wait forever on money that had already arrived. It goes in
# when the checker does.
tokens:
# The chains Lux runs — native coin only. Liquid tokens get added per
@@ -476,10 +475,6 @@ tokens:
name: Polkadot
decimals: 10
network: POLKADOT_MAINNET
- asset: ADA
name: Cardano
decimals: 6
network: CARDANO_MAINNET
limits:
minUSD: 10
+11 -4
View File
@@ -103,7 +103,11 @@ func (s *ReleasePoolSet) Acquire(ctx context.Context, destinationNetwork string)
// poolFor returns the *ReleasePool registered for the network's family.
// Pure routing — no allocation, no I/O.
func (s *ReleasePoolSet) poolFor(network string) (*ReleasePool, error) {
switch mchain.AddressTypeFor(network) {
addrType, known := mchain.AddressTypeFor(network)
if !known {
return nil, fmt.Errorf("%w: no address family for %s", ErrNoPoolForFamily, network)
}
switch addrType {
case mchain.AddressTypeETH:
if s.EVM == nil {
return nil, fmt.Errorf("%w: ETH", ErrNoPoolForFamily)
@@ -130,8 +134,7 @@ func (s *ReleasePoolSet) poolFor(network string) (*ReleasePool, error) {
}
return s.XRP, nil
default:
return nil, fmt.Errorf("%w: %s", ErrNoPoolForFamily,
mchain.AddressTypeFor(network))
return nil, fmt.Errorf("%w: %s", ErrNoPoolForFamily, addrType)
}
}
@@ -140,7 +143,11 @@ func (s *ReleasePoolSet) poolFor(network string) (*ReleasePool, error) {
// Distinct from poolFor (which returns an error) so callers don't have
// to translate "no pool" into a probe boolean.
func (s *ReleasePoolSet) PoolFor(network string) *ReleasePool {
switch mchain.AddressTypeFor(network) {
addrType, known := mchain.AddressTypeFor(network)
if !known {
return nil
}
switch addrType {
case mchain.AddressTypeETH:
return s.EVM
case mchain.AddressTypeSOL:
+1 -1
View File
@@ -24,7 +24,7 @@ type fakeKeygenerFamily struct {
func (k *fakeKeygenerFamily) KeygenForDeposit(_ context.Context, network string) (*mchain.Wallet, error) {
i := k.calls.Add(1)
t := mchain.AddressTypeFor(network)
t, _ := mchain.AddressTypeFor(network)
addr := fmt.Sprintf("0x%dETH", i)
if t == mchain.AddressTypeSOL {
addr = fmt.Sprintf("SoLaNa%d", i)
+14 -4
View File
@@ -251,6 +251,14 @@ type SigningDriver struct {
// signing driver will use real EVM EIP-155 sighashes (not placeholder
// digests) and finalize a wire-ready raw tx into Swap.DestRawTx.
// Optional — leaving it nil retains the v1 placeholder behavior.
// isFamily reports whether a network is of this address family. An unknown
// network is of no family — it does not silently become the one being tested
// for, and it does not silently become EVM either.
func isFamily(network string, want mchain.AddressType) bool {
t, known := mchain.AddressTypeFor(network)
return known && t == want
}
func (d *SigningDriver) SetAssembler(asm *txassembler.Assembler) { d.assembler = asm }
// SetDOTAssembler wires the Polkadot / Substrate assembler. With a DOT
@@ -540,7 +548,7 @@ func (d *SigningDriver) signOne(ctx context.Context, sw *Swap) {
if walletID == "" {
fallback := d.pool
if d.dotPool != nil && d.dotPool.Size() > 0 &&
mchain.AddressTypeFor(sw.DestinationNetwork) == mchain.AddressTypeDOT {
isFamily(sw.DestinationNetwork, mchain.AddressTypeDOT) {
fallback = d.dotPool
}
if fallback != nil && fallback.Size() > 0 {
@@ -614,7 +622,7 @@ func (d *SigningDriver) signOne(ctx context.Context, sw *Swap) {
// SOL has its own message build + sign + finalize path because the
// cryptographic primitives are different (Ed25519 vs ECDSA, 64-byte
// sig vs 65-byte recoverable, no nonce/gas concept).
if mchain.AddressTypeFor(sw.DestinationNetwork) == mchain.AddressTypeSOL {
if isFamily(sw.DestinationNetwork, mchain.AddressTypeSOL) {
d.signOneSOL(ctx, sw, walletID, senderAddr)
return
}
@@ -623,7 +631,7 @@ func (d *SigningDriver) signOne(ctx context.Context, sw *Swap) {
// (Payment instead of EIP-155, DER signature instead of R||S||V,
// drops instead of wei). Route there before the EVM-specific
// assembler kicks in.
if mchain.AddressTypeFor(sw.DestinationNetwork) == mchain.AddressTypeXRP {
if isFamily(sw.DestinationNetwork, mchain.AddressTypeXRP) {
d.signOneXRP(ctx, sw, walletID, senderAddr, poolEntry)
return
}
@@ -639,7 +647,9 @@ func (d *SigningDriver) signOne(ctx context.Context, sw *Swap) {
var dotUnsigned *txassembler.DOTUnsigned // DOT
var tonUnsigned *txassembler.TONUnsigned // TON
destFamily := mchain.AddressTypeFor(sw.DestinationNetwork)
// An unknown destination matches no arm below and falls to the default,
// which is a refusal rather than the EVM path.
destFamily, _ := mchain.AddressTypeFor(sw.DestinationNetwork)
switch {
case destFamily == mchain.AddressTypeDOT && d.dotAssembler != nil && d.dotChainCtx != nil && senderAddr != "":
u, msg, aerr := d.preSignDOT(ctx, sw, walletID)
+1 -1
View File
@@ -196,7 +196,7 @@ func (a *API) swapsCreateNative(c *zip.Ctx) error {
// own address shapes). Rejects X-addresses (which embed a tag the
// bridge can't honor through its separate DestinationTag field) and
// validates the classic r-address. See xrpl.ValidateDestination.
if mchain.AddressTypeFor(req.DestinationNetwork) == mchain.AddressTypeXRP {
if isFamily(req.DestinationNetwork, mchain.AddressTypeXRP) {
if err := xrpl.ValidateDestination(req.DestinationAddress); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "bad_destination",
+8 -1
View File
@@ -243,7 +243,14 @@ func (c *Client) Broadcast(ctx context.Context, network, rawTxHex string) (*Broa
// the operator's own Bitcoin Core via Client.RPCURLOverrides);
// SOL uses sendTransaction; DOT uses author_submitExtrinsic;
// XRP uses rippled's `submit` JSON-RPC; TON uses TON Center sendBoc.
switch mchain.AddressTypeFor(network) {
// An unknown network has no known transaction format, so there is no
// correct way to broadcast for it. The switch below falls through to a
// refusal rather than reaching the EVM arm by default.
addrType, known := mchain.AddressTypeFor(network)
if !known {
return nil, fmt.Errorf("broadcast: no transaction format for network %s", network)
}
switch addrType {
case mchain.AddressTypeETH:
return c.broadcastEVM(ctx, url, rawTxHex)
case mchain.AddressTypeBTC:
+14 -1
View File
@@ -78,6 +78,13 @@ var rpcURLs = map[string]string{
"ARBITRUM_MAINNET": "https://arb1.arbitrum.io/rpc",
"OPTIMISM_MAINNET": "https://mainnet.optimism.io",
"AVAX_MAINNET": "https://api.avax.network/ext/bc/C/rpc",
"CELO_MAINNET": "https://forno.celo.org",
"GNOSIS_MAINNET": "https://rpc.gnosischain.com",
"FANTOM_MAINNET": "https://rpc.ftm.tools",
"AURORA_MAINNET": "https://mainnet.aurora.dev",
"ZORA_MAINNET": "https://rpc.zora.energy",
"BLAST_MAINNET": "https://rpc.blast.io",
"LINEA_MAINNET": "https://rpc.linea.build",
"HOLESKY_TESTNET": "https://ethereum-holesky-rpc.publicnode.com",
// Bitcoin (Blockstream REST API base — note: NOT JSON-RPC)
"BITCOIN_MAINNET": "https://blockstream.info/api",
@@ -203,7 +210,13 @@ func (c *Client) Check(ctx context.Context, p CheckParams) (bool, error) {
return false, fmt.Errorf("%w: %s", ErrUnsupportedNetwork, p.NetworkInternalName)
}
addrType := mchain.AddressTypeFor(p.NetworkInternalName)
// Same refusal as the broadcaster: watching an unknown network 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.
addrType, known := mchain.AddressTypeFor(p.NetworkInternalName)
if !known {
return false, fmt.Errorf("%w: %s", ErrUnsupportedNetwork, p.NetworkInternalName)
}
switch addrType {
case mchain.AddressTypeETH:
return c.checkEVM(ctx, url, p.NetworkInternalName, p.Address, p.Asset, p.RequiredAmount)
+31 -11
View File
@@ -53,6 +53,11 @@ const (
AddressTypeTON AddressType = "ton"
AddressTypeXRP AddressType = "xrp"
AddressTypeDOT AddressType = "dot"
// Cardano. Same Ed25519 key as Solana and a different address: bech32
// addr1… over blake2b-224 (CIP-19) rather than the base58 public key.
// Mapping it to SOL, as this table did, issues a Solana-shaped string
// to send ADA to.
AddressTypeADA AddressType = "ada"
)
// Curve names the threshold-signature scheme the MPC cluster keygens /
@@ -139,18 +144,24 @@ var networkAddressType = map[string]AddressType{
"KUSAMA_MAINNET": AddressTypeDOT,
// Cardano — placeholder (Ed25519); use the sol address slot until a
// proper Cardano encoder lands. Matches TS mpc-wallet.ts behaviour.
"CARDANO_MAINNET": AddressTypeSOL,
"CARDANO_MAINNET": AddressTypeADA,
}
// AddressTypeFor returns the configured address type for an
// MPC-supported network. Unknown networks default to AddressTypeETH —
// safe because every BridgeVM-supported chain is at minimum an EVM,
// and an EVM 0x… address is meaningful as a fallback identifier.
func AddressTypeFor(networkInternalName string) AddressType {
if t, ok := networkAddressType[networkInternalName]; ok {
return t
}
return AddressTypeETH
// AddressTypeFor returns the configured address type for a network, and
// whether the network is known.
//
// The default used to be AddressTypeETH, on the reasoning that "every
// BridgeVM-supported chain is at minimum an EVM". That has not been true
// since Bitcoin: an unlisted non-EVM network silently became an EVM one, and
// an 0x address was handed out for a chain that has never seen one. A network
// this table does not name is a network whose address format is unknown, and
// unknown is not eth.
//
// Callers that need a value for a known-EVM network should look it up; callers
// that get ok=false should refuse rather than guess.
func AddressTypeFor(networkInternalName string) (AddressType, bool) {
t, ok := networkAddressType[networkInternalName]
return t, ok
}
// =============================================================================
@@ -459,7 +470,16 @@ func (c *Client) KeygenForDepositWithOrg(ctx context.Context, networkInternalNam
if c.APIURL == "" {
return nil, &MPCError{Op: "keygen", Message: "APIURL not configured"}
}
addrType := AddressTypeFor(networkInternalName)
// Refuse rather than guess. Minting a deposit wallet for a network whose
// address format we do not know produces an address on the wrong chain,
// which is discovered when a user sends funds to it.
addrType, known := AddressTypeFor(networkInternalName)
if !known {
return nil, &MPCError{
Op: "keygen",
Message: "no address format for network " + networkInternalName,
}
}
if orgID == "" {
return nil, &MPCError{Op: "keygen", Message: "org_id required (set Client.OrgID or pass per-call)"}
}
+23 -17
View File
@@ -77,6 +77,17 @@ func clientFor(m *mockCluster, opts ...func(*Client)) *Client {
// AddressTypeFor — registry lookups
// =============================================================================
// An unknown network reports unknown, not eth. The table used to return eth
// for anything it did not name — safe only while every chain was an EVM, which
// stopped being true at Bitcoin.
func TestAddressTypeFor_UnknownIsUnknown(t *testing.T) {
for _, net := range []string{"UNKNOWN_NETWORK", "MARS_MAINNET", ""} {
if got, known := AddressTypeFor(net); known {
t.Errorf("AddressTypeFor(%q) = %q, known — an unnamed network has no known address format", net, got)
}
}
}
func TestAddressTypeFor_Coverage(t *testing.T) {
cases := []struct {
net string
@@ -92,12 +103,12 @@ func TestAddressTypeFor_Coverage(t *testing.T) {
{"POLKADOT_MAINNET", AddressTypeDOT},
{"POLKADOT_TESTNET", AddressTypeDOT},
{"KUSAMA_MAINNET", AddressTypeDOT},
{"CARDANO_MAINNET", AddressTypeSOL}, // placeholder slot
{"UNKNOWN_NETWORK", AddressTypeETH}, // default fallback
{"CARDANO_MAINNET", AddressTypeADA}, // placeholder slot
// default fallback
}
for _, tc := range cases {
t.Run(tc.net, func(t *testing.T) {
if got := AddressTypeFor(tc.net); got != tc.want {
if got, _ := AddressTypeFor(tc.net); got != tc.want {
t.Errorf("AddressTypeFor(%q) = %q, want %q", tc.net, got, tc.want)
}
})
@@ -389,27 +400,22 @@ func TestKeygen_DOT_TestnetPrefix(t *testing.T) {
}
}
func TestKeygen_UnknownNetwork_DefaultsToETH(t *testing.T) {
func TestKeygen_UnknownNetwork_IsRefused(t *testing.T) {
// This asserted the opposite until today: an unknown network fell back to
// an ETH address, on the reasoning that every supported chain "is at
// minimum an EVM". That stopped being true at Bitcoin. A network the
// address table does not name has an unknown address format, and minting
// an 0x address for it produces an address on the wrong chain — found
// when a user sends funds to it.
m := newMockCluster(t)
m.result = &keygenResult{ETHAddress: "0xfallback"}
c := clientFor(m)
w, err := c.KeygenForDeposit(context.Background(), "MARS_MAINNET")
if err != nil {
t.Fatalf("err: %v", err)
}
if w.AddressType != AddressTypeETH {
t.Errorf("AddressType = %q, want eth fallback", w.AddressType)
}
if w.Address != "0xfallback" {
t.Errorf("Address = %q", w.Address)
if _, err := c.KeygenForDeposit(context.Background(), "MARS_MAINNET"); err == nil {
t.Fatal("a wallet was minted for a network with no known address format")
}
}
// =============================================================================
// LegacyDepositString format (TS SDK split-on-### contract)
// =============================================================================
func TestLegacyDepositString(t *testing.T) {
w := &Wallet{Name: "bridge-lux_testnet-12345", Address: "0xdeadbeef"}
if got := w.LegacyDepositString(); got != "bridge-lux_testnet-12345###0xdeadbeef" {