bridge: the assembler reads the networks the operator offers

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.
This commit is contained in:
2026-08-30 17:10:41 -07:00
parent 85aba53d5f
commit b8cbf65607
4 changed files with 121 additions and 7 deletions
+3 -1
View File
@@ -148,7 +148,9 @@ func itoa(n int64) string { return big.NewInt(n).String() }
// a signature the destination rejects — or, worse, one another chain accepts.
func TestEstateChainIDsAreConfigured(t *testing.T) {
asm := txassembler.New(nil)
configureEVM(asm)
// nil networks: this test is about the fallback table, and passing the
// config would test the config instead.
configureEVM(asm, nil)
for _, c := range estate {
net, ok := asm.Networks[c.network]
if !ok {
+43 -5
View File
@@ -13,6 +13,7 @@ package main
import (
"math/big"
"strings"
"github.com/luxfi/bridge/internal/txassembler"
)
@@ -23,9 +24,16 @@ import (
// nodes report; each endpoint in internal/txassembler, internal/broadcast and
// internal/depositcheck was asked eth_chainId and answered the id here. The
// rest are the public networks the bridge settles to.
// evmChainIDs is the fallback for a config that declares no chain id, and it
// exists only for that. The networks file is the source: it already carries
// `chainId` and `type: evm` per network, and a second copy here is a second
// thing to keep in step — which it was not. Arbitrum, Optimism and Avalanche
// were offered to users while this map had never heard of them, so a swap to
// any of the three could be accepted and then not assembled.
//
// An EIP-155 id is signed into the transaction. A wrong one produces a
// signature valid on some OTHER chain, so guessing is worse than refusing.
var evmChainIDs = map[string]int64{
// Lux and the chains it runs. Sovereign L1s, each its own primary
// network, so an id here is the whole identity of the chain.
"LUX_MAINNET": 96369,
"LUX_TESTNET": 96368,
"ZOO_MAINNET": 200200,
@@ -34,7 +42,6 @@ var evmChainIDs = map[string]int64{
"PARS_MAINNET": 494949,
"OSAGE_MAINNET": 1872,
// Public networks.
"ETHEREUM_MAINNET": 1,
"ETHEREUM_SEPOLIA": 11155111,
"HOLESKY_TESTNET": 17000,
@@ -43,11 +50,42 @@ var evmChainIDs = map[string]int64{
"POLYGON_MAINNET": 137,
"BSC_MAINNET": 56,
"BSC_TESTNET": 97,
"ARBITRUM_MAINNET": 42161,
"OPTIMISM_MAINNET": 10,
"AVAX_MAINNET": 43114,
}
// configureEVM registers every EVM network on the assembler.
func configureEVM(asm *txassembler.Assembler) {
// configureEVM registers every EVM network the config declares.
//
// Reading the config rather than a list here is what stops the two drifting.
// A network the operator offers is a network a user can select, and one the
// assembler has never heard of fails AFTER they have sent funds — the worst
// place in the flow to discover a gap.
func configureEVM(asm *txassembler.Assembler, networks []Network) {
seen := map[string]bool{}
for _, n := range networks {
if !strings.EqualFold(n.Type, "evm") {
continue
}
id, ok := new(big.Int).SetString(n.ChainID, 10)
if !ok || id.Sign() <= 0 {
// No usable id: fall through to the table, and if that has none
// either the network stays unregistered and a swap to it is
// refused rather than mis-signed.
continue
}
asm.SetNetwork(n.InternalName, txassembler.PerNetwork{
ChainID: id,
NativeDecimals: 18,
})
seen[n.InternalName] = true
}
for network, id := range evmChainIDs {
if seen[network] {
continue
}
asm.SetNetwork(network, txassembler.PerNetwork{
ChainID: big.NewInt(id),
NativeDecimals: 18,
+74
View File
@@ -0,0 +1,74 @@
package main
import (
"math/big"
"strings"
"testing"
"github.com/luxfi/bridge/internal/txassembler"
)
// Every EVM network the operator offers must be assemblable.
//
// The gap this catches is not cosmetic. A network in the picker but absent
// from the assembler is accepted from the user, takes their funds, and then
// cannot build the release — the failure lands after the money has moved.
// Arbitrum, Optimism and Avalanche were live in exactly that state.
func TestEveryOfferedEVMNetworkCanBeAssembled(t *testing.T) {
cfg, err := LoadConfig("networks.mainnet.yaml")
if err != nil {
t.Fatalf("load: %v", err)
}
asm := txassembler.New(nil)
configureEVM(asm, cfg.Networks)
var missing []string
var evm int
for _, n := range cfg.Networks {
if !strings.EqualFold(n.Type, "evm") {
continue
}
evm++
got, ok := asm.Networks[n.InternalName]
if !ok {
missing = append(missing, n.InternalName+" (unregistered)")
continue
}
// The id is signed into the transaction. A wrong one yields a
// signature valid on some other chain, which is worse than none.
want, _ := new(big.Int).SetString(n.ChainID, 10)
if want != nil && got.ChainID.Cmp(want) != 0 {
missing = append(missing, n.InternalName+" (id "+got.ChainID.String()+", config says "+n.ChainID+")")
}
}
if evm == 0 {
t.Fatal("no EVM networks in the config, so this test proves nothing")
}
if len(missing) > 0 {
t.Fatalf("offered but not assemblable: %s", strings.Join(missing, ", "))
}
t.Logf("%d EVM networks offered, all assemblable", evm)
}
// A non-EVM network must NOT be registered as one. Bitcoin has no EIP-155 id,
// and giving it one would be inventing a fact about a chain.
func TestNonEVMNetworksAreNotRegisteredAsEVM(t *testing.T) {
cfg, err := LoadConfig("networks.mainnet.yaml")
if err != nil {
t.Fatalf("load: %v", err)
}
asm := txassembler.New(nil)
configureEVM(asm, cfg.Networks)
for _, n := range cfg.Networks {
if strings.EqualFold(n.Type, "evm") {
continue
}
if _, ok := asm.Networks[n.InternalName]; ok {
t.Errorf("%s is %q, not evm, and was registered on the EVM assembler", n.InternalName, n.Type)
}
}
}
+1 -1
View File
@@ -527,7 +527,7 @@ func main() {
asmProvider := txassembler.NewRPCProvider(overrides, 8*time.Second)
asm := txassembler.New(asmProvider)
asm.Tokens = tokenRegistry
configureEVM(asm)
configureEVM(asm, cfg.Networks)
// XRP-side of the assembler. Without an XRPProvider the
// PreSignXRP path refuses to build — the signing driver short-