mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
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:
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user