bridgevm: the network declares its bridge limits, not the operator

The caps decide which transfers a block may carry — admissible() refuses a
transfer over the per-transfer cap and admit() refuses one over the daily cap,
both inside block verification. Reading them from the operator's chain config
meant two validators could hold different numbers and disagree about whether a
transfer was admissible, and the first one they disagreed about is a transfer
the builder keeps proposing and everyone else keeps refusing. A cap each
operator sets for themselves is not a cap.

Limits (minConfirmations, the two spend caps, the signer bond, the set size)
move to Genesis, where the network declares them once and every validator reads
the same numbers. BridgeConfig keeps only ExternalChains — endpoints and KMS
paths, how THIS node reaches the chains it was told to bridge, which cannot
change which transfers are valid. The dead BridgeFee field goes.

MaxSigners keeps its default: it is a capacity, not a risk decision.
This commit is contained in:
2026-09-07 00:06:37 -07:00
parent 6a9888847c
commit a0e796b1fe
17 changed files with 136 additions and 111 deletions
+3 -3
View File
@@ -126,7 +126,7 @@ func TestTheHeightIndexNamesOnlyAcceptedBlocks(t *testing.T) {
// the number that bounds what this bridge can move. // the number that bounds what this bridge can move.
func TestTheDailyCapSurvivesARestart(t *testing.T) { func TestTheDailyCapSurvivesARestart(t *testing.T) {
db := memdb.New() db := memdb.New()
cfg := testConfig() cfg := testLimits()
cfg.MaxBridgeAmount = 600 cfg.MaxBridgeAmount = 600
cfg.DailyBridgeLimit = 1000 cfg.DailyBridgeLimit = 1000
@@ -157,7 +157,7 @@ func TestTheDailyCapSurvivesARestart(t *testing.T) {
// TestTheCapIsCountedPerDestination keeps one busy route from closing another. // TestTheCapIsCountedPerDestination keeps one busy route from closing another.
func TestTheCapIsCountedPerDestination(t *testing.T) { func TestTheCapIsCountedPerDestination(t *testing.T) {
cfg := testConfig() cfg := testLimits()
cfg.MaxBridgeAmount = 100 cfg.MaxBridgeAmount = 100
cfg.DailyBridgeLimit = 100 cfg.DailyBridgeLimit = 100
vm := bootOn(t, memdb.New(), cfg) vm := bootOn(t, memdb.New(), cfg)
@@ -187,7 +187,7 @@ func TestTheCapIsCountedPerDestination(t *testing.T) {
// window is the BLOCK's time — the whole point of measuring it there rather // window is the BLOCK's time — the whole point of measuring it there rather
// than off each node's clock. // than off each node's clock.
func TestTheWindowReopensOnTheNextDay(t *testing.T) { func TestTheWindowReopensOnTheNextDay(t *testing.T) {
cfg := testConfig() cfg := testLimits()
cfg.MaxBridgeAmount = 100 cfg.MaxBridgeAmount = 100
cfg.DailyBridgeLimit = 100 cfg.DailyBridgeLimit = 100
vm := bootOn(t, memdb.New(), cfg) vm := bootOn(t, memdb.New(), cfg)
+1 -1
View File
@@ -221,7 +221,7 @@ func (b *Block) Verify(ctx context.Context) error {
// admit records each transfer as it goes, so a block carrying the same // admit records each transfer as it goes, so a block carrying the same
// one twice fails on the second: the first has already been recorded // one twice fails on the second: the first has already been recorded
// settled, in this same state. // settled, in this same state.
if err := state.admit(&b.vm.config, day, req); err != nil { if err := state.admit(&b.vm.limits, day, req); err != nil {
return err return err
} }
} }
+2 -2
View File
@@ -19,7 +19,7 @@ import (
// they should be carried in. // they should be carried in.
func vmWithPending(t *testing.T, n int) (*VM, []ids.ID) { func vmWithPending(t *testing.T, n int) (*VM, []ids.ID) {
t.Helper() t.Helper()
cfg := testConfig() cfg := testLimits()
cfg.DailyBridgeLimit = 1 << 40 cfg.DailyBridgeLimit = 1 << 40
vm := bootOn(t, memdb.New(), cfg) vm := bootOn(t, memdb.New(), cfg)
@@ -94,7 +94,7 @@ func TestBuildBlockRefusesWhenNothingIsReady(t *testing.T) {
// pending set by the rejection, and proposed again — block production stopped // pending set by the rejection, and proposed again — block production stopped
// and did not resume. // and did not resume.
func TestWhatIsBuiltIsWhatVerifies(t *testing.T) { func TestWhatIsBuiltIsWhatVerifies(t *testing.T) {
cfg := testConfig() cfg := testLimits()
cfg.MaxBridgeAmount = 100 cfg.MaxBridgeAmount = 100
cfg.DailyBridgeLimit = 250 cfg.DailyBridgeLimit = 250
vm := bootOn(t, memdb.New(), cfg) vm := bootOn(t, memdb.New(), cfg)
+6 -6
View File
@@ -24,13 +24,13 @@ import (
// starting somewhere else. // starting somewhere else.
func TestAChainThatCannotReadItsTipDoesNotStart(t *testing.T) { func TestAChainThatCannotReadItsTipDoesNotStart(t *testing.T) {
db := memdb.New() db := memdb.New()
vm := bootOn(t, db, testConfig()) vm := bootOn(t, db, testLimits())
pend(vm, requestFor(1, 100)) pend(vm, requestFor(1, 100))
blk := buildAndAccept(t, vm) blk := buildAndAccept(t, vm)
// The tip is recorded; the block it names is gone. // The tip is recorded; the block it names is gone.
require.NoError(t, db.Delete(blockRecordKey(blk.ID()))) require.NoError(t, db.Delete(blockRecordKey(blk.ID())))
require.Error(t, (&VM{}).Initialize(context.Background(), initFor(db, testConfig()))) require.Error(t, (&VM{}).Initialize(context.Background(), initFor(db, testLimits())))
} }
// blockRecordKey is where the shared store keeps a block's bytes. // blockRecordKey is where the shared store keeps a block's bytes.
@@ -43,7 +43,7 @@ func TestAChainWithAnUnreadableTipDoesNotStart(t *testing.T) {
db := memdb.New() db := memdb.New()
require.NoError(t, db.Put([]byte("chain/tip"), make([]byte, ids.IDLen))) require.NoError(t, db.Put([]byte("chain/tip"), make([]byte, ids.IDLen)))
// Zero is a well-formed id naming no block. // Zero is a well-formed id naming no block.
require.Error(t, (&VM{}).Initialize(context.Background(), initFor(db, testConfig()))) require.Error(t, (&VM{}).Initialize(context.Background(), initFor(db, testLimits())))
} }
// TestARelayerThatIsNotReadingReportsIt. A node with chains wired and a // TestARelayerThatIsNotReadingReportsIt. A node with chains wired and a
@@ -212,7 +212,7 @@ func TestRPC_ReplaceSignerWithANamedReplacement(t *testing.T) {
func TestRPC_TheWaitlistIsNamed(t *testing.T) { func TestRPC_TheWaitlistIsNamed(t *testing.T) {
srv, vm := newRPCRig(t) srv, vm := newRPCRig(t)
vm.config.MaxSigners = 1 vm.limits.MaxSigners = 1
first, waiting := ids.GenerateTestNodeID(), ids.GenerateTestNodeID() first, waiting := ids.GenerateTestNodeID(), ids.GenerateTestNodeID()
require.NoError(t, registerSigner(vm, first)) require.NoError(t, registerSigner(vm, first))
require.NoError(t, registerSigner(vm, waiting)) require.NoError(t, registerSigner(vm, waiting))
@@ -268,7 +268,7 @@ func TestRPC_GetInfoOnceThereIsAGroupKey(t *testing.T) {
// A counter bridge_getInfo cannot read is reported, not summed as zero. // A counter bridge_getInfo cannot read is reported, not summed as zero.
func TestRPC_GetInfoSaysWhenItCannotRead(t *testing.T) { func TestRPC_GetInfoSaysWhenItCannotRead(t *testing.T) {
vm := bootOn(t, &unreadableDB{Database: memdb.New(), prefix: movedPrefix, err: errUnreadable}, vm := bootOn(t, &unreadableDB{Database: memdb.New(), prefix: movedPrefix, err: errUnreadable},
testConfig()) testLimits())
srv, _ := serveVM(t, vm) srv, _ := serveVM(t, vm)
code, msg := callRPC(t, srv.URL, "bridge_getInfo", nil, &GetBridgeInfoReply{}) code, msg := callRPC(t, srv.URL, "bridge_getInfo", nil, &GetBridgeInfoReply{})
@@ -408,7 +408,7 @@ func (*brokenIterator) Value() []byte { return nil }
func TestAStoreThatCannotBeWalkedDoesNotBoot(t *testing.T) { func TestAStoreThatCannotBeWalkedDoesNotBoot(t *testing.T) {
err := (&VM{}).Initialize(context.Background(), err := (&VM{}).Initialize(context.Background(),
initFor(unlistableDB{Database: memdb.New()}, testConfig())) initFor(unlistableDB{Database: memdb.New()}, testLimits()))
require.ErrorContains(t, err, "read settlement records") require.ErrorContains(t, err, "read settlement records")
} }
+20 -9
View File
@@ -41,13 +41,20 @@ const (
// would make every cross-node block mismatch look like correct behaviour. // would make every cross-node block mismatch look like correct behaviour.
var testChainID = ids.ID{'b', '-', 'c', 'h', 'a', 'i', 'n', '-', 't', 'e', 's', 't'} var testChainID = ids.ID{'b', '-', 'c', 'h', 'a', 'i', 'n', '-', 't', 'e', 's', 't'}
func testConfig() BridgeConfig { // testLimits is what the NETWORK declares in genesis.
return BridgeConfig{ func testLimits() Limits {
return Limits{
MinConfirmations: 12, MinConfirmations: 12,
MaxBridgeAmount: 1_000_000, MaxBridgeAmount: 1_000_000,
DailyBridgeLimit: 10_000_000, DailyBridgeLimit: 10_000_000,
RequireValidatorBond: minValidatorBond, RequireValidatorBond: minValidatorBond,
MaxSigners: 100, MaxSigners: 100,
}
}
// testChains is what the OPERATOR declares: how this node reaches them.
func testChains() BridgeConfig {
return BridgeConfig{
ExternalChains: []ExternalChainConfig{ ExternalChains: []ExternalChainConfig{
{Name: "lux-testnet-c", ChainID: uint64(srcChain), Gateway: "0x0000000000000000000000000000000000000001"}, {Name: "lux-testnet-c", ChainID: uint64(srcChain), Gateway: "0x0000000000000000000000000000000000000001"},
{Name: "zoo-testnet", ChainID: uint64(dstChain), Gateway: "0x0000000000000000000000000000000000000002"}, {Name: "zoo-testnet", ChainID: uint64(dstChain), Gateway: "0x0000000000000000000000000000000000000002"},
@@ -82,8 +89,12 @@ func (b *refusingBatch) Write() error {
} }
// initFor is what the node hands Initialize. // initFor is what the node hands Initialize.
func initFor(db database.Database, cfg BridgeConfig) vmcore.Init { func initFor(db database.Database, lim Limits) vmcore.Init {
raw, err := json.Marshal(cfg) raw, err := json.Marshal(testChains())
if err != nil {
panic(err)
}
gen, err := json.Marshal(Genesis{Timestamp: 1000000, Limits: lim})
if err != nil { if err != nil {
panic(err) panic(err)
} }
@@ -95,31 +106,31 @@ func initFor(db database.Database, cfg BridgeConfig) vmcore.Init {
NodeID: ids.GenerateTestNodeID(), NodeID: ids.GenerateTestNodeID(),
}, },
DB: db, DB: db,
Genesis: []byte(`{"timestamp":1000000}`), Genesis: gen,
Config: raw, Config: raw,
} }
} }
// bootOn brings a VM up over db exactly as the node does, with cfg as its // bootOn brings a VM up over db exactly as the node does, with cfg as its
// configuration. // configuration.
func bootOn(t *testing.T, db database.Database, cfg BridgeConfig) *VM { func bootOn(t *testing.T, db database.Database, lim Limits) *VM {
t.Helper() t.Helper()
vm := &VM{} vm := &VM{}
require.NoError(t, vm.Initialize(context.Background(), initFor(db, cfg))) require.NoError(t, vm.Initialize(context.Background(), initFor(db, lim)))
return vm return vm
} }
// boot is a VM on a fresh database with the standard configuration. // boot is a VM on a fresh database with the standard configuration.
func boot(t *testing.T) *VM { func boot(t *testing.T) *VM {
t.Helper() t.Helper()
return bootOn(t, memdb.New(), testConfig()) return bootOn(t, memdb.New(), testLimits())
} }
// bootRefusing is a VM whose database can be told to refuse a flush. // bootRefusing is a VM whose database can be told to refuse a flush.
func bootRefusing(t *testing.T) (*VM, *refusingDB) { func bootRefusing(t *testing.T) (*VM, *refusingDB) {
t.Helper() t.Helper()
db := &refusingDB{Database: memdb.New()} db := &refusingDB{Database: memdb.New()}
return bootOn(t, db, testConfig()), db return bootOn(t, db, testLimits()), db
} }
// transferFor is one locked transfer. The request's id is the digest of its // transferFor is one locked transfer. The request's id is the digest of its
+7 -7
View File
@@ -148,7 +148,7 @@ func counterBytes(v uint64) []byte {
// transfer that fails here can never be carried by any block, and holding one // transfer that fails here can never be carried by any block, and holding one
// would have it proposed, refused, and proposed again for as long as the node // would have it proposed, refused, and proposed again for as long as the node
// runs. // runs.
func admissible(cfg *BridgeConfig, req *BridgeRequest) error { func admissible(lim *Limits, req *BridgeRequest) error {
transfer, err := req.transfer() transfer, err := req.transfer()
if err != nil { if err != nil {
return err return err
@@ -169,9 +169,9 @@ func admissible(cfg *BridgeConfig, req *BridgeRequest) error {
if req.Amount == 0 { if req.Amount == 0 {
return fmt.Errorf("bridgevm: request %s moves nothing", req.ID) return fmt.Errorf("bridgevm: request %s moves nothing", req.ID)
} }
if req.Amount > cfg.MaxBridgeAmount { if req.Amount > lim.MaxBridgeAmount {
return fmt.Errorf("bridgevm: request %s moves %d, over the %d per-transfer cap", return fmt.Errorf("bridgevm: request %s moves %d, over the %d per-transfer cap",
req.ID, req.Amount, cfg.MaxBridgeAmount) req.ID, req.Amount, lim.MaxBridgeAmount)
} }
return nil return nil
} }
@@ -183,8 +183,8 @@ func admissible(cfg *BridgeConfig, req *BridgeRequest) error {
// order. Two rules would disagree eventually, and the first transfer they // order. Two rules would disagree eventually, and the first transfer they
// disagreed about would be one the builder keeps proposing and every node // disagreed about would be one the builder keeps proposing and every node
// keeps refusing — block production stops and does not resume. // keeps refusing — block production stops and does not resume.
func (s *spend) admit(cfg *BridgeConfig, day int64, req *BridgeRequest) error { func (s *spend) admit(lim *Limits, day int64, req *BridgeRequest) error {
if err := admissible(cfg, req); err != nil { if err := admissible(lim, req); err != nil {
return err return err
} }
already, err := s.isSettled(req.ID) already, err := s.isSettled(req.ID)
@@ -200,9 +200,9 @@ func (s *spend) admit(cfg *BridgeConfig, day int64, req *BridgeRequest) error {
} }
// Written as a subtraction so a total near the top of the range cannot // Written as a subtraction so a total near the top of the range cannot
// wrap past the cap. // wrap past the cap.
if req.Amount > cfg.DailyBridgeLimit-was { if req.Amount > lim.DailyBridgeLimit-was {
return fmt.Errorf("bridgevm: request %s would move %d to chain %d, over the %d daily cap (%d already moved)", return fmt.Errorf("bridgevm: request %s would move %d to chain %d, over the %d daily cap (%d already moved)",
req.ID, req.Amount, req.DstChainID, cfg.DailyBridgeLimit, was) req.ID, req.Amount, req.DstChainID, lim.DailyBridgeLimit, was)
} }
s.record(day, req, was) s.record(day, req, was)
return nil return nil
+4 -4
View File
@@ -57,7 +57,7 @@ func TestASpendItCannotReadIsNotZero(t *testing.T) {
// And the rule that reads it refuses rather than admitting on a total it // And the rule that reads it refuses rather than admitting on a total it
// could not obtain. // could not obtain.
cfg := testConfig() cfg := testLimits()
require.ErrorIs(t, s.admit(&cfg, 1, requestFor(1, 100)), errUnreadable) require.ErrorIs(t, s.admit(&cfg, 1, requestFor(1, 100)), errUnreadable)
} }
@@ -68,7 +68,7 @@ func TestASettlementItCannotReadIsNotAbsent(t *testing.T) {
_, err := s.isSettled(ids.GenerateTestID()) _, err := s.isSettled(ids.GenerateTestID())
require.ErrorIs(t, err, errUnreadable) require.ErrorIs(t, err, errUnreadable)
cfg := testConfig() cfg := testLimits()
require.ErrorIs(t, s.admit(&cfg, 1, requestFor(1, 100)), errUnreadable) require.ErrorIs(t, s.admit(&cfg, 1, requestFor(1, 100)), errUnreadable)
} }
@@ -124,7 +124,7 @@ func TestAChildsSpendIsItsOwn(t *testing.T) {
db := memdb.New() db := memdb.New()
parent := newSpend(db) parent := newSpend(db)
req := requestFor(1, 100) req := requestFor(1, 100)
cfg := testConfig() cfg := testLimits()
require.NoError(t, parent.admit(&cfg, 1, req)) require.NoError(t, parent.admit(&cfg, 1, req))
child := parent.clone() child := parent.clone()
@@ -142,7 +142,7 @@ func TestAChildsSpendIsItsOwn(t *testing.T) {
// A total near the top of the range does not wrap past the cap. // A total near the top of the range does not wrap past the cap.
func TestTheCapDoesNotWrap(t *testing.T) { func TestTheCapDoesNotWrap(t *testing.T) {
db := memdb.New() db := memdb.New()
cfg := testConfig() cfg := testLimits()
cfg.MaxBridgeAmount = ^uint64(0) cfg.MaxBridgeAmount = ^uint64(0)
cfg.DailyBridgeLimit = 1000 cfg.DailyBridgeLimit = 1000
require.NoError(t, db.Put(movedKey(1, dstChain), counterBytes(999))) require.NoError(t, db.Put(movedKey(1, dstChain), counterBytes(999)))
+2 -2
View File
@@ -190,8 +190,8 @@ func (r *releaser) releaseOnce(ctx context.Context, req *BridgeRequest, transfer
if err != nil { if err != nil {
return fmt.Errorf("bridgevm: source confirmation check: %w", err) return fmt.Errorf("bridgevm: source confirmation check: %w", err)
} }
if conf < r.vm.config.MinConfirmations { if conf < r.vm.limits.MinConfirmations {
return fmt.Errorf("%w: %d < %d", errInsufficientConfirmations, conf, r.vm.config.MinConfirmations) return fmt.Errorf("%w: %d < %d", errInsufficientConfirmations, conf, r.vm.limits.MinConfirmations)
} }
destTx, err := r.vm.releaseTransfer(ctx, transfer) destTx, err := r.vm.releaseTransfer(ctx, transfer)
if err != nil { if err != nil {
+1 -1
View File
@@ -51,7 +51,7 @@ func releaseReq() *BridgeRequest {
func confirmationRig(t *testing.T, depth uint32) (*releaser, *sourceClient, *recordingClient, *fixedAttester) { func confirmationRig(t *testing.T, depth uint32) (*releaser, *sourceClient, *recordingClient, *fixedAttester) {
t.Helper() t.Helper()
vm, dst, ac := releaseRig(t, nil) vm, dst, ac := releaseRig(t, nil)
vm.config.MinConfirmations = 12 vm.limits.MinConfirmations = 12
src := &sourceClient{recordingClient: &recordingClient{}, depth: depth} src := &sourceClient{recordingClient: &recordingClient{}, depth: depth}
vm.evmByChainID[1] = src vm.evmByChainID[1] = src
return &releaser{vm: vm}, src, dst, ac return &releaser{vm: vm}, src, dst, ac
+1 -1
View File
@@ -663,7 +663,7 @@ func (s *Service) chainConfig(cfg ExternalChainConfig) ChainConfigReply {
ChainID: strconv.FormatUint(cfg.ChainID, 10), ChainID: strconv.FormatUint(cfg.ChainID, 10),
ChainName: cfg.Name, ChainName: cfg.Name,
BridgeContract: cfg.Gateway, BridgeContract: cfg.Gateway,
Confirmations: int(s.vm.config.MinConfirmations), Confirmations: int(s.vm.limits.MinConfirmations),
Enabled: true, Enabled: true,
} }
} }
+1 -1
View File
@@ -252,7 +252,7 @@ func TestRPC_GetSupportedChains(t *testing.T) {
require.Equal(t, strconv.FormatUint(vm.config.ExternalChains[i].ChainID, 10), c.ChainID) require.Equal(t, strconv.FormatUint(vm.config.ExternalChains[i].ChainID, 10), c.ChainID)
require.Equal(t, vm.config.ExternalChains[i].Name, c.ChainName) require.Equal(t, vm.config.ExternalChains[i].Name, c.ChainName)
require.True(t, c.Enabled) require.True(t, c.Enabled)
require.Equal(t, int(vm.config.MinConfirmations), c.Confirmations) require.Equal(t, int(vm.limits.MinConfirmations), c.Confirmations)
} }
} }
+1 -1
View File
@@ -17,7 +17,7 @@ import (
// boots one. // boots one.
func signerVM(t *testing.T, maxSigners int) *VM { func signerVM(t *testing.T, maxSigners int) *VM {
t.Helper() t.Helper()
cfg := testConfig() cfg := testLimits()
cfg.MaxSigners = maxSigners cfg.MaxSigners = maxSigners
return bootOn(t, memdb.New(), cfg) return bootOn(t, memdb.New(), cfg)
} }
+8 -8
View File
@@ -57,11 +57,11 @@ func TestATransferSettledByAnEarlierBlockCannotBeSettledAgain(t *testing.T) {
// and a re-observed lock was released a second time. // and a re-observed lock was released a second time.
func TestATransferSettledBeforeARestartCannotBeSettledAgain(t *testing.T) { func TestATransferSettledBeforeARestartCannotBeSettledAgain(t *testing.T) {
db := memdb.New() db := memdb.New()
vm := bootOn(t, db, testConfig()) vm := bootOn(t, db, testLimits())
pend(vm, requestFor(1, 500)) pend(vm, requestFor(1, 500))
first := buildAndAccept(t, vm) first := buildAndAccept(t, vm)
restarted := bootOn(t, db, testConfig()) restarted := bootOn(t, db, testLimits())
again := blockOn(t, restarted, first, now(), requestFor(1, 500)) again := blockOn(t, restarted, first, now(), requestFor(1, 500))
require.ErrorIs(t, again.Verify(context.Background()), errReplay) require.ErrorIs(t, again.Verify(context.Background()), errReplay)
} }
@@ -85,7 +85,7 @@ func TestATransferInFlightCannotAlsoRideItsChild(t *testing.T) {
// TestTheDailyCapHoldsAcrossBlocksInFlight. Checked against the tip alone, a // TestTheDailyCapHoldsAcrossBlocksInFlight. Checked against the tip alone, a
// run of blocks that are all in the air each get the whole day's allowance. // run of blocks that are all in the air each get the whole day's allowance.
func TestTheDailyCapHoldsAcrossBlocksInFlight(t *testing.T) { func TestTheDailyCapHoldsAcrossBlocksInFlight(t *testing.T) {
cfg := testConfig() cfg := testLimits()
cfg.MaxBridgeAmount = 100 cfg.MaxBridgeAmount = 100
cfg.DailyBridgeLimit = 150 cfg.DailyBridgeLimit = 150
vm := bootOn(t, memdb.New(), cfg) vm := bootOn(t, memdb.New(), cfg)
@@ -140,7 +140,7 @@ func TestARequestMustBeTheDigestOfWhatItCarries(t *testing.T) {
func TestARequestMustBeWellFormed(t *testing.T) { func TestARequestMustBeWellFormed(t *testing.T) {
vm := boot(t) vm := boot(t)
cfg := &vm.config cfg := &vm.limits
// A recipient that is not an address cannot be released to. // A recipient that is not an address cannot be released to.
short := requestFor(1, 500) short := requestFor(1, 500)
@@ -307,8 +307,8 @@ func TestTwoAcceptsAtOnceLeaveOneChain(t *testing.T) {
// database holding settled transfers is a read fault, not a new chain. // database holding settled transfers is a read fault, not a new chain.
func TestARestartBeforeTheFirstBlockComesUp(t *testing.T) { func TestARestartBeforeTheFirstBlockComesUp(t *testing.T) {
db := memdb.New() db := memdb.New()
first := bootOn(t, db, testConfig()) first := bootOn(t, db, testLimits())
second := bootOn(t, db, testConfig()) second := bootOn(t, db, testLimits())
require.Equal(t, first.genesisBlock.ID(), second.genesisBlock.ID(), require.Equal(t, first.genesisBlock.ID(), second.genesisBlock.ID(),
"one chain, two nodes of it") "one chain, two nodes of it")
@@ -576,7 +576,7 @@ func TestABlockOfAnotherChainIsNotABlockOfThisOne(t *testing.T) {
// been spent — durably, with Initialize returning nil. // been spent — durably, with Initialize returning nil.
func TestAChainDoesNotRestartOverItself(t *testing.T) { func TestAChainDoesNotRestartOverItself(t *testing.T) {
db := memdb.New() db := memdb.New()
vm := bootOn(t, db, testConfig()) vm := bootOn(t, db, testLimits())
pend(vm, requestFor(1, 500)) pend(vm, requestFor(1, 500))
buildAndAccept(t, vm) buildAndAccept(t, vm)
@@ -584,7 +584,7 @@ func TestAChainDoesNotRestartOverItself(t *testing.T) {
// they say this database is not a new chain. // they say this database is not a new chain.
require.NoError(t, db.Delete([]byte("chain/tip"))) require.NoError(t, db.Delete([]byte("chain/tip")))
err := (&VM{}).Initialize(context.Background(), initFor(db, testConfig())) err := (&VM{}).Initialize(context.Background(), initFor(db, testLimits()))
require.ErrorContains(t, err, "refusing to restart the chain over it") require.ErrorContains(t, err, "refusing to restart the chain over it")
} }
+59 -45
View File
@@ -46,26 +46,25 @@ var (
// minimum in two places eventually enforces the smaller one. // minimum in two places eventually enforces the smaller one.
const minValidatorBond uint64 = 1_000_000 * 1e9 // 1M LUX const minValidatorBond uint64 = 1_000_000 * 1e9 // 1M LUX
// BridgeConfig contains VM configuration // Limits are the bridge's risk parameters, and they are declared by the
type BridgeConfig struct { // NETWORK in genesis rather than by the operator. They decide which transfers a
// block may carry (ledger.go admissible/admit), so two nodes holding different
// values disagree about whether a transfer is admissible — and the first one
// they disagree about is a transfer the builder keeps proposing and everyone
// else keeps refusing, which stops block production and does not resume. A cap
// each operator sets for themselves is not a cap.
//
// Nothing here is defaulted. A bridge whose cap is whatever the code happened
// to pick is a bridge nobody decided the risk of, and a zero per-transfer cap
// refuses every transfer silently at every block, which looks exactly like a
// bridge nobody is using.
type Limits struct {
// MinConfirmations is how deep a lock must be buried on its source chain // MinConfirmations is how deep a lock must be buried on its source chain
// before this chain will carry it. It is applied where the lock is read, // before this chain will carry it. It is applied where the lock is read,
// so a lock that is not yet deep enough is left for a later pass rather // so a lock that is not yet deep enough is left for a later pass rather
// than admitted and held. // than admitted and held.
MinConfirmations uint32 `json:"minConfirmations"` MinConfirmations uint32 `json:"minConfirmations"`
BridgeFee uint64 `json:"bridgeFee"` // Fee in LUX for bridge operations
// ExternalChains is the ONE declaration of which chains this bridge
// serves. It carries endpoints, gateway + custody addresses, and KMS paths
// for the relayer keys — never key material. A relayer node reads this and
// calls EnableBridgeRelease with a KMS-backed KeyProvider and a Warp-backed
// AttestationClient; every other node uses it to name the chains it will
// carry transfers between.
ExternalChains []ExternalChainConfig `json:"externalChains,omitempty"`
// The spend caps. Both are declared, never defaulted: a bridge whose cap
// is whatever the code happens to pick is a bridge nobody decided the risk
// of.
MaxBridgeAmount uint64 `json:"maxBridgeAmount"` // Maximum amount per transfer MaxBridgeAmount uint64 `json:"maxBridgeAmount"` // Maximum amount per transfer
DailyBridgeLimit uint64 `json:"dailyBridgeLimit"` // Maximum per destination per day DailyBridgeLimit uint64 `json:"dailyBridgeLimit"` // Maximum per destination per day
@@ -75,26 +74,36 @@ type BridgeConfig struct {
MaxSigners int `json:"maxSigners"` MaxSigners int `json:"maxSigners"`
} }
// validate refuses a configuration this chain cannot enforce. A bridge with no // validate refuses limits this chain cannot enforce.
// declared cap does not fail safe by accident: a zero per-transfer cap refuses func (l *Limits) validate() error {
// every transfer silently at every block, which looks exactly like a bridge
// nobody is using.
func (c *BridgeConfig) validate() error {
switch { switch {
case c.MinConfirmations == 0: case l.MinConfirmations == 0:
return errors.New("bridgevm: minConfirmations must be at least 1") return errors.New("bridgevm: minConfirmations must be at least 1")
case c.MaxBridgeAmount == 0: case l.MaxBridgeAmount == 0:
return errors.New("bridgevm: maxBridgeAmount must be declared") return errors.New("bridgevm: maxBridgeAmount must be declared")
case c.DailyBridgeLimit < c.MaxBridgeAmount: case l.DailyBridgeLimit < l.MaxBridgeAmount:
return fmt.Errorf("bridgevm: dailyBridgeLimit %d is below maxBridgeAmount %d", return fmt.Errorf("bridgevm: dailyBridgeLimit %d is below maxBridgeAmount %d",
c.DailyBridgeLimit, c.MaxBridgeAmount) l.DailyBridgeLimit, l.MaxBridgeAmount)
case c.RequireValidatorBond < minValidatorBond: case l.RequireValidatorBond < minValidatorBond:
return fmt.Errorf("bridgevm: requireValidatorBond %d is below the %d minimum", return fmt.Errorf("bridgevm: requireValidatorBond %d is below the %d minimum",
c.RequireValidatorBond, minValidatorBond) l.RequireValidatorBond, minValidatorBond)
} }
return nil return nil
} }
// BridgeConfig is the operator's half: how THIS node reaches the chains the
// network told it to bridge. Nothing here can change which transfers are
// valid, so two operators may hold different values without disagreeing.
type BridgeConfig struct {
// ExternalChains is the ONE declaration of which chains this bridge
// serves. It carries endpoints, gateway + custody addresses, and KMS paths
// for the relayer keys — never key material. A relayer node reads this and
// calls EnableBridgeRelease with a KMS-backed KeyProvider and a Warp-backed
// AttestationClient; every other node uses it to name the chains it will
// carry transfers between.
ExternalChains []ExternalChainConfig `json:"externalChains,omitempty"`
}
// SignerSet tracks the current MPC signer set (LP-333) // SignerSet tracks the current MPC signer set (LP-333)
// First MaxSigners validators opt-in without reshare. Reshare ONLY on slot // First MaxSigners validators opt-in without reshare. Reshare ONLY on slot
// replacement. // replacement.
@@ -216,6 +225,7 @@ const maxRequestsPerBlock = 100
type VM struct { type VM struct {
rt *runtime.Runtime rt *runtime.Runtime
config BridgeConfig config BridgeConfig
limits Limits
log log.Logger log log.Logger
// chainID is the chain this VM is running, hashed into every block id so a // chainID is the chain this VM is running, hashed into every block id so a
@@ -341,15 +351,23 @@ func (vm *VM) Initialize(
return fmt.Errorf("bridgevm: parse config: %w", err) return fmt.Errorf("bridgevm: parse config: %w", err)
} }
} }
if vm.config.MaxSigners == 0 {
vm.config.MaxSigners = 100 // LP-333: the set freezes here genesis := &Genesis{}
if len(vmInit.Genesis) > 0 {
if err := json.Unmarshal(vmInit.Genesis, genesis); err != nil {
return fmt.Errorf("bridgevm: parse genesis: %w", err)
}
} }
if err := vm.config.validate(); err != nil { vm.limits = genesis.Limits
if vm.limits.MaxSigners == 0 {
vm.limits.MaxSigners = 100 // LP-333: the set freezes here. A capacity, not a risk decision.
}
if err := vm.limits.validate(); err != nil {
return err return err
} }
vm.signerSet = &SignerSet{ vm.signerSet = &SignerSet{
Signers: make([]*SignerInfo, 0, vm.config.MaxSigners), Signers: make([]*SignerInfo, 0, vm.limits.MaxSigners),
Waitlist: make([]ids.NodeID, 0), Waitlist: make([]ids.NodeID, 0),
} }
@@ -364,13 +382,6 @@ func (vm *VM) Initialize(
// carries the chain list; a relayer reads vm.config.ExternalChains and calls // carries the chain list; a relayer reads vm.config.ExternalChains and calls
// EnableBridgeRelease. Non-relayer nodes never broadcast. // EnableBridgeRelease. Non-relayer nodes never broadcast.
genesis := &Genesis{}
if len(vmInit.Genesis) > 0 {
if err := json.Unmarshal(vmInit.Genesis, genesis); err != nil {
return fmt.Errorf("bridgevm: parse genesis: %w", err)
}
}
vm.chain = chain.New[*Block](vmInit.DB, nil) vm.chain = chain.New[*Block](vmInit.DB, nil)
genesisBlock := &Block{ genesisBlock := &Block{
@@ -461,7 +472,7 @@ func (vm *VM) BuildBlock(ctx context.Context) (vmchain.Block, error) {
if len(requests) == maxRequestsPerBlock { if len(requests) == maxRequestsPerBlock {
break break
} }
if err := state.admit(&vm.config, day, req); err != nil { if err := state.admit(&vm.limits, day, req); err != nil {
vm.log.Debug("bridgevm: transfer not carried", vm.log.Debug("bridgevm: transfer not carried",
log.Stringer("requestID", req.ID), log.String("reason", err.Error())) log.Stringer("requestID", req.ID), log.String("reason", err.Error()))
continue continue
@@ -655,6 +666,9 @@ func (vm *VM) WaitForEvent(ctx context.Context) (vmcore.Message, error) {
// Genesis represents the genesis state // Genesis represents the genesis state
type Genesis struct { type Genesis struct {
Timestamp int64 `json:"timestamp"` Timestamp int64 `json:"timestamp"`
// Limits are inline so a network declares its caps beside the rest of the
// chain's genesis, in one file, and every validator reads the same numbers.
Limits
} }
// ============================================================================= // =============================================================================
@@ -720,12 +734,12 @@ func (vm *VM) RegisterValidator(input *RegisterValidatorInput) (*RegisterValidat
if err != nil { if err != nil {
return nil, fmt.Errorf("bridgevm: bond amount %q is not a number", input.BondAmount) return nil, fmt.Errorf("bridgevm: bond amount %q is not a number", input.BondAmount)
} }
if bondAmount < vm.config.RequireValidatorBond { if bondAmount < vm.limits.RequireValidatorBond {
return nil, fmt.Errorf("bridgevm: bond %d is below the required %d", return nil, fmt.Errorf("bridgevm: bond %d is below the required %d",
bondAmount, vm.config.RequireValidatorBond) bondAmount, vm.limits.RequireValidatorBond)
} }
if !vm.signerSet.SetFrozen && len(vm.signerSet.Signers) < vm.config.MaxSigners { if !vm.signerSet.SetFrozen && len(vm.signerSet.Signers) < vm.limits.MaxSigners {
signerInfo := &SignerInfo{ signerInfo := &SignerInfo{
NodeID: nodeID, NodeID: nodeID,
PartyID: party.ID(nodeID.String()), PartyID: party.ID(nodeID.String()),
@@ -739,7 +753,7 @@ func (vm *VM) RegisterValidator(input *RegisterValidatorInput) (*RegisterValidat
vm.signerSet.Signers = append(vm.signerSet.Signers, signerInfo) vm.signerSet.Signers = append(vm.signerSet.Signers, signerInfo)
vm.signerSet.renumber() vm.signerSet.renumber()
if len(vm.signerSet.Signers) >= vm.config.MaxSigners { if len(vm.signerSet.Signers) >= vm.limits.MaxSigners {
vm.signerSet.SetFrozen = true vm.signerSet.SetFrozen = true
} }
@@ -760,7 +774,7 @@ func (vm *VM) RegisterValidator(input *RegisterValidatorInput) (*RegisterValidat
Threshold: vm.signerSet.ThresholdT, Threshold: vm.signerSet.ThresholdT,
CurrentEpoch: vm.signerSet.CurrentEpoch, CurrentEpoch: vm.signerSet.CurrentEpoch,
SetFrozen: vm.signerSet.SetFrozen, SetFrozen: vm.signerSet.SetFrozen,
RemainingSlots: vm.config.MaxSigners - len(vm.signerSet.Signers), RemainingSlots: vm.limits.MaxSigners - len(vm.signerSet.Signers),
Message: "registered as bridge signer", Message: "registered as bridge signer",
}, nil }, nil
} }
@@ -795,10 +809,10 @@ func (vm *VM) GetSignerSetInfo() *SignerSetInfo {
info := &SignerSetInfo{ info := &SignerSetInfo{
TotalSigners: len(vm.signerSet.Signers), TotalSigners: len(vm.signerSet.Signers),
Threshold: vm.signerSet.ThresholdT, Threshold: vm.signerSet.ThresholdT,
MaxSigners: vm.config.MaxSigners, MaxSigners: vm.limits.MaxSigners,
CurrentEpoch: vm.signerSet.CurrentEpoch, CurrentEpoch: vm.signerSet.CurrentEpoch,
SetFrozen: vm.signerSet.SetFrozen, SetFrozen: vm.signerSet.SetFrozen,
RemainingSlots: vm.config.MaxSigners - len(vm.signerSet.Signers), RemainingSlots: vm.limits.MaxSigners - len(vm.signerSet.Signers),
WaitlistSize: len(vm.signerSet.Waitlist), WaitlistSize: len(vm.signerSet.Waitlist),
Signers: vm.signerSet.Signers, Signers: vm.signerSet.Signers,
} }
@@ -1045,7 +1059,7 @@ func (vm *VM) SlashSigner(input *SlashSignerInput) (*SlashSignerResult, error) {
// A signer whose bond no longer meets what the chain requires is not a // A signer whose bond no longer meets what the chain requires is not a
// signer. The requirement is read from the same declaration registration // signer. The requirement is read from the same declaration registration
// applies, so the two cannot drift apart. // applies, so the two cannot drift apart.
if remainingBond < vm.config.RequireValidatorBond { if remainingBond < vm.limits.RequireValidatorBond {
vm.signerSet.Signers = append(vm.signerSet.Signers[:signerIndex], vm.signerSet.Signers[signerIndex+1:]...) vm.signerSet.Signers = append(vm.signerSet.Signers[:signerIndex], vm.signerSet.Signers[signerIndex+1:]...)
vm.signerSet.renumber() vm.signerSet.renumber()
vm.signerSet.CurrentEpoch++ vm.signerSet.CurrentEpoch++
+16 -16
View File
@@ -50,19 +50,19 @@ func TestInitializeRefusesWhatItCannotRunOn(t *testing.T) {
require.ErrorContains(t, (&VM{}).Initialize(ctx, vmcore.Init{}), "no runtime") require.ErrorContains(t, (&VM{}).Initialize(ctx, vmcore.Init{}), "no runtime")
noDB := initFor(memdb.New(), testConfig()) noDB := initFor(memdb.New(), testLimits())
noDB.DB = nil noDB.DB = nil
require.ErrorContains(t, (&VM{}).Initialize(ctx, noDB), "no database") require.ErrorContains(t, (&VM{}).Initialize(ctx, noDB), "no database")
noLog := initFor(memdb.New(), testConfig()) noLog := initFor(memdb.New(), testLimits())
noLog.Runtime = &runtime.Runtime{NetworkID: 96369} noLog.Runtime = &runtime.Runtime{NetworkID: 96369}
require.ErrorContains(t, (&VM{}).Initialize(ctx, noLog), "invalid logger") require.ErrorContains(t, (&VM{}).Initialize(ctx, noLog), "invalid logger")
badConfig := initFor(memdb.New(), testConfig()) badConfig := initFor(memdb.New(), testLimits())
badConfig.Config = []byte(`{"maxBridgeAmount": "not a number"}`) badConfig.Config = []byte(`{"externalChains": "not a list"}`)
require.ErrorContains(t, (&VM{}).Initialize(ctx, badConfig), "parse config") require.ErrorContains(t, (&VM{}).Initialize(ctx, badConfig), "parse config")
badGenesis := initFor(memdb.New(), testConfig()) badGenesis := initFor(memdb.New(), testLimits())
badGenesis.Genesis = []byte(`{`) badGenesis.Genesis = []byte(`{`)
require.ErrorContains(t, (&VM{}).Initialize(ctx, badGenesis), "parse genesis") require.ErrorContains(t, (&VM{}).Initialize(ctx, badGenesis), "parse genesis")
} }
@@ -73,30 +73,30 @@ func TestInitializeRefusesWhatItCannotRunOn(t *testing.T) {
func TestABridgeDeclaresItsCaps(t *testing.T) { func TestABridgeDeclaresItsCaps(t *testing.T) {
ctx := context.Background() ctx := context.Background()
for name, mutate := range map[string]func(*BridgeConfig){ for name, mutate := range map[string]func(*Limits){
"minConfirmations must be at least 1": func(c *BridgeConfig) { c.MinConfirmations = 0 }, "minConfirmations must be at least 1": func(c *Limits) { c.MinConfirmations = 0 },
"maxBridgeAmount must be declared": func(c *BridgeConfig) { c.MaxBridgeAmount = 0 }, "maxBridgeAmount must be declared": func(c *Limits) { c.MaxBridgeAmount = 0 },
"is below maxBridgeAmount": func(c *BridgeConfig) { c.DailyBridgeLimit = c.MaxBridgeAmount - 1 }, "is below maxBridgeAmount": func(c *Limits) { c.DailyBridgeLimit = c.MaxBridgeAmount - 1 },
"is below the": func(c *BridgeConfig) { c.RequireValidatorBond = minValidatorBond - 1 }, "is below the": func(c *Limits) { c.RequireValidatorBond = minValidatorBond - 1 },
} { } {
cfg := testConfig() cfg := testLimits()
mutate(&cfg) mutate(&cfg)
require.ErrorContains(t, (&VM{}).Initialize(ctx, initFor(memdb.New(), cfg)), name) require.ErrorContains(t, (&VM{}).Initialize(ctx, initFor(memdb.New(), cfg)), name)
} }
// An empty configuration declares nothing, so it runs nothing. // A genesis that declares no limits declares nothing, so it runs nothing.
empty := initFor(memdb.New(), testConfig()) empty := initFor(memdb.New(), testLimits())
empty.Config = nil empty.Genesis = nil
require.Error(t, (&VM{}).Initialize(ctx, empty)) require.Error(t, (&VM{}).Initialize(ctx, empty))
} }
// The signer set freezes at a size, and that size has a default because it is a // The signer set freezes at a size, and that size has a default because it is a
// capacity rather than a risk decision. // capacity rather than a risk decision.
func TestTheSignerSetSizeDefaults(t *testing.T) { func TestTheSignerSetSizeDefaults(t *testing.T) {
cfg := testConfig() cfg := testLimits()
cfg.MaxSigners = 0 cfg.MaxSigners = 0
vm := bootOn(t, memdb.New(), cfg) vm := bootOn(t, memdb.New(), cfg)
require.Equal(t, 100, vm.config.MaxSigners) require.Equal(t, 100, vm.limits.MaxSigners)
} }
// TestTheGenesisTimestampIsTheChainsFirstTime. // TestTheGenesisTimestampIsTheChainsFirstTime.
+2 -2
View File
@@ -132,7 +132,7 @@ func (w *watcher) pass() {
// chain requiring 100 permanently ineligible: the depth was never revisited and // chain requiring 100 permanently ineligible: the depth was never revisited and
// the cursor had already moved past the lock. // the cursor had already moved past the lock.
func (w *watcher) lag() uint64 { func (w *watcher) lag() uint64 {
if conf := uint64(w.vm.config.MinConfirmations); conf > minWatchLag { if conf := uint64(w.vm.limits.MinConfirmations); conf > minWatchLag {
return conf return conf
} }
return minWatchLag return minWatchLag
@@ -199,7 +199,7 @@ func (w *watcher) enqueue(l lock) {
// A lock this chain can never carry is not held: it would be proposed, // A lock this chain can never carry is not held: it would be proposed,
// refused, and proposed again for as long as the node runs. What can be // refused, and proposed again for as long as the node runs. What can be
// decided without the chain's state is decided here, once. // decided without the chain's state is decided here, once.
if err := admissible(&w.vm.config, req); err != nil { if err := admissible(&w.vm.limits, req); err != nil {
w.vm.log.Warn("bridgevm: lock not admitted", w.vm.log.Warn("bridgevm: lock not admitted",
log.Stringer("requestID", id), log.String("reason", err.Error())) log.Stringer("requestID", id), log.String("reason", err.Error()))
return return
+2 -2
View File
@@ -174,7 +174,7 @@ func TestWatchStaysBehindTheHeadAndBoundsAPass(t *testing.T) {
// ever carried. Reading as deep as the chain asks makes anything read eligible // ever carried. Reading as deep as the chain asks makes anything read eligible
// by construction, and there is no depth left to go stale. // by construction, and there is no depth left to go stale.
func TestWatchReadsAsDeepAsTheChainAsksFor(t *testing.T) { func TestWatchReadsAsDeepAsTheChainAsksFor(t *testing.T) {
cfg := testConfig() cfg := testLimits()
cfg.MinConfirmations = 500 cfg.MinConfirmations = 500
vm := bootOn(t, memdb.New(), cfg) vm := bootOn(t, memdb.New(), cfg)
src := &fakeSource{head: 1000} src := &fakeSource{head: 1000}
@@ -239,7 +239,7 @@ func TestAClientThatIsNotASourceIsSkipped(t *testing.T) {
// lock is read, is the difference between one warning and a proposer that // lock is read, is the difference between one warning and a proposer that
// never makes progress again. // never makes progress again.
func TestALockThatCanNeverBeCarriedIsNotHeld(t *testing.T) { func TestALockThatCanNeverBeCarriedIsNotHeld(t *testing.T) {
cfg := testConfig() cfg := testLimits()
cfg.MaxBridgeAmount = 10 cfg.MaxBridgeAmount = 10
vm := bootOn(t, memdb.New(), cfg) vm := bootOn(t, memdb.New(), cfg)
src := &fakeSource{head: 1000} src := &fakeSource{head: 1000}