Files
chains/bridgevm/harness_test.go
T
zeekay a0e796b1fe 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.
2026-09-07 00:06:37 -07:00

230 lines
6.5 KiB
Go

// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package bridgevm
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
"github.com/luxfi/chains/internal/bridgeattest"
"github.com/luxfi/database"
"github.com/luxfi/database/memdb"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/runtime"
"github.com/luxfi/threshold/pkg/math/curve"
"github.com/luxfi/threshold/pkg/party"
"github.com/luxfi/threshold/protocols/cmp/config"
vmcore "github.com/luxfi/vm"
)
// The harness builds a VM the way the node does — through Initialize — and
// transfers the way a source chain does — id equal to the digest of the
// contents. A rig that hands each node a different chain id, or names a
// request something other than its transfer, cannot see the defects those two
// rules exist to stop.
const (
srcChain uint32 = 96368 // Lux testnet C
dstChain uint32 = 200201 // Zoo
)
// testChainID is ONE value, shared by every VM a test builds, because a
// running network is one chain. A rig that generated a fresh chain id per node
// 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'}
// 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"},
},
}
}
// refusingDB is a real database whose flush to disk can be made to fail. That
// is how a block fails in the field: the writes are complete and staged, and
// making them durable is what does not happen.
type refusingDB struct {
database.Database
refuse bool
}
func (d *refusingDB) NewBatch() database.Batch {
return &refusingBatch{Batch: d.Database.NewBatch(), db: d}
}
type refusingBatch struct {
database.Batch
db *refusingDB
}
var errRefused = errors.New("flush refused")
func (b *refusingBatch) Write() error {
if b.db.refuse {
return errRefused
}
return b.Batch.Write()
}
// initFor is what the node hands Initialize.
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)
}
return vmcore.Init{
Runtime: &runtime.Runtime{
Log: log.NewNoOpLogger(),
NetworkID: 96369,
ChainID: testChainID,
NodeID: ids.GenerateTestNodeID(),
},
DB: db,
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, lim Limits) *VM {
t.Helper()
vm := &VM{}
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(), 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, testLimits()), db
}
// transferFor is one locked transfer. The request's id is the digest of its
// contents, which is what a source chain's gateway emits and what the chain
// requires.
func transferFor(nonce, amount uint64) bridgeattest.BridgeTransfer {
bt := bridgeattest.BridgeTransfer{
SrcChainID: srcChain,
DstChainID: dstChain,
Amount: amount,
Nonce: nonce,
}
bt.Asset[31] = 1
bt.Recipient[19] = 9
return bt
}
// requestFor is the request a watcher builds from that transfer.
func requestFor(nonce, amount uint64) *BridgeRequest {
bt := transferFor(nonce, amount)
req := &BridgeRequest{
ID: ids.ID(bt.Digest()),
SrcChainID: bt.SrcChainID,
DstChainID: bt.DstChainID,
Nonce: bt.Nonce,
Asset: ids.ID(bt.Asset),
Amount: bt.Amount,
Recipient: append([]byte(nil), bt.Recipient[:]...),
}
req.SourceTxID[0] = byte(nonce)
return req
}
// pend puts transfers in the set waiting for a block, as the watcher would.
func pend(vm *VM, reqs ...*BridgeRequest) {
vm.mu.Lock()
defer vm.mu.Unlock()
for _, req := range reqs {
vm.pendingBridges[req.ID] = req
}
}
// buildAndAccept runs one block through the whole path a block takes: built,
// verified, accepted. Calling the store directly instead would skip the very
// checks Verify and Accept exist to make.
func buildAndAccept(t *testing.T, vm *VM) *Block {
t.Helper()
blk := build(t, vm)
require.NoError(t, blk.Verify(context.Background()))
require.NoError(t, blk.Accept(context.Background()))
return blk
}
func build(t *testing.T, vm *VM) *Block {
t.Helper()
built, err := vm.BuildBlock(context.Background())
require.NoError(t, err)
return built.(*Block)
}
// watcherOn is a watcher wired to one source chain, without the goroutine.
func watcherOn(vm *VM, src ChainClient) *watcher {
vm.mu.Lock()
vm.evmByChainID[srcChain] = src
vm.mu.Unlock()
return &watcher{vm: vm, cursor: make(map[uint32]uint64), every: watchInterval, quit: make(chan struct{})}
}
// moved is what has moved to a destination on the day at falls in, read from
// committed state.
func moved(t *testing.T, vm *VM, at int64, dst uint32) uint64 {
t.Helper()
v, err := vm.movedToday(at, dst)
require.NoError(t, err)
return v
}
// serveVM puts this VM's JSON-RPC handler behind an httptest server.
func serveVM(t *testing.T, vm *VM) (*httptest.Server, *VM) {
t.Helper()
handlers, err := vm.CreateRPCHandlers()
require.NoError(t, err)
mux := http.NewServeMux()
for path, h := range handlers {
mux.Handle(path, h)
}
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv, vm
}
// emptyGroupConfig is a CMP config with no public shares: the shape a chain
// holds before M-Chain's keygen has produced anything.
func emptyGroupConfig() *config.Config {
return &config.Config{Group: curve.Secp256k1{}, Public: map[party.ID]*config.Public{}}
}