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.
func TestTheDailyCapSurvivesARestart(t *testing.T) {
db := memdb.New()
cfg := testConfig()
cfg := testLimits()
cfg.MaxBridgeAmount = 600
cfg.DailyBridgeLimit = 1000
@@ -157,7 +157,7 @@ func TestTheDailyCapSurvivesARestart(t *testing.T) {
// TestTheCapIsCountedPerDestination keeps one busy route from closing another.
func TestTheCapIsCountedPerDestination(t *testing.T) {
cfg := testConfig()
cfg := testLimits()
cfg.MaxBridgeAmount = 100
cfg.DailyBridgeLimit = 100
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
// than off each node's clock.
func TestTheWindowReopensOnTheNextDay(t *testing.T) {
cfg := testConfig()
cfg := testLimits()
cfg.MaxBridgeAmount = 100
cfg.DailyBridgeLimit = 100
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
// one twice fails on the second: the first has already been recorded
// 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
}
}
+2 -2
View File
@@ -19,7 +19,7 @@ import (
// they should be carried in.
func vmWithPending(t *testing.T, n int) (*VM, []ids.ID) {
t.Helper()
cfg := testConfig()
cfg := testLimits()
cfg.DailyBridgeLimit = 1 << 40
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
// and did not resume.
func TestWhatIsBuiltIsWhatVerifies(t *testing.T) {
cfg := testConfig()
cfg := testLimits()
cfg.MaxBridgeAmount = 100
cfg.DailyBridgeLimit = 250
vm := bootOn(t, memdb.New(), cfg)
+6 -6
View File
@@ -24,13 +24,13 @@ import (
// starting somewhere else.
func TestAChainThatCannotReadItsTipDoesNotStart(t *testing.T) {
db := memdb.New()
vm := bootOn(t, db, testConfig())
vm := bootOn(t, db, testLimits())
pend(vm, requestFor(1, 100))
blk := buildAndAccept(t, vm)
// The tip is recorded; the block it names is gone.
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.
@@ -43,7 +43,7 @@ func TestAChainWithAnUnreadableTipDoesNotStart(t *testing.T) {
db := memdb.New()
require.NoError(t, db.Put([]byte("chain/tip"), make([]byte, ids.IDLen)))
// 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
@@ -212,7 +212,7 @@ func TestRPC_ReplaceSignerWithANamedReplacement(t *testing.T) {
func TestRPC_TheWaitlistIsNamed(t *testing.T) {
srv, vm := newRPCRig(t)
vm.config.MaxSigners = 1
vm.limits.MaxSigners = 1
first, waiting := ids.GenerateTestNodeID(), ids.GenerateTestNodeID()
require.NoError(t, registerSigner(vm, first))
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.
func TestRPC_GetInfoSaysWhenItCannotRead(t *testing.T) {
vm := bootOn(t, &unreadableDB{Database: memdb.New(), prefix: movedPrefix, err: errUnreadable},
testConfig())
testLimits())
srv, _ := serveVM(t, vm)
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) {
err := (&VM{}).Initialize(context.Background(),
initFor(unlistableDB{Database: memdb.New()}, testConfig()))
initFor(unlistableDB{Database: memdb.New()}, testLimits()))
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.
var testChainID = ids.ID{'b', '-', 'c', 'h', 'a', 'i', 'n', '-', 't', 'e', 's', 't'}
func testConfig() BridgeConfig {
return BridgeConfig{
// testLimits is what the NETWORK declares in genesis.
func testLimits() Limits {
return Limits{
MinConfirmations: 12,
MaxBridgeAmount: 1_000_000,
DailyBridgeLimit: 10_000_000,
RequireValidatorBond: minValidatorBond,
MaxSigners: 100,
}
}
// testChains is what the OPERATOR declares: how this node reaches them.
func testChains() BridgeConfig {
return BridgeConfig{
ExternalChains: []ExternalChainConfig{
{Name: "lux-testnet-c", ChainID: uint64(srcChain), Gateway: "0x0000000000000000000000000000000000000001"},
{Name: "zoo-testnet", ChainID: uint64(dstChain), Gateway: "0x0000000000000000000000000000000000000002"},
@@ -82,8 +89,12 @@ func (b *refusingBatch) Write() error {
}
// initFor is what the node hands Initialize.
func initFor(db database.Database, cfg BridgeConfig) vmcore.Init {
raw, err := json.Marshal(cfg)
func initFor(db database.Database, lim Limits) vmcore.Init {
raw, err := json.Marshal(testChains())
if err != nil {
panic(err)
}
gen, err := json.Marshal(Genesis{Timestamp: 1000000, Limits: lim})
if err != nil {
panic(err)
}
@@ -95,31 +106,31 @@ func initFor(db database.Database, cfg BridgeConfig) vmcore.Init {
NodeID: ids.GenerateTestNodeID(),
},
DB: db,
Genesis: []byte(`{"timestamp":1000000}`),
Genesis: gen,
Config: raw,
}
}
// bootOn brings a VM up over db exactly as the node does, with cfg as its
// 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()
vm := &VM{}
require.NoError(t, vm.Initialize(context.Background(), initFor(db, cfg)))
require.NoError(t, vm.Initialize(context.Background(), initFor(db, lim)))
return vm
}
// boot is a VM on a fresh database with the standard configuration.
func boot(t *testing.T) *VM {
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.
func bootRefusing(t *testing.T) (*VM, *refusingDB) {
t.Helper()
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
+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
// would have it proposed, refused, and proposed again for as long as the node
// runs.
func admissible(cfg *BridgeConfig, req *BridgeRequest) error {
func admissible(lim *Limits, req *BridgeRequest) error {
transfer, err := req.transfer()
if err != nil {
return err
@@ -169,9 +169,9 @@ func admissible(cfg *BridgeConfig, req *BridgeRequest) error {
if req.Amount == 0 {
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",
req.ID, req.Amount, cfg.MaxBridgeAmount)
req.ID, req.Amount, lim.MaxBridgeAmount)
}
return nil
}
@@ -183,8 +183,8 @@ func admissible(cfg *BridgeConfig, req *BridgeRequest) error {
// order. Two rules would disagree eventually, and the first transfer they
// disagreed about would be one the builder keeps proposing and every node
// keeps refusing — block production stops and does not resume.
func (s *spend) admit(cfg *BridgeConfig, day int64, req *BridgeRequest) error {
if err := admissible(cfg, req); err != nil {
func (s *spend) admit(lim *Limits, day int64, req *BridgeRequest) error {
if err := admissible(lim, req); err != nil {
return err
}
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
// 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)",
req.ID, req.Amount, req.DstChainID, cfg.DailyBridgeLimit, was)
req.ID, req.Amount, req.DstChainID, lim.DailyBridgeLimit, was)
}
s.record(day, req, was)
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
// could not obtain.
cfg := testConfig()
cfg := testLimits()
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())
require.ErrorIs(t, err, errUnreadable)
cfg := testConfig()
cfg := testLimits()
require.ErrorIs(t, s.admit(&cfg, 1, requestFor(1, 100)), errUnreadable)
}
@@ -124,7 +124,7 @@ func TestAChildsSpendIsItsOwn(t *testing.T) {
db := memdb.New()
parent := newSpend(db)
req := requestFor(1, 100)
cfg := testConfig()
cfg := testLimits()
require.NoError(t, parent.admit(&cfg, 1, req))
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.
func TestTheCapDoesNotWrap(t *testing.T) {
db := memdb.New()
cfg := testConfig()
cfg := testLimits()
cfg.MaxBridgeAmount = ^uint64(0)
cfg.DailyBridgeLimit = 1000
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 {
return fmt.Errorf("bridgevm: source confirmation check: %w", err)
}
if conf < r.vm.config.MinConfirmations {
return fmt.Errorf("%w: %d < %d", errInsufficientConfirmations, conf, r.vm.config.MinConfirmations)
if conf < r.vm.limits.MinConfirmations {
return fmt.Errorf("%w: %d < %d", errInsufficientConfirmations, conf, r.vm.limits.MinConfirmations)
}
destTx, err := r.vm.releaseTransfer(ctx, transfer)
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) {
t.Helper()
vm, dst, ac := releaseRig(t, nil)
vm.config.MinConfirmations = 12
vm.limits.MinConfirmations = 12
src := &sourceClient{recordingClient: &recordingClient{}, depth: depth}
vm.evmByChainID[1] = src
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),
ChainName: cfg.Name,
BridgeContract: cfg.Gateway,
Confirmations: int(s.vm.config.MinConfirmations),
Confirmations: int(s.vm.limits.MinConfirmations),
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, vm.config.ExternalChains[i].Name, c.ChainName)
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.
func signerVM(t *testing.T, maxSigners int) *VM {
t.Helper()
cfg := testConfig()
cfg := testLimits()
cfg.MaxSigners = maxSigners
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.
func TestATransferSettledBeforeARestartCannotBeSettledAgain(t *testing.T) {
db := memdb.New()
vm := bootOn(t, db, testConfig())
vm := bootOn(t, db, testLimits())
pend(vm, requestFor(1, 500))
first := buildAndAccept(t, vm)
restarted := bootOn(t, db, testConfig())
restarted := bootOn(t, db, testLimits())
again := blockOn(t, restarted, first, now(), requestFor(1, 500))
require.ErrorIs(t, again.Verify(context.Background()), errReplay)
}
@@ -85,7 +85,7 @@ func TestATransferInFlightCannotAlsoRideItsChild(t *testing.T) {
// TestTheDailyCapHoldsAcrossBlocksInFlight. Checked against the tip alone, a
// run of blocks that are all in the air each get the whole day's allowance.
func TestTheDailyCapHoldsAcrossBlocksInFlight(t *testing.T) {
cfg := testConfig()
cfg := testLimits()
cfg.MaxBridgeAmount = 100
cfg.DailyBridgeLimit = 150
vm := bootOn(t, memdb.New(), cfg)
@@ -140,7 +140,7 @@ func TestARequestMustBeTheDigestOfWhatItCarries(t *testing.T) {
func TestARequestMustBeWellFormed(t *testing.T) {
vm := boot(t)
cfg := &vm.config
cfg := &vm.limits
// A recipient that is not an address cannot be released to.
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.
func TestARestartBeforeTheFirstBlockComesUp(t *testing.T) {
db := memdb.New()
first := bootOn(t, db, testConfig())
second := bootOn(t, db, testConfig())
first := bootOn(t, db, testLimits())
second := bootOn(t, db, testLimits())
require.Equal(t, first.genesisBlock.ID(), second.genesisBlock.ID(),
"one chain, two nodes of it")
@@ -576,7 +576,7 @@ func TestABlockOfAnotherChainIsNotABlockOfThisOne(t *testing.T) {
// been spent — durably, with Initialize returning nil.
func TestAChainDoesNotRestartOverItself(t *testing.T) {
db := memdb.New()
vm := bootOn(t, db, testConfig())
vm := bootOn(t, db, testLimits())
pend(vm, requestFor(1, 500))
buildAndAccept(t, vm)
@@ -584,7 +584,7 @@ func TestAChainDoesNotRestartOverItself(t *testing.T) {
// they say this database is not a new chain.
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")
}
+59 -45
View File
@@ -46,26 +46,25 @@ var (
// minimum in two places eventually enforces the smaller one.
const minValidatorBond uint64 = 1_000_000 * 1e9 // 1M LUX
// BridgeConfig contains VM configuration
type BridgeConfig struct {
// Limits are the bridge's risk parameters, and they are declared by the
// 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
// 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
// than admitted and held.
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
DailyBridgeLimit uint64 `json:"dailyBridgeLimit"` // Maximum per destination per day
@@ -75,26 +74,36 @@ type BridgeConfig struct {
MaxSigners int `json:"maxSigners"`
}
// validate refuses a configuration this chain cannot enforce. A bridge with no
// declared cap does not fail safe by accident: a zero per-transfer cap refuses
// every transfer silently at every block, which looks exactly like a bridge
// nobody is using.
func (c *BridgeConfig) validate() error {
// validate refuses limits this chain cannot enforce.
func (l *Limits) validate() error {
switch {
case c.MinConfirmations == 0:
case l.MinConfirmations == 0:
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")
case c.DailyBridgeLimit < c.MaxBridgeAmount:
case l.DailyBridgeLimit < l.MaxBridgeAmount:
return fmt.Errorf("bridgevm: dailyBridgeLimit %d is below maxBridgeAmount %d",
c.DailyBridgeLimit, c.MaxBridgeAmount)
case c.RequireValidatorBond < minValidatorBond:
l.DailyBridgeLimit, l.MaxBridgeAmount)
case l.RequireValidatorBond < minValidatorBond:
return fmt.Errorf("bridgevm: requireValidatorBond %d is below the %d minimum",
c.RequireValidatorBond, minValidatorBond)
l.RequireValidatorBond, minValidatorBond)
}
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)
// First MaxSigners validators opt-in without reshare. Reshare ONLY on slot
// replacement.
@@ -216,6 +225,7 @@ const maxRequestsPerBlock = 100
type VM struct {
rt *runtime.Runtime
config BridgeConfig
limits Limits
log log.Logger
// 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)
}
}
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
}
vm.signerSet = &SignerSet{
Signers: make([]*SignerInfo, 0, vm.config.MaxSigners),
Signers: make([]*SignerInfo, 0, vm.limits.MaxSigners),
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
// 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)
genesisBlock := &Block{
@@ -461,7 +472,7 @@ func (vm *VM) BuildBlock(ctx context.Context) (vmchain.Block, error) {
if len(requests) == maxRequestsPerBlock {
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",
log.Stringer("requestID", req.ID), log.String("reason", err.Error()))
continue
@@ -655,6 +666,9 @@ func (vm *VM) WaitForEvent(ctx context.Context) (vmcore.Message, error) {
// Genesis represents the genesis state
type Genesis struct {
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 {
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",
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{
NodeID: nodeID,
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.renumber()
if len(vm.signerSet.Signers) >= vm.config.MaxSigners {
if len(vm.signerSet.Signers) >= vm.limits.MaxSigners {
vm.signerSet.SetFrozen = true
}
@@ -760,7 +774,7 @@ func (vm *VM) RegisterValidator(input *RegisterValidatorInput) (*RegisterValidat
Threshold: vm.signerSet.ThresholdT,
CurrentEpoch: vm.signerSet.CurrentEpoch,
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",
}, nil
}
@@ -795,10 +809,10 @@ func (vm *VM) GetSignerSetInfo() *SignerSetInfo {
info := &SignerSetInfo{
TotalSigners: len(vm.signerSet.Signers),
Threshold: vm.signerSet.ThresholdT,
MaxSigners: vm.config.MaxSigners,
MaxSigners: vm.limits.MaxSigners,
CurrentEpoch: vm.signerSet.CurrentEpoch,
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),
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
// signer. The requirement is read from the same declaration registration
// 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.renumber()
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")
noDB := initFor(memdb.New(), testConfig())
noDB := initFor(memdb.New(), testLimits())
noDB.DB = nil
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}
require.ErrorContains(t, (&VM{}).Initialize(ctx, noLog), "invalid logger")
badConfig := initFor(memdb.New(), testConfig())
badConfig.Config = []byte(`{"maxBridgeAmount": "not a number"}`)
badConfig := initFor(memdb.New(), testLimits())
badConfig.Config = []byte(`{"externalChains": "not a list"}`)
require.ErrorContains(t, (&VM{}).Initialize(ctx, badConfig), "parse config")
badGenesis := initFor(memdb.New(), testConfig())
badGenesis := initFor(memdb.New(), testLimits())
badGenesis.Genesis = []byte(`{`)
require.ErrorContains(t, (&VM{}).Initialize(ctx, badGenesis), "parse genesis")
}
@@ -73,30 +73,30 @@ func TestInitializeRefusesWhatItCannotRunOn(t *testing.T) {
func TestABridgeDeclaresItsCaps(t *testing.T) {
ctx := context.Background()
for name, mutate := range map[string]func(*BridgeConfig){
"minConfirmations must be at least 1": func(c *BridgeConfig) { c.MinConfirmations = 0 },
"maxBridgeAmount must be declared": func(c *BridgeConfig) { c.MaxBridgeAmount = 0 },
"is below maxBridgeAmount": func(c *BridgeConfig) { c.DailyBridgeLimit = c.MaxBridgeAmount - 1 },
"is below the": func(c *BridgeConfig) { c.RequireValidatorBond = minValidatorBond - 1 },
for name, mutate := range map[string]func(*Limits){
"minConfirmations must be at least 1": func(c *Limits) { c.MinConfirmations = 0 },
"maxBridgeAmount must be declared": func(c *Limits) { c.MaxBridgeAmount = 0 },
"is below maxBridgeAmount": func(c *Limits) { c.DailyBridgeLimit = c.MaxBridgeAmount - 1 },
"is below the": func(c *Limits) { c.RequireValidatorBond = minValidatorBond - 1 },
} {
cfg := testConfig()
cfg := testLimits()
mutate(&cfg)
require.ErrorContains(t, (&VM{}).Initialize(ctx, initFor(memdb.New(), cfg)), name)
}
// An empty configuration declares nothing, so it runs nothing.
empty := initFor(memdb.New(), testConfig())
empty.Config = nil
// A genesis that declares no limits declares nothing, so it runs nothing.
empty := initFor(memdb.New(), testLimits())
empty.Genesis = nil
require.Error(t, (&VM{}).Initialize(ctx, empty))
}
// The signer set freezes at a size, and that size has a default because it is a
// capacity rather than a risk decision.
func TestTheSignerSetSizeDefaults(t *testing.T) {
cfg := testConfig()
cfg := testLimits()
cfg.MaxSigners = 0
vm := bootOn(t, memdb.New(), cfg)
require.Equal(t, 100, vm.config.MaxSigners)
require.Equal(t, 100, vm.limits.MaxSigners)
}
// TestTheGenesisTimestampIsTheChainsFirstTime.
+2 -2
View File
@@ -132,7 +132,7 @@ func (w *watcher) pass() {
// chain requiring 100 permanently ineligible: the depth was never revisited and
// the cursor had already moved past the lock.
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 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,
// refused, and proposed again for as long as the node runs. What can be
// 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",
log.Stringer("requestID", id), log.String("reason", err.Error()))
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
// by construction, and there is no depth left to go stale.
func TestWatchReadsAsDeepAsTheChainAsksFor(t *testing.T) {
cfg := testConfig()
cfg := testLimits()
cfg.MinConfirmations = 500
vm := bootOn(t, memdb.New(), cfg)
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
// never makes progress again.
func TestALockThatCanNeverBeCarriedIsNotHeld(t *testing.T) {
cfg := testConfig()
cfg := testLimits()
cfg.MaxBridgeAmount = 10
vm := bootOn(t, memdb.New(), cfg)
src := &fakeSource{head: 1000}