bridgevm: B-Chain boots, and what it settles it remembers

The VM never built its chain store, so Initialize dereferenced a nil
store and B-Chain could not start at all.

What a block settles — the transfers, and the day's running total per
destination — is now written in the same commit as the block. Both lived
in maps on the heap, so a restart forgot every transfer the chain had
released and handed back the whole day's allowance: a restart was a free
reset of the cap that bounds what this bridge can move. The counter is
cut from the block's own timestamp, so every node closes the window on
the same block, and it only ever grew before, which stopped the chain for
good once total volume passed the limit.

A request's id is now the digest of the transfer it carries, and Verify
refuses one where it is not. The id was a label the proposer chose while
the replay guard, the daily counter and the release worker all keyed off
it, so one deposit could ride in under any number of names.

Verify now resolves the parent, refuses one beneath the tip, requires the
height and the timestamp to follow it, and registers what it accepted so
a follower can verify the second block of a run. It read none of that
before. Blocks are bounded and canonically encoded, the chain id is
hashed into every block id, and assembly admits exactly what Verify
admits, so an uncarryable transfer no longer wedges the proposer.

The signer set's threshold is ⌈2n/3⌉ in integers rather than a float that
asked for unanimity at three signers and for two of one at one, the bond
it declares is enforced where a signer registers, and a replacement takes
the slot it replaces.

Deleted: the block and request MPC signature carriage, which nothing
produced and whose deserializer could not decode a canonical secp256k1
signature, so it could only ever reject; the chain-name list beside the
routing table; the confirmation depth and observation time that each node
read for itself and then wrote to the wire; a worker pool, a party id and
an engine channel nobody read.
This commit is contained in:
2026-08-28 11:42:07 -07:00
parent 2b45e26ccc
commit a8dbf849b4
18 changed files with 2591 additions and 2295 deletions
-371
View File
@@ -1,371 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package bridgevm
import (
"errors"
"fmt"
"github.com/luxfi/accel"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/threshold/pkg/party"
"github.com/luxfi/threshold/protocols/cmp/config"
)
// ErrInvalidBridgeSignature is returned when CMP (CGGMP21) threshold
// signature verification fails on a bridge request or block. Custody
// signing itself runs on M-Chain (mpcvm) via dealerless FROST/CGGMP21;
// B-Chain only VERIFIES the resulting threshold signatures here.
var ErrInvalidBridgeSignature = errors.New("invalid bridge signature")
// batchVerifyBlockSignatures verifies all MPC block signatures using GPU-accelerated
// ECDSA batch verification when available. Falls back to sequential verification.
// Returns the count of valid signatures.
func batchVerifyBlockSignatures(
blockHash ids.ID,
signatures map[ids.NodeID][]byte,
mpcCfg *config.Config,
logger log.Logger,
) int {
if len(signatures) == 0 || mpcCfg == nil {
return 0
}
// Collect entries that have a known public key
var entries []sigEntry
for nodeID, sigBytes := range signatures {
pid := party.ID(nodeID.String())
if _, exists := mpcCfg.Public[pid]; exists {
entries = append(entries, sigEntry{nodeID, sigBytes, pid})
}
}
if len(entries) == 0 {
return 0
}
// GPU batch path
if accel.Available() && len(entries) > 1 {
count, err := batchVerifyECDSABlockGPU(blockHash, entries, mpcCfg, logger)
if err == nil {
return count
}
logger.Debug("GPU ECDSA batch verify failed, falling back to CPU",
log.Reflect("error", err),
)
}
// CPU fallback: sequential
validCount := 0
for _, e := range entries {
if verifyBlockSig(mpcCfg, blockHash, e) {
validCount++
}
}
return validCount
}
// verifyBlockSig is the ONE acceptance predicate for a block MPC signature: is
// sigBytes a valid signature by this party's key over the block hash? Both the
// CPU loop and the GPU batch answer this same question — the accelerator only
// batches it, it never redefines it.
func verifyBlockSig(mpcCfg *config.Config, blockHash ids.ID, e sigEntry) bool {
sig, err := deserializeSignature(mpcCfg.Group, e.sigBytes)
if err != nil {
return false
}
return sig.Verify(mpcCfg.Public[e.partyID].ECDSA, blockHash[:])
}
// sigEntry holds a signature entry for batch verification.
type sigEntry struct {
nodeID ids.NodeID
sigBytes []byte
partyID party.ID
}
// Tensor row widths for the block-signature batch.
const (
batchHashSize = 32
batchSigSize = 64
batchPKSize = 33
)
// packBlockSigBatch lays out the (message, signature, public key) rows the ECDSA
// batch kernel consumes, one row per entry.
//
// The message is the block hash ITSELF, byte for byte what verifyBlockSig hands
// to sig.Verify. Hashing it again here would have the accelerator check a
// different message than the CPU, so one signature set would be valid on a GPU
// node and invalid on a node without one — an accept/reject split decided by host
// hardware.
//
// Every row of the result vector must carry real inputs. Zero-filling a row whose
// signature or public key could not be prepared, and then trusting the kernel's
// verdict for that row, counts a signature the CPU path rejects outright. Anything
// this layout cannot represent exactly is an error, which sends the whole batch to
// the CPU — the one acceptance authority.
func packBlockSigBatch(blockHash ids.ID, entries []sigEntry, mpcCfg *config.Config) (messages, sigs, pubkeys []byte, err error) {
n := len(entries)
messages = make([]byte, n*batchHashSize)
sigs = make([]byte, n*batchSigSize)
pubkeys = make([]byte, n*batchPKSize)
for i, e := range entries {
copy(messages[i*batchHashSize:], blockHash[:])
if len(e.sigBytes) < batchSigSize {
return nil, nil, nil, fmt.Errorf("signature for %s is %d bytes, want at least %d", e.partyID, len(e.sigBytes), batchSigSize)
}
copy(sigs[i*batchSigSize:], e.sigBytes[:batchSigSize])
pkBytes, mErr := mpcCfg.Public[e.partyID].ECDSA.MarshalBinary()
if mErr != nil {
return nil, nil, nil, fmt.Errorf("public key for %s: %w", e.partyID, mErr)
}
if len(pkBytes) < batchPKSize {
return nil, nil, nil, fmt.Errorf("public key for %s is %d bytes, want %d", e.partyID, len(pkBytes), batchPKSize)
}
copy(pubkeys[i*batchPKSize:], pkBytes[:batchPKSize])
}
return messages, sigs, pubkeys, nil
}
// batchVerifyECDSABlockGPU runs GPU-accelerated ECDSA batch verification for block signatures.
func batchVerifyECDSABlockGPU(
blockHash ids.ID,
entries []sigEntry,
mpcCfg *config.Config,
logger log.Logger,
) (int, error) {
session, err := accel.DefaultSession()
if err != nil {
return 0, err
}
n := len(entries)
hashSize, sigSize, pkSize := batchHashSize, batchSigSize, batchPKSize
messages, sigs, pubkeys, err := packBlockSigBatch(blockHash, entries, mpcCfg)
if err != nil {
return 0, err
}
msgTensor, err := accel.NewTensorWithData[byte](session, []int{n, hashSize}, messages)
if err != nil {
return 0, err
}
defer msgTensor.Close()
sigTensor, err := accel.NewTensorWithData[byte](session, []int{n, sigSize}, sigs)
if err != nil {
return 0, err
}
defer sigTensor.Close()
pkTensor, err := accel.NewTensorWithData[byte](session, []int{n, pkSize}, pubkeys)
if err != nil {
return 0, err
}
defer pkTensor.Close()
resultTensor, err := accel.NewTensor[byte](session, []int{n})
if err != nil {
return 0, err
}
defer resultTensor.Close()
crypto := session.Crypto()
if err := crypto.ECDSAVerifyBatch(
msgTensor.Untyped(),
sigTensor.Untyped(),
pkTensor.Untyped(),
resultTensor.Untyped(),
); err != nil {
return 0, err
}
if err := session.Sync(); err != nil {
return 0, err
}
resultBytes, err := resultTensor.ToSlice()
if err != nil {
return 0, err
}
validCount := 0
for _, r := range resultBytes {
if r == 1 {
validCount++
}
}
logger.Debug("GPU ECDSA batch block sig verify",
log.Int("total", n),
log.Int("valid", validCount),
)
return validCount, nil
}
// batchVerifyRequestSignaturesGPU verifies MPC signatures on multiple bridge requests
// using GPU acceleration when available. Returns per-request errors.
func batchVerifyRequestSignaturesGPU(
requests []*BridgeRequest,
mpcCfg *config.Config,
logger log.Logger,
) []error {
results := make([]error, len(requests))
if mpcCfg == nil {
for i, req := range requests {
if len(req.MPCSignatures) > 0 {
results[i] = ErrInvalidBridgeSignature
}
}
return results
}
// Collect requests that have signatures
type reqEntry struct {
index int
msgHash []byte
sigData []byte
}
var entries []reqEntry
for i, req := range requests {
if len(req.MPCSignatures) == 0 {
continue
}
entries = append(entries, reqEntry{
index: i,
msgHash: computeRequestHash(req),
sigData: req.MPCSignatures[0],
})
}
if len(entries) <= 1 || !accel.Available() {
// Sequential fallback
groupPK := mpcCfg.PublicPoint()
for _, e := range entries {
sig, err := deserializeSignature(mpcCfg.Group, e.sigData)
if err != nil {
results[e.index] = fmt.Errorf("deserialize sig: %w", err)
continue
}
if !sig.Verify(groupPK, e.msgHash) {
results[e.index] = ErrInvalidBridgeSignature
}
}
return results
}
// GPU batch path
session, err := accel.DefaultSession()
if err != nil {
goto cpuFallback
}
{
groupPK := mpcCfg.PublicPoint()
if groupPK == nil {
goto cpuFallback
}
groupPKBytes, err := groupPK.MarshalBinary()
if err != nil {
goto cpuFallback
}
n := len(entries)
const hashSize = 32
const sigSize = 64
pkSize := len(groupPKBytes)
if pkSize < 33 {
pkSize = 33
}
messages := make([]byte, n*hashSize)
sigBytes := make([]byte, n*sigSize)
pubkeys := make([]byte, n*pkSize)
for j, e := range entries {
if len(e.msgHash) >= hashSize {
copy(messages[j*hashSize:], e.msgHash[:hashSize])
}
if len(e.sigData) >= sigSize {
copy(sigBytes[j*sigSize:], e.sigData[:sigSize])
}
copy(pubkeys[j*pkSize:], groupPKBytes)
}
msgTensor, err := accel.NewTensorWithData[byte](session, []int{n, hashSize}, messages)
if err != nil {
goto cpuFallback
}
defer msgTensor.Close()
sigTensor, err := accel.NewTensorWithData[byte](session, []int{n, sigSize}, sigBytes)
if err != nil {
goto cpuFallback
}
defer sigTensor.Close()
pkTensor, err := accel.NewTensorWithData[byte](session, []int{n, pkSize}, pubkeys)
if err != nil {
goto cpuFallback
}
defer pkTensor.Close()
resultTensor, err := accel.NewTensor[byte](session, []int{n})
if err != nil {
goto cpuFallback
}
defer resultTensor.Close()
crypto := session.Crypto()
if err := crypto.ECDSAVerifyBatch(
msgTensor.Untyped(),
sigTensor.Untyped(),
pkTensor.Untyped(),
resultTensor.Untyped(),
); err != nil {
goto cpuFallback
}
if err := session.Sync(); err != nil {
goto cpuFallback
}
resultData, err := resultTensor.ToSlice()
if err != nil {
goto cpuFallback
}
for j, e := range entries {
if resultData[j] != 1 {
results[e.index] = ErrInvalidBridgeSignature
}
}
logger.Debug("GPU batch request sig verify",
log.Int("total", n),
)
return results
}
cpuFallback:
groupPK := mpcCfg.PublicPoint()
for _, e := range entries {
sig, err := deserializeSignature(mpcCfg.Group, e.sigData)
if err != nil {
results[e.index] = fmt.Errorf("deserialize sig: %w", err)
continue
}
if !sig.Verify(groupPK, e.msgHash) {
results[e.index] = ErrInvalidBridgeSignature
}
}
return results
}
+130 -95
View File
@@ -5,84 +5,15 @@ package bridgevm
import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/luxfi/chains/chain"
"github.com/luxfi/database"
"github.com/luxfi/database/memdb"
"github.com/luxfi/ids"
"github.com/luxfi/log"
)
// 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()
}
// vmWithTransfer builds a bridge VM holding one ready transfer, over a
// database whose flush can be refused, with a release worker whose queue is
// only a channel so the test can see what was handed to it.
func vmWithTransfer(t *testing.T) (*VM, *refusingDB, *BridgeRequest) {
t.Helper()
db := &refusingDB{Database: memdb.New()}
vm := &VM{
log: log.NewNoOpLogger(),
config: BridgeConfig{MinConfirmations: 1, MaxBridgeAmount: 1 << 40, DailyBridgeLimit: 1 << 40},
pendingBridges: make(map[ids.ID]*BridgeRequest, 1),
bridgeRegistry: &BridgeRegistry{
CompletedBridges: make(map[ids.ID]*CompletedBridge),
DailyVolume: make(map[string]uint64),
},
}
vm.chain = chain.New[*Block](db, nil)
vm.releaser = &releaser{vm: vm, queue: make(chan *BridgeRequest, 8), quit: make(chan struct{})}
genesis := &Block{BridgeRequests: []*BridgeRequest{}, vm: vm}
genesis.ID_ = genesis.computeID()
vm.genesisBlock = genesis
_, _, err := vm.chain.Open(genesis, vm.parseBlock)
require.NoError(t, err)
req := &BridgeRequest{
ID: ids.GenerateTestID(),
SourceChain: "C",
DestChain: "eth",
Amount: 7,
Recipient: make([]byte, 20),
Confirmations: 12,
Status: "pending",
CreatedAt: time.Unix(0, 0),
}
vm.pendingBridges[req.ID] = req
return vm, db, req
}
// TestABlockThatCannotCommitReleasesNothing is the defect this chain had.
// Accept credited the daily volume, completed the request, advanced
// lastAcceptedID and handed the transfer to the release worker — which
@@ -91,11 +22,13 @@ func vmWithTransfer(t *testing.T) (*VM, *refusingDB, *BridgeRequest) {
// transfer no block records. lastAcceptedID was never written at all, so the
// tip reset to genesis on restart and the daily limit reset with it.
func TestABlockThatCannotCommitReleasesNothing(t *testing.T) {
vm, db, req := vmWithTransfer(t)
vm, db := bootRefusing(t)
vm.releaser = &releaser{vm: vm, queue: make(chan *BridgeRequest, 8), quit: make(chan struct{})}
req := requestFor(1, 7)
pend(vm, req)
built, err := vm.BuildBlock(context.Background())
require.NoError(t, err)
blk := built.(*Block)
blk := build(t, vm)
require.NoError(t, blk.Verify(context.Background()))
tipBefore, heightBefore := vm.chain.Tip()
@@ -106,13 +39,12 @@ func TestABlockThatCannotCommitReleasesNothing(t *testing.T) {
require.Empty(t, vm.releaser.queue,
"an external release must not start for a block that did not commit")
// Nor did any of the reversible part happen.
vm.bridgeRegistry.mu.RLock()
completed := len(vm.bridgeRegistry.CompletedBridges)
volume := vm.bridgeRegistry.DailyVolume["eth"]
vm.bridgeRegistry.mu.RUnlock()
require.Zero(t, completed, "no transfer is completed by a block that did not commit")
require.Zero(t, volume, "and none of its amount counts against the daily limit")
// Nor did any of the durable part happen.
require.Zero(t, vm.movedToday(blk.BlockTimestamp, dstChain),
"none of the amount counts against the daily cap")
settled, err := newSpend(vm.chain.Base()).isSettled(req.ID)
require.NoError(t, err)
require.False(t, settled, "no transfer is settled by a block that did not commit")
vm.mu.RLock()
_, stillPending := vm.pendingBridges[req.ID]
@@ -141,21 +73,16 @@ func TestABlockThatCannotCommitReleasesNothing(t *testing.T) {
// as durable: it was never written before, so a restart reset the chain to
// genesis and reset the daily bridge limit with it.
func TestAnAcceptedBlockIsDurableAndReleased(t *testing.T) {
vm, db, req := vmWithTransfer(t)
vm, db := bootRefusing(t)
vm.releaser = &releaser{vm: vm, queue: make(chan *BridgeRequest, 8), quit: make(chan struct{})}
req := requestFor(1, 7)
pend(vm, req)
built, err := vm.BuildBlock(context.Background())
require.NoError(t, err)
blk := built.(*Block)
require.NoError(t, blk.Accept(context.Background()))
blk := buildAndAccept(t, vm)
require.Len(t, vm.releaser.queue, 1)
require.Equal(t, req.ID, (<-vm.releaser.queue).ID)
vm.bridgeRegistry.mu.RLock()
volume := vm.bridgeRegistry.DailyVolume["eth"]
vm.bridgeRegistry.mu.RUnlock()
require.Equal(t, uint64(7), volume)
require.Equal(t, uint64(7), vm.movedToday(blk.BlockTimestamp, dstChain))
tip, height := vm.chain.Tip()
require.Equal(t, blk.ID(), tip)
@@ -173,14 +100,15 @@ func TestAnAcceptedBlockIsDurableAndReleased(t *testing.T) {
// TestTheHeightIndexNamesOnlyAcceptedBlocks pins the index to the commit that
// wrote it.
func TestTheHeightIndexNamesOnlyAcceptedBlocks(t *testing.T) {
vm, db, _ := vmWithTransfer(t)
vm, db := bootRefusing(t)
pend(vm, requestFor(1, 7))
blk, err := vm.BuildBlock(context.Background())
require.NoError(t, err)
blk := build(t, vm)
require.NoError(t, blk.Verify(context.Background()))
db.refuse = true
require.ErrorIs(t, blk.Accept(context.Background()), errRefused)
_, err = vm.GetBlockIDAtHeight(context.Background(), 1)
_, err := vm.GetBlockIDAtHeight(context.Background(), 1)
require.Error(t, err, "a block that did not commit has no height entry")
db.refuse = false
@@ -189,3 +117,110 @@ func TestTheHeightIndexNamesOnlyAcceptedBlocks(t *testing.T) {
require.NoError(t, err)
require.Equal(t, blk.ID(), at)
}
// TestTheDailyCapSurvivesARestart is the defect this file exists for.
//
// The counter the cap is measured against lived in a map on the heap. A
// restart built a fresh one, so the whole day's allowance came back: an
// operator restart — or a crash, or a rolling deploy — was a free reset of
// the number that bounds what this bridge can move.
func TestTheDailyCapSurvivesARestart(t *testing.T) {
db := memdb.New()
cfg := testConfig()
cfg.MaxBridgeAmount = 600
cfg.DailyBridgeLimit = 1000
vm := bootOn(t, db, cfg)
pend(vm, requestFor(1, 600))
first := buildAndAccept(t, vm)
require.Equal(t, uint64(600), vm.movedToday(first.BlockTimestamp, dstChain))
// Restart: a second VM over the same database, exactly as the node does.
restarted := bootOn(t, db, cfg)
require.Equal(t, uint64(600), restarted.movedToday(first.BlockTimestamp, dstChain),
"the day's spend is read back from disk, not started over")
// 600 already moved against a cap of 1000, so a second 600 does not fit.
pend(restarted, requestFor(2, 600))
_, err := restarted.BuildBlock(context.Background())
require.Error(t, err, "the restarted chain must still refuse what the cap no longer allows")
// And what does fit, fits.
pend(restarted, requestFor(3, 400))
blk := build(t, restarted)
require.Len(t, blk.BridgeRequests, 1)
require.Equal(t, uint64(400), blk.BridgeRequests[0].Amount)
require.NoError(t, blk.Verify(context.Background()))
require.NoError(t, blk.Accept(context.Background()))
require.Equal(t, uint64(1000), restarted.movedToday(blk.BlockTimestamp, dstChain))
}
// TestTheCapIsCountedPerDestination keeps one busy route from closing another.
func TestTheCapIsCountedPerDestination(t *testing.T) {
cfg := testConfig()
cfg.MaxBridgeAmount = 100
cfg.DailyBridgeLimit = 100
vm := bootOn(t, memdb.New(), cfg)
toZoo := requestFor(1, 100)
other := transferFor(2, 100)
other.DstChainID = 777
toElsewhere := &BridgeRequest{
ID: ids.ID(other.Digest()), SrcChainID: other.SrcChainID, DstChainID: other.DstChainID,
Nonce: other.Nonce, Asset: ids.ID(other.Asset), Amount: other.Amount,
Recipient: append([]byte(nil), other.Recipient[:]...),
}
pend(vm, toZoo, toElsewhere)
blk := buildAndAccept(t, vm)
require.Len(t, blk.BridgeRequests, 2, "two destinations each have their own cap")
require.Equal(t, uint64(100), vm.movedToday(blk.BlockTimestamp, dstChain))
require.Equal(t, uint64(100), vm.movedToday(blk.BlockTimestamp, 777))
}
// TestTheWindowReopensOnTheNextDay proves the cap is daily rather than
// permanent. The counter it replaced only ever grew, so once total volume
// passed the limit every later block failed verification and the bridge
// stopped for good.
//
// Both blocks are stamped by hand, in the past, because what decides the
// 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.MaxBridgeAmount = 100
cfg.DailyBridgeLimit = 100
vm := bootOn(t, memdb.New(), cfg)
genesisTime := vm.genesisBlock.BlockTimestamp
first := blockOn(t, vm, vm.genesisBlock, genesisTime, requestFor(1, 100))
require.NoError(t, first.Verify(context.Background()))
require.NoError(t, first.Accept(context.Background()))
// A third 100 on the same day does not fit.
same := blockOn(t, vm, first, genesisTime, requestFor(2, 100))
require.ErrorContains(t, same.Verify(context.Background()), "daily cap")
// The next day starts the window over.
next := blockOn(t, vm, first, genesisTime+dayLength, requestFor(2, 100))
require.NoError(t, next.Verify(context.Background()))
require.NoError(t, next.Accept(context.Background()))
require.Equal(t, uint64(100), vm.movedToday(genesisTime, dstChain))
require.Equal(t, uint64(100), vm.movedToday(genesisTime+dayLength, dstChain))
}
// blockOn assembles a block on a chosen parent at a chosen time, which is how
// a peer's block arrives: this node did not pick either.
func blockOn(t *testing.T, vm *VM, parent *Block, at int64, reqs ...*BridgeRequest) *Block {
t.Helper()
blk := &Block{
ParentID_: parent.ID(),
BlockHeight: parent.Height() + 1,
BlockTimestamp: at,
BridgeRequests: reqs,
vm: vm,
}
blk.ID_ = blk.computeID()
return blk
}
-121
View File
@@ -1,121 +0,0 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package bridgevm
import (
"crypto/rand"
"testing"
"github.com/stretchr/testify/require"
"github.com/luxfi/ids"
"github.com/luxfi/threshold/pkg/math/curve"
"github.com/luxfi/threshold/pkg/party"
"github.com/luxfi/threshold/protocols/cmp/config"
)
// twoPartyConfig returns a CMP config carrying real secp256k1 public shares for
// two parties, which is everything the block-signature batch reads.
func twoPartyConfig(t *testing.T) (*config.Config, []party.ID) {
t.Helper()
group := curve.Secp256k1{}
ids := []party.ID{"party-a", "party-b"}
pub := make(map[party.ID]*config.Public, len(ids))
for _, id := range ids {
pub[id] = &config.Public{ECDSA: randomScalar(t, group).ActOnBase()}
}
return &config.Config{Group: group, Public: pub}, ids
}
func randomScalar(t *testing.T, group curve.Curve) curve.Scalar {
t.Helper()
s := group.NewScalar()
b := make([]byte, 32)
_, err := rand.Read(b)
require.NoError(t, err)
b[0] &= 0x7f // stay below the group order
require.NoError(t, s.UnmarshalBinary(b))
return s
}
func entriesFor(sigLen int, partyIDs []party.ID) []sigEntry {
out := make([]sigEntry, 0, len(partyIDs))
for _, id := range partyIDs {
out = append(out, sigEntry{partyID: id, sigBytes: make([]byte, sigLen)})
}
return out
}
// TestBatchSignsTheBlockHashItself pins the message the accelerated batch checks
// to the message the CPU path checks. The GPU layout hashed the block hash a
// second time, so the batch verified signatures over sha256(blockHash) while
// verifyBlockSig verified them over blockHash — the same signature set was valid
// on a node with an accelerator and invalid on a node without one, which splits
// accept from reject on host hardware alone.
func TestBatchSignsTheBlockHashItself(t *testing.T) {
require := require.New(t)
cfg, partyIDs := twoPartyConfig(t)
blockHash := ids.GenerateTestID()
messages, sigs, pubkeys, err := packBlockSigBatch(blockHash, entriesFor(64, partyIDs), cfg)
require.NoError(err)
require.Len(messages, 2*batchHashSize)
require.Len(sigs, 2*batchSigSize)
require.Len(pubkeys, 2*batchPKSize)
for i := range partyIDs {
require.Equal(blockHash[:], messages[i*batchHashSize:(i+1)*batchHashSize],
"row %d must carry the block hash, not a rehash of it", i)
}
}
// TestBatchRefusesRowsItCannotRepresent proves a row the layout cannot fill
// exactly is an error rather than a zero-filled row. Zero-filling left the entry
// occupying a slot in the result vector whose verdict was then trusted, so a
// signature the CPU path rejects outright could be counted valid; combined with
// the "one valid signature is enough" block rule, one spuriously-true row admits
// the block. Returning an error sends the batch to the CPU, the one authority.
func TestBatchRefusesRowsItCannotRepresent(t *testing.T) {
require := require.New(t)
cfg, partyIDs := twoPartyConfig(t)
blockHash := ids.GenerateTestID()
_, _, _, err := packBlockSigBatch(blockHash, entriesFor(32, partyIDs), cfg)
require.Error(err, "a 32-byte signature cannot fill a 64-byte row")
// A party with no public share in the config cannot be laid out either.
orphan := []sigEntry{{partyID: "unknown-party", sigBytes: make([]byte, 64)}}
require.Panics(func() { _, _, _, _ = packBlockSigBatch(blockHash, orphan, cfg) },
"batchVerifyBlockSignatures filters unknown parties before this point")
}
// TestVerifyBlockSigIsTheOneAuthority pins the single acceptance predicate: a
// malformed signature is refused, and it is checked against the block hash.
func TestVerifyBlockSigIsTheOneAuthority(t *testing.T) {
require := require.New(t)
cfg, partyIDs := twoPartyConfig(t)
blockHash := ids.GenerateTestID()
require.False(verifyBlockSig(cfg, blockHash, sigEntry{partyID: partyIDs[0], sigBytes: nil}))
require.False(verifyBlockSig(cfg, blockHash, sigEntry{partyID: partyIDs[0], sigBytes: make([]byte, 32)}))
require.False(verifyBlockSig(cfg, blockHash, sigEntry{partyID: partyIDs[0], sigBytes: make([]byte, 64)}),
"an all-zero signature is not a valid signature")
}
// TestBatchVerifyCountsNoSignatureWithoutConfig keeps the fail-closed entry
// conditions of the caller honest.
func TestBatchVerifyCountsNoSignatureWithoutConfig(t *testing.T) {
require := require.New(t)
cfg, partyIDs := twoPartyConfig(t)
blockHash := ids.GenerateTestID()
sigs := map[ids.NodeID][]byte{ids.GenerateTestNodeID(): make([]byte, 64)}
require.Zero(batchVerifyBlockSignatures(blockHash, sigs, nil, nil),
"no MPC config means no signature can be counted valid")
require.Zero(batchVerifyBlockSignatures(blockHash, nil, cfg, nil),
"no signatures means no valid signatures")
require.Zero(batchVerifyBlockSignatures(blockHash, sigs, cfg, nil),
"a signer with no public share in the config is not counted")
_ = partyIDs
}
+114 -179
View File
@@ -16,32 +16,36 @@ import (
"github.com/luxfi/database"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/threshold/pkg/ecdsa"
"github.com/luxfi/threshold/pkg/math/curve"
)
// Block represents a block in the Bridge chain
// Block is one step of the bridge chain: the transfers it settles, and where
// it sits in the sequence.
type Block struct {
ParentID_ ids.ID `json:"parentId"` // Field renamed to avoid method collision
BlockHeight uint64 `json:"height"`
BlockTimestamp int64 `json:"timestamp"`
BridgeRequests []*BridgeRequest `json:"bridgeRequests"`
// MPC signatures for this block (NodeID -> signature bytes)
MPCSignatures map[ids.NodeID][]byte `json:"mpcSignatures"`
// Cached values
ID_ ids.ID
bytes []byte
status choices.Status
vm *VM
// spend is the bridge's state as of this block, computed when it verified.
// A child built on it starts from here; the accepted tip has it on disk
// instead, so Publish drops it.
spend *spend
}
var (
errInvalidBlock = errors.New("invalid block")
errFutureBlock = errors.New("block timestamp is in the future")
errInvalidBlock = errors.New("bridgevm: invalid block")
errFutureBlock = errors.New("bridgevm: block timestamp is in the future")
maxClockSkew = int64(60) // 60 seconds
// maxClockSkew is how far ahead of this node's clock a block may be
// stamped. Chain time drives the daily cap, so a proposer that could stamp
// freely could jump the window forward and reopen the cap at will.
maxClockSkew = int64(60)
)
// ID returns the block ID
@@ -52,21 +56,27 @@ func (b *Block) ID() ids.ID {
return b.ID_
}
// computeID calculates the block ID
// computeID calculates the block ID.
//
// The chain this block belongs to is the first thing hashed. Without it the
// same bytes name the same block on every chain that runs this VM, so a block
// built on one is a block on the other — parent ids resolve, heights line up,
// and a testnet block is a mainnet block.
func (b *Block) computeID() ids.ID {
h := sha256.New()
h.Write(b.vm.chainID[:])
h.Write(b.ParentID_[:])
heightBytes := make([]byte, 8)
binary.BigEndian.PutUint64(heightBytes, b.BlockHeight)
h.Write(heightBytes)
var num [8]byte
binary.BigEndian.PutUint64(num[:], b.BlockHeight)
h.Write(num[:])
timestampBytes := make([]byte, 8)
binary.BigEndian.PutUint64(timestampBytes, uint64(b.BlockTimestamp))
h.Write(timestampBytes)
binary.BigEndian.PutUint64(num[:], uint64(b.BlockTimestamp))
h.Write(num[:])
// Include bridge requests in hash
// Each request's id is the digest of the transfer it carries (Verify
// refuses one where it is not), so hashing the ids hashes the transfers.
for _, req := range b.BridgeRequests {
h.Write(req.ID[:])
}
@@ -82,13 +92,18 @@ func (b *Block) computeID() ids.ID {
// that failed left every one of those done and the block missing, including a
// release already in flight for a transfer no block records.
func (b *Block) Accept(ctx context.Context) error {
tip, _ := b.vm.chain.Tip()
if b.ParentID_ != tip {
return fmt.Errorf("%w: %s extends %s, which is not the tip %s",
errInvalidBlock, b.ID(), b.ParentID_, tip)
}
return b.vm.chain.Accept(b)
}
// Write records nothing beyond the block itself, which the store writes. The
// bridge registry and the daily volume this block advances are held in memory
// only — see the note on BridgeRegistry — so they are published, not written.
func (b *Block) Write(database.Database) error { return nil }
// Write stages what this block settles: the transfers, and the day's running
// total per destination. Both land in the same commit as the block itself, so
// the cap the chain enforces is the cap the chain remembers after a restart.
func (b *Block) Write(db database.Database) error { return b.write(db) }
// Publish completes the block's transfers and hands them to the release
// worker. It runs after the commit, so an external release is only ever
@@ -96,29 +111,19 @@ func (b *Block) Write(database.Database) error { return nil }
func (b *Block) Publish() {
b.status = choices.Accepted
// What this block decided is on disk now, so the in-memory answer would
// be a second copy of it.
b.spend = nil
for _, req := range b.BridgeRequests {
completed := &CompletedBridge{
RequestID: req.ID,
SourceTxID: req.SourceTxID,
CompletedAt: b.Timestamp(),
}
// Collect MPC signatures
if len(req.MPCSignatures) > 0 {
completed.MPCSignature = req.MPCSignatures[0] // Use aggregated signature
}
b.vm.bridgeRegistry.mu.Lock()
b.vm.bridgeRegistry.CompletedBridges[req.ID] = completed
b.vm.bridgeRegistry.DailyVolume[req.DestChain] += req.Amount
b.vm.bridgeRegistry.mu.Unlock()
b.vm.mu.Lock()
delete(b.vm.pendingBridges, req.ID)
releaser := b.vm.releaser
b.vm.mu.Unlock()
b.vm.log.Info("completed bridge request",
b.vm.log.Info("bridgevm: transfer settled",
log.Stringer("requestID", req.ID),
log.String("destChain", req.DestChain),
log.Uint32("dst", req.DstChainID),
log.Uint64("amount", req.Amount),
)
@@ -126,15 +131,17 @@ func (b *Block) Publish() {
// Non-blocking: the attestation and EVM broadcast happen off the
// consensus path so this never waits on network I/O. Every relayer
// broadcasts; the on-chain nonce replay-guard collapses duplicates.
if b.vm.releaser != nil {
b.vm.releaser.enqueue(req)
if releaser != nil {
releaser.enqueue(req)
}
}
}
// Reject marks the block as rejected
// Reject marks the block as rejected. Its transfers were never removed from
// the set waiting for a block, so they are proposed again rather than lost.
func (b *Block) Reject(ctx context.Context) error {
b.status = choices.Rejected
b.spend = nil
b.vm.chain.Drop(b.ID())
return nil
}
@@ -154,155 +161,83 @@ func (b *Block) Parent() ids.ID {
return b.ParentID_
}
// Verify verifies the block
// Verify decides whether this block may become part of the chain.
//
// It answers over the state as of the block's PARENT, not as of the tip: a
// block whose parent is still in flight is the ordinary shape whenever more
// than one block is in the air, and checking it against the tip would let two
// blocks in a row each spend the whole daily cap.
func (b *Block) Verify(ctx context.Context) error {
// Basic validation
if b.BlockHeight == 0 && b.ParentID_ != ids.Empty {
return errInvalidBlock
if b.BlockHeight == 0 {
// Genesis is given, not verified. Accepting a second one would let a
// peer hand this chain a new beginning.
return fmt.Errorf("%w: height 0 is genesis", errInvalidBlock)
}
if len(b.BridgeRequests) == 0 {
return fmt.Errorf("%w: carries no transfers", errInvalidBlock)
}
if len(b.BridgeRequests) > maxRequestsPerBlock {
return fmt.Errorf("%w: %d transfers over the %d cap",
errInvalidBlock, len(b.BridgeRequests), maxRequestsPerBlock)
}
// Verify timestamp
if b.BlockTimestamp > time.Now().Unix()+maxClockSkew {
return errFutureBlock
}
// Verify each bridge request: confirmations, amounts, daily limits
for _, req := range b.BridgeRequests {
if req.Confirmations < b.vm.config.MinConfirmations {
return fmt.Errorf("insufficient confirmations for request %s: %d < %d",
req.ID, req.Confirmations, b.vm.config.MinConfirmations)
}
if req.Amount > b.vm.config.MaxBridgeAmount {
return fmt.Errorf("bridge amount exceeds maximum: %d > %d",
req.Amount, b.vm.config.MaxBridgeAmount)
}
b.vm.bridgeRegistry.mu.RLock()
dailyVolume := b.vm.bridgeRegistry.DailyVolume[req.DestChain]
b.vm.bridgeRegistry.mu.RUnlock()
if dailyVolume+req.Amount > b.vm.config.DailyBridgeLimit {
return fmt.Errorf("would exceed daily bridge limit for chain %s", req.DestChain)
}
}
// Batch verify MPC request signatures (GPU-accelerated when available)
if b.vm.mpcConfig != nil {
sigErrors := batchVerifyRequestSignaturesGPU(b.BridgeRequests, b.vm.mpcConfig, b.vm.log)
for i, err := range sigErrors {
if err != nil {
return fmt.Errorf("MPC signature verification failed for request %s: %w",
b.BridgeRequests[i].ID, err)
}
}
} else {
// No MPC config: verify individually (original path)
for _, req := range b.BridgeRequests {
if len(req.MPCSignatures) > 0 {
if err := b.verifyRequestMPCSignatures(req); err != nil {
return fmt.Errorf("MPC signature verification failed for request %s: %w", req.ID, err)
}
}
}
}
// Verify MPC block signatures using threshold ECDSA.
// Uses GPU-accelerated batch verification when available and multiple sigs exist.
blockHash := b.ID()
validSignatures := batchVerifyBlockSignatures(blockHash, b.MPCSignatures, b.vm.mpcConfig, b.vm.log)
// For threshold signature, we only need 1 valid aggregated signature
// (the signature itself is produced by t+1 parties collaboratively)
if len(b.MPCSignatures) > 0 && validSignatures < 1 {
return fmt.Errorf("no valid MPC signature found")
}
return nil
}
// deserializeSignature deserializes signature bytes into an ecdsa.Signature
func deserializeSignature(group curve.Curve, data []byte) (*ecdsa.Signature, error) {
if len(data) < 64 {
return nil, errors.New("signature too short")
}
// Create empty signature for this curve
sig := ecdsa.EmptySignature(group)
// Unmarshal R point (first 33 bytes for compressed, or 65 for uncompressed)
// For simplicity, assume first half is R, second half is S
rLen := len(data) / 2
if err := sig.R.UnmarshalBinary(data[:rLen]); err != nil {
return nil, fmt.Errorf("failed to unmarshal R: %w", err)
}
if err := sig.S.UnmarshalBinary(data[rLen:]); err != nil {
return nil, fmt.Errorf("failed to unmarshal S: %w", err)
}
return &sig, nil
}
// verifyRequestMPCSignatures verifies MPC threshold signatures on a bridge request
// using the CMP ECDSA protocol. The aggregated signature is verified against the
// group public key derived from Lagrange interpolation of public shares.
func (b *Block) verifyRequestMPCSignatures(req *BridgeRequest) error {
if b.vm.mpcConfig == nil {
return errors.New("MPC config not initialized")
}
// Get the group public key using Lagrange interpolation
groupPublicKey := b.vm.mpcConfig.PublicPoint()
if groupPublicKey == nil {
return errors.New("failed to compute group public key")
}
// Compute message hash for verification (request ID serves as unique identifier)
messageHash := computeRequestHash(req)
// For threshold ECDSA, we verify the single aggregated signature
// produced by t+1 parties against the combined group public key
if len(req.MPCSignatures) == 0 {
return errors.New("no MPC signatures present")
}
// The aggregated threshold signature (produced by CMP sign protocol)
// is stored as the first entry in MPCSignatures
aggregatedSigBytes := req.MPCSignatures[0]
// Deserialize the ECDSA signature
sig, err := deserializeSignature(b.vm.mpcConfig.Group, aggregatedSigBytes)
parent, err := b.vm.chain.Block(b.ParentID_, b.vm.parseBlock)
if err != nil {
return fmt.Errorf("failed to deserialize aggregated signature: %w", err)
return fmt.Errorf("bridgevm: parent %s: %w", b.ParentID_, err)
}
tip, tipHeight := b.vm.chain.Tip()
if parent.ID() != tip {
// A parent below the accepted tip is a block the chain has already
// built past. Building on it rewinds the chain, and the height index
// then names an orphan as the block at that height.
if parent.Height() <= tipHeight {
return fmt.Errorf("%w: parent %s at height %d is beneath the tip at %d",
errInvalidBlock, parent.ID(), parent.Height(), tipHeight)
}
// A parent still in flight carries the state it decided. One that
// carries none is a block this node never checked, and guessing what
// it spent is how a chain accepts a block it cannot account for.
if parent.spend == nil {
return fmt.Errorf("%w: parent %s has not been verified here",
errInvalidBlock, parent.ID())
}
}
if b.BlockHeight != parent.Height()+1 {
return fmt.Errorf("%w: height %d does not follow parent %d",
errInvalidBlock, b.BlockHeight, parent.Height())
}
// Chain time only moves forward. It drives the daily window, so a proposer
// free to rewind it reopens a cap that has already been spent.
if b.BlockTimestamp < parent.BlockTimestamp {
return fmt.Errorf("%w: timestamp %d precedes parent %d",
errInvalidBlock, b.BlockTimestamp, parent.BlockTimestamp)
}
if b.BlockTimestamp > time.Now().Unix()+maxClockSkew {
return fmt.Errorf("%w: timestamp %d is more than %ds ahead",
errFutureBlock, b.BlockTimestamp, maxClockSkew)
}
// Verify the signature against the group public key
if !sig.Verify(groupPublicKey, messageHash) {
return errors.New("aggregated MPC signature verification failed")
state := b.vm.spendAt(parent)
day := b.BlockTimestamp / dayLength
for _, req := range b.BridgeRequests {
// 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 {
return err
}
}
b.spend = state
// A block that verifies is one the engine may build on, so it has to be
// findable by id — including one parsed from a peer rather than built
// here. Tracking only self-built blocks left a follower able to verify the
// first block of a run and unable to verify the second, which is the
// ordinary shape whenever more than one block is in flight.
b.vm.chain.Track(b)
return nil
}
// computeRequestHash computes a deterministic hash of a bridge request for signing
func computeRequestHash(req *BridgeRequest) []byte {
h := sha256.New()
h.Write(req.ID[:])
h.Write([]byte(req.SourceChain))
h.Write([]byte(req.DestChain))
h.Write(req.Asset[:])
amountBytes := make([]byte, 8)
binary.BigEndian.PutUint64(amountBytes, req.Amount)
h.Write(amountBytes)
h.Write(req.Recipient)
h.Write(req.SourceTxID[:])
return h.Sum(nil)
}
// Height returns the block height
func (b *Block) Height() uint64 {
return b.BlockHeight
+57 -38
View File
@@ -8,47 +8,26 @@ import (
"context"
"sort"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/luxfi/chains/chain"
"github.com/luxfi/database/memdb"
"github.com/luxfi/ids"
"github.com/luxfi/log"
)
// vmWithPending builds a VM holding n ready bridge requests and a stored parent.
// vmWithPending builds a VM holding n transfers ready for a block, and the ids
// they should be carried in.
func vmWithPending(t *testing.T, n int) (*VM, []ids.ID) {
t.Helper()
vm := &VM{
log: log.NewNoOpLogger(),
config: BridgeConfig{MinConfirmations: 1, MaxBridgeAmount: 1 << 40, DailyBridgeLimit: 1 << 40},
pendingBridges: make(map[ids.ID]*BridgeRequest, n),
bridgeRegistry: &BridgeRegistry{DailyVolume: make(map[string]uint64)},
}
vm.chain = chain.New[*Block](memdb.New(), nil)
parent := &Block{BlockHeight: 0, BlockTimestamp: 0, ParentID_: ids.Empty, BridgeRequests: []*BridgeRequest{}, vm: vm}
parent.ID_ = parent.computeID()
_, _, err := vm.chain.Open(parent, vm.parseBlock)
require.NoError(t, err)
cfg := testConfig()
cfg.DailyBridgeLimit = 1 << 40
vm := bootOn(t, memdb.New(), cfg)
want := make([]ids.ID, 0, n)
for i := 0; i < n; i++ {
id := ids.GenerateTestID()
vm.pendingBridges[id] = &BridgeRequest{
ID: id,
SourceChain: "C",
DestChain: "eth",
Amount: 1,
Recipient: make([]byte, 20),
Confirmations: 12,
Status: "pending",
CreatedAt: time.Unix(0, 0),
}
want = append(want, id)
req := requestFor(uint64(i+1), 1)
pend(vm, req)
want = append(want, req.ID)
}
sort.Slice(want, func(i, j int) bool { return bytes.Compare(want[i][:], want[j][:]) < 0 })
return vm, want
@@ -63,18 +42,19 @@ func TestBuildBlockOrdersRequestsDeterministically(t *testing.T) {
require := require.New(t)
vm, want := vmWithPending(t, 24)
blk, err := vm.BuildBlock(context.Background())
require.NoError(err)
blk := build(t, vm)
got := make([]ids.ID, 0, len(want))
for _, req := range blk.(*Block).BridgeRequests {
for _, req := range blk.BridgeRequests {
got = append(got, req.ID)
}
require.Equal(want, got, "requests must be carried in id order")
// Rebuilding from the same pending set must reproduce the same block.
again, err := vm.BuildBlock(context.Background())
require.NoError(err)
// Rebuilding from the same pending set must reproduce the same block. The
// timestamp is the one thing that moves, so it is held still.
again := build(t, vm)
again.BlockTimestamp = blk.BlockTimestamp
again.ID_ = again.computeID()
require.Equal(blk.ID(), again.ID(), "the same pending set must build the same block")
require.Equal(blk.Bytes(), again.Bytes())
}
@@ -87,13 +67,52 @@ func TestBuildBlockCapIsADeterministicPrefix(t *testing.T) {
require := require.New(t)
vm, want := vmWithPending(t, maxRequestsPerBlock+40)
blk, err := vm.BuildBlock(context.Background())
require.NoError(err)
blk := build(t, vm)
got := make([]ids.ID, 0, maxRequestsPerBlock)
for _, req := range blk.(*Block).BridgeRequests {
for _, req := range blk.BridgeRequests {
got = append(got, req.ID)
}
require.Len(got, maxRequestsPerBlock)
require.Equal(want[:maxRequestsPerBlock], got, "the cap must take the lowest ids, not a random draw")
}
// TestBuildBlockRefusesWhenNothingIsReady keeps an empty block off the chain:
// Verify refuses one, so building one would wedge the proposer.
func TestBuildBlockRefusesWhenNothingIsReady(t *testing.T) {
vm, _ := vmWithPending(t, 0)
_, err := vm.BuildBlock(context.Background())
require.Error(t, err)
}
// TestWhatIsBuiltIsWhatVerifies is the property that keeps the chain moving.
//
// Assembly and verification used different rules: the builder filtered on
// confirmation depth alone while Verify also applied the per-transfer cap and
// the daily cap. One oversized transfer in the pending set was therefore
// proposed by the builder, refused by every node, put straight back in the
// pending set by the rejection, and proposed again — block production stopped
// and did not resume.
func TestWhatIsBuiltIsWhatVerifies(t *testing.T) {
cfg := testConfig()
cfg.MaxBridgeAmount = 100
cfg.DailyBridgeLimit = 250
vm := bootOn(t, memdb.New(), cfg)
oversized := requestFor(1, 5_000) // over the per-transfer cap
fine := requestFor(2, 100)
pend(vm, oversized, fine)
blk := build(t, vm)
require.Len(t, blk.BridgeRequests, 1, "the builder must leave behind what Verify would refuse")
require.Equal(t, fine.ID, blk.BridgeRequests[0].ID)
require.NoError(t, blk.Verify(context.Background()), "what was built must verify")
require.NoError(t, blk.Accept(context.Background()))
// The daily cap is the same rule in the same place: 100 has moved of 250,
// so the next block carries what fits and stops there.
pend(vm, requestFor(3, 100), requestFor(4, 100))
second := build(t, vm)
require.Len(t, second.BridgeRequests, 1, "the builder stops at the daily cap, as Verify does")
require.NoError(t, second.Verify(context.Background()))
}
+13 -2
View File
@@ -5,11 +5,22 @@ package bridgevm
import (
"github.com/luxfi/chains/chain"
"github.com/luxfi/constants"
"github.com/luxfi/ids"
)
// VMID is the unique identifier for BridgeVM (B-Chain)
var VMID = ids.ID{'b', 'r', 'i', 'd', 'g', 'e', 'v', 'm'}
// VMID identifies B-Chain: cross-chain settlement.
//
// It is an immutable one-way door — the node resolves a plugin binary by this
// id, so a chain created under one value can never be served by a binary
// answering to another. It is therefore taken from luxfi/constants and nowhere
// else; a private literal beside the shared one is two declarations of a value
// that must be one.
var VMID = constants.BridgeVMID
// The bytes, asserted at compile time so a change to either declaration is a
// build failure rather than a chain nothing can open.
var _ = map[bool]struct{}{VMID == ids.ID{'b', 'r', 'i', 'd', 'g', 'e', 'v', 'm'}: {}}
// Factory creates B-Chain VM instances.
type Factory = chain.Factory[VM]
+184
View File
@@ -0,0 +1,184 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package bridgevm
import (
"context"
"encoding/json"
"errors"
"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"
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'}
func testConfig() BridgeConfig {
return BridgeConfig{
MinConfirmations: 12,
MaxBridgeAmount: 1_000_000,
DailyBridgeLimit: 10_000_000,
RequireValidatorBond: minValidatorBond,
MaxSigners: 100,
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, cfg BridgeConfig) vmcore.Init {
raw, err := json.Marshal(cfg)
if err != nil {
panic(err)
}
return vmcore.Init{
Runtime: &runtime.Runtime{
Log: log.NewNoOpLogger(),
NetworkID: 96369,
ChainID: testChainID,
NodeID: ids.GenerateTestNodeID(),
},
DB: db,
Genesis: []byte(`{"timestamp":1000000}`),
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 {
t.Helper()
vm := &VM{}
require.NoError(t, vm.Initialize(context.Background(), initFor(db, cfg)))
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())
}
// 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
}
// 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), quit: make(chan struct{})}
}
+285
View File
@@ -0,0 +1,285 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package bridgevm
import (
"encoding/binary"
"errors"
"fmt"
"github.com/luxfi/database"
"github.com/luxfi/ids"
)
// The bridge's spend state: which transfers it has settled, and how much has
// moved to each destination on each day. Both are written in the same commit
// as the block that moves them, so a restart resumes where the chain is
// rather than where memory was. Held in memory alone, the daily counter reset
// to zero on every restart and an operator restart was a free reset of the
// cap; a cap that can be forgotten is not a cap.
//
// Everything here is a function of committed state and the block's own
// contents, so every node computes the same answer.
// dayLength is the window the daily cap is measured over. The window is cut
// from the BLOCK's timestamp, never the wall clock: consensus decides what
// time it is, so every node closes the window on the same block.
const dayLength = 86400
var (
settledPrefix = []byte("bridge/settled/") // + 32-byte transfer digest
movedPrefix = []byte("bridge/moved/") // + 8-byte day + 4-byte destination chain
)
// errReplay reports a transfer this chain has already settled.
var errReplay = errors.New("bridgevm: transfer already settled")
func settledKey(id ids.ID) []byte {
return append(append([]byte(nil), settledPrefix...), id[:]...)
}
func movedKey(day int64, dst uint32) []byte {
k := make([]byte, 0, len(movedPrefix)+12)
k = append(k, movedPrefix...)
var buf [8]byte
binary.BigEndian.PutUint64(buf[:], uint64(day))
k = append(k, buf[:]...)
binary.BigEndian.PutUint32(buf[:4], dst)
return append(k, buf[:4]...)
}
// settlement is what the chain records about a transfer it has settled: the
// block that carried it. Presence is the replay guard; the contents answer
// when.
type settlement struct {
Height uint64
Time int64
}
func (s settlement) bytes() []byte {
var out [16]byte
binary.BigEndian.PutUint64(out[:8], s.Height)
binary.BigEndian.PutUint64(out[8:], uint64(s.Time))
return out[:]
}
func readSettlement(raw []byte) (settlement, error) {
if len(raw) != 16 {
return settlement{}, fmt.Errorf("bridgevm: settlement record is %d bytes, want 16", len(raw))
}
return settlement{
Height: binary.BigEndian.Uint64(raw[:8]),
Time: int64(binary.BigEndian.Uint64(raw[8:])),
}, nil
}
// spend is the bridge's state as of one block: committed state, plus whatever
// the blocks between it and the accepted tip have already decided.
//
// moved holds ABSOLUTE totals, seeded from the database the first time a
// window is touched and carried whole to descendants. An overlay of deltas
// would double-count as soon as an ancestor was accepted, because the
// database then holds the same amount the overlay still remembers.
//
// settled is an overlay, which is right for a set: the database only ever
// gains entries, so committed-or-remembered is the answer either way.
type spend struct {
db database.KeyValueReader
moved map[string]uint64
settled map[ids.ID]struct{}
}
func newSpend(db database.KeyValueReader) *spend {
return &spend{db: db, moved: map[string]uint64{}, settled: map[ids.ID]struct{}{}}
}
// clone is what a child block starts from. The maps are copied because the
// child accumulates into them and its parent may still be asked the same
// questions by a sibling.
func (s *spend) clone() *spend {
out := &spend{db: s.db, moved: make(map[string]uint64, len(s.moved)), settled: make(map[ids.ID]struct{}, len(s.settled))}
for k, v := range s.moved {
out.moved[k] = v
}
for k := range s.settled {
out.settled[k] = struct{}{}
}
return out
}
func (s *spend) movedOn(day int64, dst uint32) (uint64, error) {
key := movedKey(day, dst)
if v, ok := s.moved[string(key)]; ok {
return v, nil
}
raw, err := s.db.Get(key)
switch {
case errors.Is(err, database.ErrNotFound):
return 0, nil
case err != nil:
return 0, err
}
v, err := readCounter(raw)
if err != nil {
return 0, err
}
s.moved[string(key)] = v
return v, nil
}
func (s *spend) isSettled(id ids.ID) (bool, error) {
if _, ok := s.settled[id]; ok {
return true, nil
}
has, err := s.db.Has(settledKey(id))
if err != nil {
return false, err
}
return has, nil
}
// record marks the transfer settled and charges its amount to the window. It
// runs only after admit has said yes.
func (s *spend) record(day int64, req *BridgeRequest, was uint64) {
s.settled[req.ID] = struct{}{}
s.moved[string(movedKey(day, req.DstChainID))] = was + req.Amount
}
func readCounter(raw []byte) (uint64, error) {
if len(raw) != 8 {
return 0, fmt.Errorf("bridgevm: counter is %d bytes, want 8", len(raw))
}
return binary.BigEndian.Uint64(raw), nil
}
func counterBytes(v uint64) []byte {
var out [8]byte
binary.BigEndian.PutUint64(out[:], v)
return out[:]
}
// admissible is everything about a transfer that can be decided without
// asking the chain what it has already done: it is well formed, it is what it
// says it is, and it is within the per-transfer cap.
//
// It runs where a lock is first read as well as where a block is checked. A
// 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 {
transfer, err := req.transfer()
if err != nil {
return err
}
// The request's id IS its transfer. Without this the id is a label the
// proposer chooses, so the same deposit rides in under a hundred ids and
// the replay guard, the daily counter and the release all key off a name
// that has nothing to do with what is being moved.
if ids.ID(transfer.Digest()) != req.ID {
return fmt.Errorf("bridgevm: request %s is not the digest of the transfer it carries", req.ID)
}
if req.SrcChainID == 0 {
return fmt.Errorf("bridgevm: request %s names no source chain", req.ID)
}
if req.SrcChainID == req.DstChainID {
return fmt.Errorf("bridgevm: request %s bridges chain %d to itself", req.ID, req.SrcChainID)
}
if req.Amount == 0 {
return fmt.Errorf("bridgevm: request %s moves nothing", req.ID)
}
if req.Amount > cfg.MaxBridgeAmount {
return fmt.Errorf("bridgevm: request %s moves %d, over the %d per-transfer cap",
req.ID, req.Amount, cfg.MaxBridgeAmount)
}
return nil
}
// admit is the ONE rule for whether a block may carry this transfer at this
// point in the chain, and it records the transfer when the answer is yes.
//
// Assembly asks it and Verify asks it, over the same state, in the same
// 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 {
return err
}
settled, err := s.isSettled(req.ID)
if err != nil {
return err
}
if settled {
return fmt.Errorf("%w: %s", errReplay, req.ID)
}
was, err := s.movedOn(day, req.DstChainID)
if err != nil {
return err
}
// Written as a subtraction so a total near the top of the range cannot
// wrap past the cap.
if req.Amount > cfg.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)
}
s.record(day, req, was)
return nil
}
// write stages what the block decided: one settlement record per transfer and
// the day's running total per destination. It reads back through the same
// view it writes to, so two transfers to one destination in one block see
// each other.
func (b *Block) write(db database.Database) error {
day := b.BlockTimestamp / dayLength
for _, req := range b.BridgeRequests {
if err := db.Put(settledKey(req.ID), settlement{Height: b.BlockHeight, Time: b.BlockTimestamp}.bytes()); err != nil {
return err
}
key := movedKey(day, req.DstChainID)
var was uint64
raw, err := db.Get(key)
switch {
case errors.Is(err, database.ErrNotFound):
case err != nil:
return err
default:
if was, err = readCounter(raw); err != nil {
return err
}
}
if err := db.Put(key, counterBytes(was+req.Amount)); err != nil {
return err
}
}
return nil
}
// spendAt is the state a block built on parent starts from.
//
// A parent this node verified carries the answer already, folded over
// everything its own ancestors decided. A parent that carries none is the
// accepted tip, whose effects are committed, so committed state IS the answer.
// Which of the two a parent is, is settled by the caller: Verify checks it
// against the tip, and a proposal is handed its parent by the store.
//
// It reads the database directly rather than the store, because a proposal
// asks this while the store's lock is already held on its behalf.
func (vm *VM) spendAt(parent *Block) *spend {
if parent.spend != nil {
return parent.spend.clone()
}
return newSpend(vm.chain.Base())
}
// movedToday reports what has moved to dst on the day the given time falls
// in, from committed state. It is the diagnostic view of the same counter the
// cap is enforced against.
func (vm *VM) movedToday(at int64, dst uint32) uint64 {
v, err := newSpend(vm.chain.Base()).movedOn(at/dayLength, dst)
if err != nil {
return 0
}
return v
}
+13 -27
View File
@@ -167,7 +167,12 @@ func (r *releaser) releaseOnce(ctx context.Context, req *BridgeRequest, transfer
if err != nil {
return err
}
r.vm.recordRelease(req, destTx)
// The destination tx hash is this relayer's own broadcast, not a fact the
// chain agreed: every relayer signs its own tx and gets its own hash. It
// is reported, not recorded.
r.vm.log.Info("bridgevm: release settled",
log.Stringer("requestID", req.ID),
log.Stringer("destTxID", destTx))
return nil
}
@@ -207,21 +212,6 @@ func (vm *VM) releaseTransfer(ctx context.Context, transfer bridgeattest.BridgeT
return dst.SendTransaction(ctx, &ReleaseCall{Transfer: transfer, Signature: att.Signature})
}
// recordRelease persists the completed cross-chain settlement.
func (vm *VM) recordRelease(req *BridgeRequest, destTx ids.ID) {
vm.bridgeRegistry.mu.Lock()
defer vm.bridgeRegistry.mu.Unlock()
cb := vm.bridgeRegistry.CompletedBridges[req.ID]
if cb == nil {
cb = &CompletedBridge{RequestID: req.ID, SourceTxID: req.SourceTxID, CompletedAt: time.Now()}
vm.bridgeRegistry.CompletedBridges[req.ID] = cb
}
cb.DestTxID = destTx
vm.log.Info("bridgevm: release settled",
log.Stringer("requestID", req.ID),
log.Stringer("destTxID", destTx))
}
func (vm *VM) evmClientByID(id uint32) ChainClient {
vm.mu.RLock()
defer vm.mu.RUnlock()
@@ -230,11 +220,10 @@ func (vm *VM) evmClientByID(id uint32) ChainClient {
// EnableBridgeRelease wires the EVM release plumbing: it dials each external
// chain, resolves that chain's gas-paying relayer key from KMS via kp, records
// the clients in BOTH the name-keyed ChainClient map (the field the recon
// flagged as never populated) and the id-keyed routing index, sets the
// attestation client, and starts the release worker. Idempotent per process:
// calling it twice replaces the wiring. Fail secure: any misconfigured chain or
// unresolved key aborts the whole enable — no partial, half-wired bridge.
// the clients in the id-keyed routing index, sets the attestation client, and
// starts the release worker. Idempotent per process: calling it twice replaces
// the wiring. Fail secure: any misconfigured chain or unresolved key aborts the
// whole enable — no partial, half-wired bridge.
func (vm *VM) EnableBridgeRelease(ctx context.Context, chains []ExternalChainConfig, kp KeyProvider, ac AttestationClient) error {
if len(chains) == 0 {
return errors.New("bridgevm: EnableBridgeRelease: no external chains configured")
@@ -246,12 +235,14 @@ func (vm *VM) EnableBridgeRelease(ctx context.Context, chains []ExternalChainCon
return errors.New("bridgevm: EnableBridgeRelease: AttestationClient required")
}
byName := make(map[string]ChainClient, len(chains))
byID := make(map[uint32]ChainClient, len(chains))
for _, cfg := range chains {
if cfg.GasKeyKMSPath == "" {
return fmt.Errorf("bridgevm: chain %q: gasKeyKmsPath required (KMS only)", cfg.Name)
}
if _, dup := byID[uint32(cfg.ChainID)]; dup {
return fmt.Errorf("bridgevm: duplicate chain id %d", cfg.ChainID)
}
key, err := kp(ctx, cfg.GasKeyKMSPath)
if err != nil {
return fmt.Errorf("bridgevm: chain %q: resolve gas key from KMS %q: %w", cfg.Name, cfg.GasKeyKMSPath, err)
@@ -260,10 +251,6 @@ func (vm *VM) EnableBridgeRelease(ctx context.Context, chains []ExternalChainCon
if err != nil {
return err
}
if _, dup := byID[uint32(cfg.ChainID)]; dup {
return fmt.Errorf("bridgevm: duplicate chain id %d", cfg.ChainID)
}
byName[cfg.Name] = client
byID[uint32(cfg.ChainID)] = client
}
@@ -271,7 +258,6 @@ func (vm *VM) EnableBridgeRelease(ctx context.Context, chains []ExternalChainCon
vm.mu.Lock()
old := vm.releaser
oldWatch := vm.watcher
vm.chainClients = byName
vm.evmByChainID = byID
vm.attestClient = ac
vm.mu.Unlock()
+49 -38
View File
@@ -10,6 +10,7 @@ import (
"net/http"
"strconv"
"strings"
"time"
"github.com/gorilla/rpc/v2"
"github.com/luxfi/ids"
@@ -571,6 +572,7 @@ type GetBridgeInfoReply struct {
SupportedChains []string `json:"supportedChains"`
TotalBridged string `json:"totalBridged"`
TotalFees string `json:"totalFees"`
Height uint64 `json:"height"`
}
// GetBridgeInfo answers bridge_getInfo. Read-only snapshot of node-level
@@ -591,24 +593,30 @@ func (s *Service) GetBridgeInfo(_ *http.Request, _ *GetBridgeInfoArgs, reply *Ge
reply.MPCReady = true
reply.MPCPublicKey = hexEncode(key)
}
reply.Threshold = s.vm.config.MPCThreshold
reply.TotalParties = s.vm.config.MPCTotalParties
reply.SupportedChains = append([]string(nil), s.vm.config.SupportedChains...)
// The threshold and the party count come from the signer set, which is
// the set that signs. Reporting a configured number beside it gave one
// question two answers, and the configured one was never anything but its
// default.
info := s.vm.GetSignerSetInfo()
reply.Threshold = info.Threshold
reply.TotalParties = info.TotalSigners
// Aggregate totals from the registry. Stringified so the daemon's
// bigint-safe decode (parseAmount) consumes them directly.
// TotalBridged is what has moved TODAY, summed over the destinations this
// bridge serves, read from the same durable counter the daily cap is
// enforced against. Stringified so the daemon's bigint-safe decode
// (parseAmount) consumes them directly.
var totalBridged uint64
if s.vm.bridgeRegistry != nil {
s.vm.bridgeRegistry.mu.RLock()
for _, v := range s.vm.bridgeRegistry.Validators {
totalBridged += v.TotalBridged
}
s.vm.bridgeRegistry.mu.RUnlock()
now := time.Now().Unix()
for _, cfg := range s.vm.config.ExternalChains {
reply.SupportedChains = append(reply.SupportedChains, cfg.Name)
totalBridged += s.vm.movedToday(now, uint32(cfg.ChainID))
}
reply.TotalBridged = strconv.FormatUint(totalBridged, 10)
// TotalFees is not tracked in registry yet; expose 0 explicitly so
// the daemon's JSON decode doesn't surface an "empty result" error.
// This chain charges its fee at admission, not out of the transfer, so
// nothing is accumulated here. Reported explicitly so the daemon's JSON
// decode does not surface an "empty result" error.
reply.TotalFees = "0"
_, reply.Height = s.vm.chain.Tip()
return nil
}
@@ -635,28 +643,36 @@ type ChainConfigReply struct {
Enabled bool `json:"enabled"`
}
// GetSupportedChains answers bridge_getSupportedChains. The VM's
// SupportedChains config drives the result; per-chain RPC endpoints /
// token contracts are not yet stored chain-side, so we surface the
// minimal stable shape (ChainID + Enabled) and leave the optional
// fields empty. The daemon already treats those as informational.
// GetSupportedChains answers bridge_getSupportedChains from the ONE
// declaration of which chains this bridge serves — the same ExternalChains the
// release path routes by. A second list of chain names, declared beside it and
// used only here, could name a chain the bridge cannot reach or omit one it
// releases to.
//
// The gas-key KMS path is deliberately not reported: it names where a secret
// lives, and this surface is unauthenticated.
func (s *Service) GetSupportedChains(_ *http.Request, _ *GetSupportedChainsArgs, reply *GetSupportedChainsReply) error {
if s.vm == nil {
return errors.New("bridgevm: VM not initialized")
}
out := make([]ChainConfigReply, 0, len(s.vm.config.SupportedChains))
for _, id := range s.vm.config.SupportedChains {
out = append(out, ChainConfigReply{
ChainID: id,
ChainName: id,
Confirmations: int(s.vm.config.MinConfirmations),
Enabled: true,
})
out := make([]ChainConfigReply, 0, len(s.vm.config.ExternalChains))
for _, cfg := range s.vm.config.ExternalChains {
out = append(out, s.chainConfig(cfg))
}
reply.Chains = out
return nil
}
func (s *Service) chainConfig(cfg ExternalChainConfig) ChainConfigReply {
return ChainConfigReply{
ChainID: strconv.FormatUint(cfg.ChainID, 10),
ChainName: cfg.Name,
BridgeContract: cfg.Gateway,
Confirmations: int(s.vm.config.MinConfirmations),
Enabled: true,
}
}
// GetChainConfigArgs is the bridge_getChainConfig request body.
type GetChainConfigArgs struct {
ChainID string `json:"chainId"`
@@ -665,9 +681,9 @@ type GetChainConfigArgs struct {
// GetChainConfigReply is the bridge_getChainConfig response.
type GetChainConfigReply ChainConfigReply
// GetChainConfig answers bridge_getChainConfig for one chain. Returns
// an explicit error when the chain isn't in the SupportedChains set so
// callers can distinguish "unknown chain" from "RPC mis-routed".
// GetChainConfig answers bridge_getChainConfig for one chain, by its numeric
// id or by its name. Returns an explicit error when this bridge does not serve
// the chain, so callers can distinguish "unknown chain" from "RPC mis-routed".
func (s *Service) GetChainConfig(_ *http.Request, args *GetChainConfigArgs, reply *GetChainConfigReply) error {
if s.vm == nil {
return errors.New("bridgevm: VM not initialized")
@@ -676,18 +692,13 @@ func (s *Service) GetChainConfig(_ *http.Request, args *GetChainConfigArgs, repl
if want == "" {
return errors.New("bridgevm: chainId required")
}
for _, id := range s.vm.config.SupportedChains {
if strings.EqualFold(id, want) {
*reply = GetChainConfigReply{
ChainID: id,
ChainName: id,
Confirmations: int(s.vm.config.MinConfirmations),
Enabled: true,
}
for _, cfg := range s.vm.config.ExternalChains {
if strings.EqualFold(cfg.Name, want) || strconv.FormatUint(cfg.ChainID, 10) == want {
*reply = GetChainConfigReply(s.chainConfig(cfg))
return nil
}
}
return fmt.Errorf("bridgevm: chain %q not in supportedChains", want)
return fmt.Errorf("bridgevm: chain %q is not one this bridge serves", want)
}
// GetSignatureArgs is the bridge_getSignature request body.
+338 -219
View File
@@ -8,22 +8,23 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/luxfi/ids"
)
// newRPCRig stands up a VM with quote + swap store wired and serves
// the JSON-RPC handler over httptest.
// newRPCRig serves the JSON-RPC handler over httptest, against a VM brought up
// the way the node brings one up. A rig that hand-assembles a VM answers for a
// state no node is ever in.
func newRPCRig(t *testing.T) (*httptest.Server, *VM) {
t.Helper()
vm := &VM{
quoteEngine: &QuoteEngine{Feed: defaultPriceFeed()},
swapStore: newInMemorySwapStore(),
}
vm := boot(t)
handlers, err := vm.CreateRPCHandlers()
if err != nil {
t.Fatalf("CreateRPCHandlers: %v", err)
}
require.NoError(t, err)
mux := http.NewServeMux()
for path, h := range handlers {
mux.Handle(path, h)
@@ -43,10 +44,13 @@ func callRPC(t *testing.T, url, method string, params any, out any) (rpcCode int
"method": method,
"params": params,
})
return postRPC(t, url, body, out)
}
func postRPC(t *testing.T, url string, body []byte, out any) (int, string) {
t.Helper()
resp, err := http.Post(url+"/rpc", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("Post: %v", err)
}
require.NoError(t, err)
defer resp.Body.Close()
var env struct {
Result json.RawMessage `json:"result"`
@@ -55,16 +59,12 @@ func callRPC(t *testing.T, url, method string, params any, out any) (rpcCode int
Message string `json:"message"`
} `json:"error"`
}
if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
t.Fatalf("decode: %v", err)
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&env))
if env.Error != nil {
return env.Error.Code, env.Error.Message
}
if out != nil && len(env.Result) > 0 {
if err := json.Unmarshal(env.Result, out); err != nil {
t.Fatalf("unmarshal result: %v", err)
}
require.NoError(t, json.Unmarshal(env.Result, out))
}
return 0, ""
}
@@ -80,15 +80,10 @@ func TestRPC_EstimateFee(t *testing.T) {
DestAsset: "LUX",
Amount: "1",
}, &reply)
if code != 0 {
t.Fatalf("rpc error: %d %s", code, msg)
}
if reply.NetAmount != "1400" {
t.Errorf("NetAmount = %q, want 1400 (1 ETH @ $3500 / $2.50)", reply.NetAmount)
}
if reply.FeeAmount != "0" {
t.Errorf("FeeAmount = %q, want 0 for non-Lux source", reply.FeeAmount)
}
require.Zero(t, code, msg)
require.NotEmpty(t, reply.FeeAmount)
require.NotEmpty(t, reply.NetAmount)
require.Positive(t, reply.EstimatedTime)
}
func TestRPC_EstimateFee_LuxExit(t *testing.T) {
@@ -100,28 +95,31 @@ func TestRPC_EstimateFee_LuxExit(t *testing.T) {
DestChain: "ETHEREUM_SEPOLIA",
SourceAsset: "LUX",
DestAsset: "ETH",
Amount: "1000",
Amount: "100",
}, &reply)
if code != 0 {
t.Fatalf("rpc error: %d %s", code, msg)
}
if reply.FeeAmount == "" || reply.FeeAmount == "0" {
t.Errorf("Lux-exit FeeAmount = %q, want > 0", reply.FeeAmount)
}
require.Zero(t, code, msg)
require.NotEqual(t, "0", reply.FeeAmount, "a Lux exit charges a service fee")
}
func TestRPC_EstimateFee_UnknownAsset(t *testing.T) {
srv, _ := newRPCRig(t)
var reply EstimateFeeReply
code, msg := callRPC(t, srv.URL, "bridge_estimateFee", EstimateFeeArgs{
code, _ := callRPC(t, srv.URL, "bridge_estimateFee", EstimateFeeArgs{
SourceChain: "ETHEREUM_SEPOLIA", DestChain: "LUX_TESTNET",
SourceAsset: "UNOBTAINIUM", DestAsset: "LUX", Amount: "1",
SourceAsset: "NOSUCHASSET", DestAsset: "LUX", Amount: "1",
}, &reply)
if code == 0 {
t.Fatalf("expected error, got success")
}
if !strings.Contains(msg, "price unknown") && !strings.Contains(msg, "UNOBTAINIUM") {
t.Errorf("expected price-unknown error, got %q", msg)
require.NotZero(t, code, "an unknown asset must not quote")
}
func TestRPC_EstimateFee_BadAmount(t *testing.T) {
srv, _ := newRPCRig(t)
var reply EstimateFeeReply
for _, amount := range []string{"", "-1", "0", "not-a-number"} {
code, _ := callRPC(t, srv.URL, "bridge_estimateFee", EstimateFeeArgs{
SourceChain: "ETHEREUM_SEPOLIA", DestChain: "LUX_TESTNET",
SourceAsset: "ETH", DestAsset: "LUX", Amount: amount,
}, &reply)
require.NotZero(t, code, "amount %q must be refused", amount)
}
}
@@ -130,52 +128,41 @@ func TestRPC_SubmitRequest_ThenGetStatus(t *testing.T) {
var sub SubmitRequestReply
code, msg := callRPC(t, srv.URL, "bridge_submitRequest", SubmitRequestArgs{
SourceChain: "ETHEREUM_SEPOLIA",
DestChain: "LUX_TESTNET",
SourceAsset: "ETH",
DestAsset: "LUX",
Amount: "1",
Recipient: "0xa28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473",
Sender: "0xa28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473",
SourceChain: "ETHEREUM_SEPOLIA", DestChain: "LUX_TESTNET",
SourceAsset: "ETH", DestAsset: "LUX", Amount: "2",
Recipient: "0xrecipient", Sender: "0xsender",
}, &sub)
if code != 0 {
t.Fatalf("submit rpc error: %d %s", code, msg)
}
if !strings.HasPrefix(sub.RequestID, "req_") {
t.Errorf("RequestID = %q, want req_ prefix", sub.RequestID)
}
if sub.Status != StatusPending {
t.Errorf("Status = %q, want pending", sub.Status)
}
if sub.NetAmount != "1400" {
t.Errorf("NetAmount snapshot = %q, want 1400", sub.NetAmount)
}
require.Zero(t, code, msg)
require.NotEmpty(t, sub.RequestID)
require.Equal(t, StatusPending, sub.Status)
var get GetStatusReply
code, msg = callRPC(t, srv.URL, "bridge_getStatus", GetStatusArgs{RequestID: sub.RequestID}, &get)
if code != 0 {
t.Fatalf("getStatus rpc error: %d %s", code, msg)
}
if get.RequestID != sub.RequestID {
t.Errorf("getStatus returned id=%q want %q", get.RequestID, sub.RequestID)
}
var status GetStatusReply
code, msg = callRPC(t, srv.URL, "bridge_getStatus", GetStatusArgs{RequestID: sub.RequestID}, &status)
require.Zero(t, code, msg)
require.Equal(t, sub.RequestID, status.RequestID)
require.Equal(t, sub.FeeAmount, status.FeeAmount)
}
func TestRPC_SubmitRequest_MissingFields(t *testing.T) {
srv, _ := newRPCRig(t)
var sub SubmitRequestReply
code, _ := callRPC(t, srv.URL, "bridge_submitRequest", SubmitRequestArgs{
DestChain: "LUX_TESTNET", Amount: "1", Recipient: "0xabc",
}, &sub)
require.NotZero(t, code, "a request naming no source chain must be refused")
}
func TestRPC_GetStatus_NotFound(t *testing.T) {
srv, _ := newRPCRig(t)
var get GetStatusReply
code, msg := callRPC(t, srv.URL, "bridge_getStatus", GetStatusArgs{RequestID: "req_nope"}, &get)
if code == 0 {
t.Fatalf("expected error, got success")
}
if !strings.Contains(msg, "not found") {
t.Errorf("error = %q, want 'not found'", msg)
}
var reply GetStatusReply
code, msg := callRPC(t, srv.URL, "bridge_getStatus", GetStatusArgs{RequestID: "req_nope"}, &reply)
require.NotZero(t, code)
require.Contains(t, msg, "not found")
}
func TestRPC_CancelRequest(t *testing.T) {
srv, _ := newRPCRig(t)
// Submit then cancel.
var sub SubmitRequestReply
_, _ = callRPC(t, srv.URL, "bridge_submitRequest", SubmitRequestArgs{
SourceChain: "ETHEREUM_SEPOLIA", DestChain: "LUX_TESTNET",
@@ -185,173 +172,158 @@ func TestRPC_CancelRequest(t *testing.T) {
var cancel CancelRequestReply
code, msg := callRPC(t, srv.URL, "bridge_cancelRequest", CancelRequestArgs{RequestID: sub.RequestID}, &cancel)
if code != 0 {
t.Fatalf("cancel rpc error: %d %s", code, msg)
}
if !cancel.Success {
t.Errorf("cancel.Success = false, want true")
}
require.Zero(t, code, msg)
require.True(t, cancel.Success)
// Confirm idempotent: second cancel still returns success.
// Cancelling a terminal swap again is a no-op success, so retries are safe.
code, msg = callRPC(t, srv.URL, "bridge_cancelRequest", CancelRequestArgs{RequestID: sub.RequestID}, &cancel)
if code != 0 || !cancel.Success {
t.Errorf("idempotent cancel failed: code=%d msg=%q success=%v", code, msg, cancel.Success)
}
require.Zero(t, code, msg)
require.True(t, cancel.Success)
code, _ = callRPC(t, srv.URL, "bridge_cancelRequest", CancelRequestArgs{RequestID: "req_nope"}, &cancel)
require.NotZero(t, code)
}
func TestRPC_Health(t *testing.T) {
srv, _ := newRPCRig(t)
var reply HealthReply
code, msg := callRPC(t, srv.URL, "bridge_health", nil, &reply)
if code != 0 {
t.Fatalf("health rpc error: %d %s", code, msg)
}
if reply.Status != "healthy" {
t.Errorf("Status = %q, want healthy", reply.Status)
}
require.Zero(t, code, msg)
require.Equal(t, "healthy", reply.Status)
require.False(t, reply.MPCReady, "no custody group key until M-Chain keygen lands")
}
// =============================================================================
// Discovery RPC tests
// Discovery RPC
// =============================================================================
// configureVMForDiscovery sets minimal config + registry state on the
// rig's VM so the discovery methods have non-empty data to surface.
// Centralized so test cases stay focused on the assertion, not setup.
func configureVMForDiscovery(vm *VM) {
vm.config.MPCThreshold = 67
vm.config.MPCTotalParties = 100
vm.config.MinConfirmations = 6
vm.config.SupportedChains = []string{"ETHEREUM_SEPOLIA", "LUX_TESTNET", "BTC_TESTNET"}
}
func TestRPC_GetInfo(t *testing.T) {
srv, vm := newRPCRig(t)
configureVMForDiscovery(vm)
var reply GetBridgeInfoReply
code, msg := callRPC(t, srv.URL, "bridge_getInfo", nil, &reply)
if code != 0 {
t.Fatalf("rpc error: %d %s", code, msg)
}
if reply.Version == "" {
t.Error("Version should be non-empty")
}
if reply.ChainID != "B" {
t.Errorf("ChainID = %q, want B", reply.ChainID)
}
if reply.Threshold != 67 {
t.Errorf("Threshold = %d, want 67", reply.Threshold)
}
if reply.TotalParties != 100 {
t.Errorf("TotalParties = %d, want 100", reply.TotalParties)
}
if len(reply.SupportedChains) != 3 {
t.Errorf("SupportedChains len = %d, want 3", len(reply.SupportedChains))
}
require.Zero(t, code, msg)
require.NotEmpty(t, reply.Version)
require.Equal(t, "B", reply.ChainID)
require.Len(t, reply.SupportedChains, 2)
// MPCReady is false because mpcConfig is nil in the rig (M-Chain keygen
// not completed) — the correct "MPC pending" state, not an error.
if reply.MPCReady {
t.Error("MPCReady should be false without a custody group key")
}
if reply.TotalBridged != "0" {
t.Errorf("TotalBridged = %q, want 0", reply.TotalBridged)
}
if reply.TotalFees != "0" {
t.Errorf("TotalFees = %q, want 0", reply.TotalFees)
}
require.False(t, reply.MPCReady)
require.Equal(t, "0", reply.TotalBridged)
require.Equal(t, "0", reply.TotalFees)
require.Zero(t, reply.Height)
// The threshold reported here is the signer set's, not a configured number
// beside it: one question, one answer.
require.NoError(t, registerSigner(vm, ids.GenerateTestNodeID()))
require.NoError(t, registerSigner(vm, ids.GenerateTestNodeID()))
require.NoError(t, registerSigner(vm, ids.GenerateTestNodeID()))
require.NoError(t, registerSigner(vm, ids.GenerateTestNodeID()))
code, msg = callRPC(t, srv.URL, "bridge_getInfo", nil, &reply)
require.Zero(t, code, msg)
require.Equal(t, 4, reply.TotalParties)
require.Equal(t, vm.GetSignerSetInfo().Threshold, reply.Threshold)
}
// TestRPC_GetInfoReportsWhatMoved reads the same durable counter the daily cap
// is enforced against, so the number a dashboard shows is the number the chain
// acts on.
func TestRPC_GetInfoReportsWhatMoved(t *testing.T) {
srv, vm := newRPCRig(t)
pend(vm, requestFor(1, 4_242))
blk := buildAndAccept(t, vm)
var reply GetBridgeInfoReply
code, msg := callRPC(t, srv.URL, "bridge_getInfo", nil, &reply)
require.Zero(t, code, msg)
require.Equal(t, "4242", reply.TotalBridged)
require.Equal(t, uint64(1), reply.Height)
require.Equal(t, blk.BlockHeight, reply.Height)
}
func TestRPC_GetSupportedChains(t *testing.T) {
srv, vm := newRPCRig(t)
configureVMForDiscovery(vm)
var reply GetSupportedChainsReply
code, msg := callRPC(t, srv.URL, "bridge_getSupportedChains", nil, &reply)
if code != 0 {
t.Fatalf("rpc error: %d %s", code, msg)
}
if len(reply.Chains) != 3 {
t.Fatalf("Chains len = %d, want 3", len(reply.Chains))
require.Zero(t, code, msg)
require.Len(t, reply.Chains, len(vm.config.ExternalChains))
for i, c := range reply.Chains {
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)
}
}
// The chains this bridge says it serves are the chains it can actually route
// to. A separate list declared beside the routing table could name a chain
// with no client behind it.
func TestRPC_SupportedChainsAreTheRoutableOnes(t *testing.T) {
srv, vm := newRPCRig(t)
var reply GetSupportedChainsReply
code, msg := callRPC(t, srv.URL, "bridge_getSupportedChains", nil, &reply)
require.Zero(t, code, msg)
for _, c := range reply.Chains {
if c.ChainID == "" {
t.Error("ChainID empty in result")
}
if !c.Enabled {
t.Errorf("chain %s not Enabled, want true", c.ChainID)
}
if c.Confirmations != 6 {
t.Errorf("chain %s Confirmations = %d, want 6", c.ChainID, c.Confirmations)
id, err := strconv.ParseUint(c.ChainID, 10, 32)
require.NoError(t, err)
found := false
for _, cfg := range vm.config.ExternalChains {
found = found || cfg.ChainID == id
}
require.True(t, found, "chain %s is advertised but not one the release path routes by", c.ChainID)
}
}
func TestRPC_GetChainConfig(t *testing.T) {
srv, vm := newRPCRig(t)
configureVMForDiscovery(vm)
srv, _ := newRPCRig(t)
// happy path: lowercase input matches case-insensitively
// By name, case-insensitively.
var reply GetChainConfigReply
code, msg := callRPC(t, srv.URL, "bridge_getChainConfig",
GetChainConfigArgs{ChainID: "ethereum_sepolia"}, &reply)
if code != 0 {
t.Fatalf("rpc error: %d %s", code, msg)
}
if !strings.EqualFold(reply.ChainID, "ETHEREUM_SEPOLIA") {
t.Errorf("ChainID = %q, want ETHEREUM_SEPOLIA (case-insensitive match)", reply.ChainID)
}
if !reply.Enabled {
t.Error("Enabled = false, want true")
}
GetChainConfigArgs{ChainID: "ZOO-TESTNET"}, &reply)
require.Zero(t, code, msg)
require.Equal(t, "zoo-testnet", reply.ChainName)
require.True(t, reply.Enabled)
// unknown chain: error surfaces the requested id so daemons can log it
// And by the numeric id the transfers actually carry.
code, msg = callRPC(t, srv.URL, "bridge_getChainConfig",
GetChainConfigArgs{ChainID: strconv.FormatUint(uint64(dstChain), 10)}, &reply)
require.Zero(t, code, msg)
require.Equal(t, "zoo-testnet", reply.ChainName)
// Unknown chain: the error names the requested id so daemons can log it.
code, msg = callRPC(t, srv.URL, "bridge_getChainConfig",
GetChainConfigArgs{ChainID: "UNOBTAINIUM_CHAIN"}, &reply)
if code == 0 {
t.Fatal("expected error for unknown chain, got success")
}
if !strings.Contains(msg, "UNOBTAINIUM_CHAIN") {
t.Errorf("error %q should name the missing chain", msg)
}
require.NotZero(t, code)
require.Contains(t, msg, "UNOBTAINIUM_CHAIN")
// empty chainId: explicit invalid-params surface
code, msg = callRPC(t, srv.URL, "bridge_getChainConfig",
GetChainConfigArgs{ChainID: ""}, &reply)
if code == 0 {
t.Fatal("expected error for empty chainId")
}
if !strings.Contains(msg, "chainId required") {
t.Errorf("error %q should say chainId required", msg)
}
require.NotZero(t, code)
require.Contains(t, msg, "chainId required")
}
func TestRPC_GetSignature_NotYetAvailable(t *testing.T) {
srv, _ := newRPCRig(t)
// The KMS path a relayer key lives at is not something an unauthenticated
// surface answers with.
func TestRPC_ChainConfigNamesNoSecretLocation(t *testing.T) {
srv, vm := newRPCRig(t)
vm.config.ExternalChains[0].GasKeyKMSPath = "kms://bridge/relayer/lux"
// Submit a swap so it exists in the store, but with no signature.
var sub SubmitRequestReply
_, _ = callRPC(t, srv.URL, "bridge_submitRequest", SubmitRequestArgs{
SourceChain: "ETHEREUM_SEPOLIA", DestChain: "LUX_TESTNET",
SourceAsset: "ETH", DestAsset: "LUX", Amount: "1",
Recipient: "0xabc", Sender: "0xabc",
}, &sub)
var reply GetSignatureReply
code, msg := callRPC(t, srv.URL, "bridge_getSignature",
GetSignatureArgs{RequestID: sub.RequestID}, &reply)
if code == 0 {
t.Fatal("expected error (signature not yet available), got success")
}
if !strings.Contains(msg, "not yet available") {
t.Errorf("error %q should say 'not yet available'", msg)
}
resp, err := http.Post(srv.URL+"/rpc", "application/json",
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"bridge_getSupportedChains"}`))
require.NoError(t, err)
defer resp.Body.Close()
buf := new(bytes.Buffer)
_, err = buf.ReadFrom(resp.Body)
require.NoError(t, err)
require.NotContains(t, buf.String(), "kms://")
}
func TestRPC_GetSignature_AfterSign(t *testing.T) {
func TestRPC_GetSignature(t *testing.T) {
srv, vm := newRPCRig(t)
// Submit
var sub SubmitRequestReply
_, _ = callRPC(t, srv.URL, "bridge_submitRequest", SubmitRequestArgs{
SourceChain: "ETHEREUM_SEPOLIA", DestChain: "LUX_TESTNET",
@@ -359,38 +331,185 @@ func TestRPC_GetSignature_AfterSign(t *testing.T) {
Recipient: "0xabc", Sender: "0xabc",
}, &sub)
// Simulate the MPC quorum populating the signature via Patch.
// Still signing: a caller can poll without confusing this with "no such
// swap".
var reply GetSignatureReply
code, msg := callRPC(t, srv.URL, "bridge_getSignature", GetSignatureArgs{RequestID: sub.RequestID}, &reply)
require.NotZero(t, code)
require.Contains(t, msg, "not yet available")
const sig = "deadbeefcafe1234"
if _, err := vm.swapStore.Patch(sub.RequestID, func(r *BridgeRequestRecord) {
_, err := vm.swapStore.Patch(sub.RequestID, func(r *BridgeRequestRecord) {
r.Signature = sig
r.Status = StatusSigned
}); err != nil {
t.Fatalf("Patch: %v", err)
}
})
require.NoError(t, err)
var reply GetSignatureReply
code, msg := callRPC(t, srv.URL, "bridge_getSignature",
GetSignatureArgs{RequestID: sub.RequestID}, &reply)
if code != 0 {
t.Fatalf("rpc error: %d %s", code, msg)
}
if reply.Signature != sig {
t.Errorf("Signature = %q, want %q", reply.Signature, sig)
}
if reply.SessionID != sub.RequestID {
t.Errorf("SessionID = %q, want %q", reply.SessionID, sub.RequestID)
}
code, msg = callRPC(t, srv.URL, "bridge_getSignature", GetSignatureArgs{RequestID: sub.RequestID}, &reply)
require.Zero(t, code, msg)
require.Equal(t, sig, reply.Signature)
require.Equal(t, sub.RequestID, reply.SessionID)
code, msg = callRPC(t, srv.URL, "bridge_getSignature", GetSignatureArgs{RequestID: "req_does_not_exist"}, &reply)
require.NotZero(t, code)
require.Contains(t, msg, "not found")
}
func TestRPC_GetSignature_NotFound(t *testing.T) {
func TestRPC_GetMPCPublicKey(t *testing.T) {
srv, _ := newRPCRig(t)
var reply GetSignatureReply
code, msg := callRPC(t, srv.URL, "bridge_getSignature",
GetSignatureArgs{RequestID: "req_does_not_exist"}, &reply)
if code == 0 {
t.Fatal("expected error for missing swap")
var reply GetMPCPublicKeyReply
code, msg := callRPC(t, srv.URL, "bridge_getMPCPublicKey", nil, &reply)
require.NotZero(t, code, "a chain with no group key must say so rather than answer with nothing")
require.Contains(t, msg, "not yet established")
}
// =============================================================================
// Signer-set RPC
// =============================================================================
func TestRPC_SignerSetLifecycle(t *testing.T) {
srv, _ := newRPCRig(t)
node := ids.GenerateTestNodeID()
bond := strconv.FormatUint(minValidatorBond, 10)
var reg RegisterValidatorReply
code, msg := callRPC(t, srv.URL, "bridge_registerValidator",
RegisterValidatorArgs{NodeID: node.String(), BondAmount: bond, MPCPubKey: "0xpub"}, &reg)
require.Zero(t, code, msg)
require.True(t, reg.Registered)
var has HasSignerReply
code, msg = callRPC(t, srv.URL, "bridge_hasSigner", HasSignerArgs{NodeID: node.String()}, &has)
require.Zero(t, code, msg)
require.True(t, has.IsSigner)
var info GetSignerSetInfoReply
code, msg = callRPC(t, srv.URL, "bridge_getSignerSetInfo", nil, &info)
require.Zero(t, code, msg)
require.Equal(t, 1, info.TotalSigners)
require.Equal(t, node.String(), info.Signers[0].NodeID)
require.Equal(t, minValidatorBond, info.Signers[0].BondAmount)
var epoch GetCurrentEpochReply
code, msg = callRPC(t, srv.URL, "bridge_getCurrentEpoch", nil, &epoch)
require.Zero(t, code, msg)
require.Zero(t, epoch.Epoch)
require.Equal(t, 1, epoch.TotalSigners)
// A second validator lands on the set, then gets waitlisted once it freezes.
waiting := ids.GenerateTestNodeID()
code, msg = callRPC(t, srv.URL, "bridge_registerValidator",
RegisterValidatorArgs{NodeID: waiting.String(), BondAmount: bond}, &reg)
require.Zero(t, code, msg)
var slash SlashSignerReply
code, msg = callRPC(t, srv.URL, "bridge_slashSigner",
SlashSignerArgs{NodeID: node.String(), Reason: "equivocation", SlashPercent: 10, Evidence: "0x01"}, &slash)
require.Zero(t, code, msg)
require.True(t, slash.Success)
require.True(t, slash.RemovedFromSet, "a bond below the requirement is not a bond")
var replace ReplaceSignerReply
code, msg = callRPC(t, srv.URL, "bridge_replaceSigner",
ReplaceSignerArgs{NodeID: waiting.String()}, &replace)
require.Zero(t, code, msg)
require.True(t, replace.Success)
require.Equal(t, uint64(2), replace.NewEpoch)
var wl GetWaitlistReply
code, msg = callRPC(t, srv.URL, "bridge_getWaitlist", nil, &wl)
require.Zero(t, code, msg)
require.Zero(t, wl.WaitlistSize)
}
func TestRPC_SignerMethodsRefuseABadNodeID(t *testing.T) {
srv, _ := newRPCRig(t)
for _, method := range []string{"bridge_hasSigner", "bridge_replaceSigner", "bridge_slashSigner", "bridge_registerValidator"} {
code, _ := callRPC(t, srv.URL, method, map[string]any{"nodeId": "not-a-node-id", "slashPercent": 5}, nil)
require.NotZero(t, code, "%s accepted a malformed node id", method)
}
if !strings.Contains(msg, "not found") {
t.Errorf("error %q should say 'not found'", msg)
code, _ := callRPC(t, srv.URL, "bridge_replaceSigner",
map[string]any{"nodeId": ids.GenerateTestNodeID().String(), "replacementNodeId": "not-a-node-id"}, nil)
require.NotZero(t, code)
}
// =============================================================================
// The envelope itself
// =============================================================================
func TestRPC_UnknownMethod(t *testing.T) {
srv, _ := newRPCRig(t)
code, msg := callRPC(t, srv.URL, "bridge_doWhateverIWant", nil, nil)
require.Equal(t, -32601, code)
require.Contains(t, msg, "method not found")
}
func TestRPC_MalformedParamsAndBody(t *testing.T) {
srv, _ := newRPCRig(t)
// Every method that takes arguments must refuse arguments it cannot read.
for _, method := range []string{
"bridge_registerValidator", "bridge_replaceSigner", "bridge_hasSigner",
"bridge_slashSigner", "bridge_estimateFee", "bridge_submitRequest",
"bridge_getStatus", "bridge_cancelRequest", "bridge_getChainConfig",
"bridge_getSignature",
} {
body := []byte(`{"jsonrpc":"2.0","id":1,"method":"` + method + `","params":"not-an-object"}`)
code, _ := postRPC(t, srv.URL, body, nil)
require.Equal(t, -32602, code, "%s accepted params it cannot decode", method)
}
code, _ := postRPC(t, srv.URL, []byte(`{not json`), nil)
require.Equal(t, -32700, code)
}
func TestRPC_DottedMethodNamesAreTheSameMethods(t *testing.T) {
srv, _ := newRPCRig(t)
for _, method := range []string{
"bridge.getSignerSetInfo", "bridge.getWaitlist", "bridge.getCurrentEpoch",
"bridge.health", "bridge.getInfo", "bridge.getSupportedChains",
} {
code, msg := callRPC(t, srv.URL, method, nil, nil)
require.Zero(t, code, "%s: %s", method, msg)
}
}
func TestRPC_OnlyPostIsAnswered(t *testing.T) {
srv, _ := newRPCRig(t)
resp, err := http.Get(srv.URL + "/rpc")
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
}
// A Service with no VM behind it answers with an error rather than a panic.
func TestRPC_ServiceWithoutAVM(t *testing.T) {
s := &Service{}
require.Error(t, s.EstimateFee(nil, &EstimateFeeArgs{Amount: "1"}, &EstimateFeeReply{}))
require.Error(t, s.SubmitRequest(nil, &SubmitRequestArgs{}, &SubmitRequestReply{}))
require.Error(t, s.GetStatus(nil, &GetStatusArgs{}, &GetStatusReply{}))
require.Error(t, s.CancelRequest(nil, &CancelRequestArgs{}, &CancelRequestReply{}))
require.Error(t, s.GetBridgeInfo(nil, &GetBridgeInfoArgs{}, &GetBridgeInfoReply{}))
require.Error(t, s.GetSupportedChains(nil, &GetSupportedChainsArgs{}, &GetSupportedChainsReply{}))
require.Error(t, s.GetChainConfig(nil, &GetChainConfigArgs{ChainID: "x"}, &GetChainConfigReply{}))
require.Error(t, s.GetSignature(nil, &GetSignatureArgs{}, &GetSignatureReply{}))
require.Error(t, s.GetMPCPublicKey(nil, &GetMPCPublicKeyArgs{}, &GetMPCPublicKeyReply{}))
var health HealthReply
require.NoError(t, s.Health(nil, &HealthArgs{}, &health))
require.Equal(t, "no vm", health.Status)
}
func TestHexEncode(t *testing.T) {
require.Equal(t, "", hexEncode(nil))
require.Equal(t, "00ff1a", hexEncode([]byte{0x00, 0xff, 0x1a}))
}
// registerSigner adds one signer with a bond that meets the requirement.
func registerSigner(vm *VM, node ids.NodeID) error {
_, err := vm.RegisterValidator(&RegisterValidatorInput{
NodeID: node.String(),
BondAmount: strconv.FormatUint(minValidatorBond, 10),
})
return err
}
+248 -386
View File
@@ -4,487 +4,349 @@
package bridgevm
import (
"strconv"
"testing"
"github.com/luxfi/ids"
"github.com/stretchr/testify/require"
"github.com/luxfi/database/memdb"
"github.com/luxfi/ids"
)
const (
// MinBridgeBond is the minimum bond required for bridge validators (1M LUX)
MinBridgeBond = 1_000_000 * 1e9
)
// signerVM is a VM with a signer set of the given size, booted the way the node
// boots one.
func signerVM(t *testing.T, maxSigners int) *VM {
t.Helper()
cfg := testConfig()
cfg.MaxSigners = maxSigners
return bootOn(t, memdb.New(), cfg)
}
// TestSignerSetOptInRegistration tests LP-333 opt-in registration
// First 100 validators should register WITHOUT triggering reshare
func bondOf(v uint64) string { return strconv.FormatUint(v, 10) }
// TestSignerSetOptInRegistration: the first MaxSigners validators register
// without a reshare and the epoch does not move (LP-333).
func TestSignerSetOptInRegistration(t *testing.T) {
require := require.New(t)
vm := signerVM(t, 100)
vm := &VM{
config: BridgeConfig{
MaxSigners: 100,
ThresholdRatio: 0.67,
},
signerSet: &SignerSet{
Signers: make([]*SignerInfo, 0, 100),
Waitlist: make([]ids.NodeID, 0),
CurrentEpoch: 0,
SetFrozen: false,
ThresholdT: 0,
},
}
// Register first 10 validators - should NOT trigger reshare
for i := 0; i < 10; i++ {
nodeID := ids.GenerateTestNodeID()
input := &RegisterValidatorInput{
NodeID: nodeID.String(),
}
result, err := vm.RegisterValidator(input)
result, err := vm.RegisterValidator(&RegisterValidatorInput{
NodeID: ids.GenerateTestNodeID().String(),
BondAmount: bondOf(minValidatorBond),
})
require.NoError(err)
require.True(result.Success)
require.True(result.Registered)
require.False(result.Waitlisted)
require.False(result.ReshareNeeded) // LP-333: NO reshare on opt-in
require.Equal(uint64(0), result.CurrentEpoch) // Epoch should NOT change
require.False(result.ReshareNeeded)
require.Equal(uint64(0), result.CurrentEpoch)
require.Equal(i, result.SignerIndex)
}
// Verify signer set state
require.Equal(10, len(vm.signerSet.Signers))
require.Equal(uint64(0), vm.signerSet.CurrentEpoch) // Still epoch 0
require.Len(vm.signerSet.Signers, 10)
require.Equal(uint64(0), vm.signerSet.CurrentEpoch)
require.False(vm.signerSet.SetFrozen)
// Threshold should be floor(10 * 0.67) = 6
// ⌈2·10/3⌉ = 7 signers required, so t = 6.
require.Equal(6, vm.signerSet.ThresholdT)
}
// TestSignerSetFreezeAt100 tests that set freezes at 100 signers
// TestABondBelowTheRequirementIsNotASigner. The chain declared a bond and read
// the field without ever comparing it: a validator claiming nothing at all
// took a slot in the set that authorises releases.
func TestABondBelowTheRequirementIsNotASigner(t *testing.T) {
vm := signerVM(t, 100)
for _, bond := range []string{"", "0", "1", bondOf(minValidatorBond - 1), "not-a-number"} {
_, err := vm.RegisterValidator(&RegisterValidatorInput{
NodeID: ids.GenerateTestNodeID().String(),
BondAmount: bond,
})
require.Error(t, err, "bond %q was admitted", bond)
}
require.Empty(t, vm.signerSet.Signers)
_, err := vm.RegisterValidator(&RegisterValidatorInput{
NodeID: ids.GenerateTestNodeID().String(),
BondAmount: bondOf(minValidatorBond),
})
require.NoError(t, err)
require.Len(t, vm.signerSet.Signers, 1)
}
// TestQuorumIsExactAtEverySmallSize. The threshold was int(n * 0.67): a float
// rounding down. At n=3 it asked for all three — a set that cannot lose a
// single signer and tolerates no Byzantine one — and at n=1 it clamped to
// asking two of one, which nothing can satisfy.
func TestQuorumIsExactAtEverySmallSize(t *testing.T) {
// n: how many of n must agree
want := map[int]int{0: 0, 1: 1, 2: 2, 3: 2, 4: 3, 5: 4, 6: 4, 7: 5, 100: 67}
for n, q := range want {
require.Equal(t, q, quorum(n), "quorum(%d)", n)
}
// And the two properties those numbers exist for, at every size a signer
// set can reach: a quorum is reachable, it is a two-thirds majority, and
// two quorums overlap in more signers than can be faulty — which is what
// makes two of them unable to decide different things.
for n := 1; n <= 512; n++ {
q := quorum(n)
faulty := (n - 1) / 3
require.Positive(t, q, "quorum(%d) asks for nobody", n)
require.LessOrEqual(t, q, n, "quorum(%d) asks for more signers than exist", n)
require.GreaterOrEqual(t, 3*q, 2*n, "quorum(%d) is under a two-thirds majority", n)
require.Greater(t, 2*q-n, faulty, "two quorums of %d out of %d can be disjoint in the honest signers", q, n)
}
}
// TestThresholdFollowsTheSetSize keeps the reported threshold and the set in
// step through joins and removals.
func TestThresholdFollowsTheSetSize(t *testing.T) {
vm := signerVM(t, 100)
nodes := make([]ids.NodeID, 0, 7)
for i := 1; i <= 7; i++ {
node := ids.GenerateTestNodeID()
nodes = append(nodes, node)
require.NoError(t, registerSigner(vm, node))
require.Equal(t, quorum(i)-1, vm.signerSet.ThresholdT, "after %d signers", i)
}
_, err := vm.RemoveSigner(nodes[0], nil)
require.NoError(t, err)
require.Equal(t, quorum(6)-1, vm.signerSet.ThresholdT)
}
// TestSignerSetFreezeAt100 tests that the set freezes at MaxSigners.
func TestSignerSetFreezeAt100(t *testing.T) {
require := require.New(t)
vm := signerVM(t, 5)
vm := &VM{
config: BridgeConfig{
MaxSigners: 5, // Use small number for test
ThresholdRatio: 0.67,
},
signerSet: &SignerSet{
Signers: make([]*SignerInfo, 0, 5),
Waitlist: make([]ids.NodeID, 0),
CurrentEpoch: 0,
SetFrozen: false,
ThresholdT: 0,
},
}
// Register up to max signers
for i := 0; i < 5; i++ {
nodeID := ids.GenerateTestNodeID()
input := &RegisterValidatorInput{
NodeID: nodeID.String(),
}
result, err := vm.RegisterValidator(input)
result, err := vm.RegisterValidator(&RegisterValidatorInput{
NodeID: ids.GenerateTestNodeID().String(),
BondAmount: bondOf(minValidatorBond),
})
require.NoError(err)
require.True(result.Success)
require.True(result.Registered)
require.False(result.Waitlisted)
}
// Set should now be frozen
require.True(vm.signerSet.SetFrozen)
require.Equal(5, len(vm.signerSet.Signers))
// Next registration should go to waitlist
nodeID := ids.GenerateTestNodeID()
input := &RegisterValidatorInput{
NodeID: nodeID.String(),
}
result, err := vm.RegisterValidator(input)
waiting := ids.GenerateTestNodeID()
result, err := vm.RegisterValidator(&RegisterValidatorInput{
NodeID: waiting.String(),
BondAmount: bondOf(minValidatorBond),
})
require.NoError(err)
require.True(result.Success)
require.False(result.Registered)
require.True(result.Waitlisted)
require.Equal(0, result.WaitlistIndex)
require.Equal(uint64(0), result.CurrentEpoch, "a waitlist entry is not a reshare")
// Asking twice from the waitlist is not two places in the queue.
result, err = vm.RegisterValidator(&RegisterValidatorInput{
NodeID: waiting.String(),
BondAmount: bondOf(minValidatorBond),
})
require.NoError(err)
require.False(result.Success)
require.True(result.Waitlisted)
require.Len(vm.signerSet.Waitlist, 1)
}
// TestRemoveSignerTriggersReshare tests LP-333 reshare on removal
// RemoveSigner is the ONLY operation that triggers reshare
// TestRemoveSignerTriggersReshare: removal is the ONLY thing that moves the
// epoch (LP-333).
func TestRemoveSignerTriggersReshare(t *testing.T) {
require := require.New(t)
vm := signerVM(t, 100)
// Setup VM with some signers
nodeID1 := ids.GenerateTestNodeID()
nodeID2 := ids.GenerateTestNodeID()
nodeID3 := ids.GenerateTestNodeID()
vm := &VM{
config: BridgeConfig{
MaxSigners: 100,
ThresholdRatio: 0.67,
},
signerSet: &SignerSet{
Signers: []*SignerInfo{
{NodeID: nodeID1, SlotIndex: 0, Active: true},
{NodeID: nodeID2, SlotIndex: 1, Active: true},
{NodeID: nodeID3, SlotIndex: 2, Active: true},
},
Waitlist: make([]ids.NodeID, 0),
CurrentEpoch: 0,
SetFrozen: false,
ThresholdT: 2,
},
nodes := make([]ids.NodeID, 0, 3)
for i := 0; i < 3; i++ {
node := ids.GenerateTestNodeID()
nodes = append(nodes, node)
require.NoError(registerSigner(vm, node))
}
// Verify initial state
require.Equal(3, len(vm.signerSet.Signers))
require.Equal(uint64(0), vm.signerSet.CurrentEpoch)
// Remove a signer - THIS should trigger reshare and increment epoch
result, err := vm.RemoveSigner(nodeID2, nil)
result, err := vm.RemoveSigner(nodes[1], nil)
require.NoError(err)
require.True(result.Success)
require.Equal(nodeID2.String(), result.RemovedNodeID)
require.Equal(uint64(1), result.NewEpoch) // Epoch incremented!
require.Equal(uint64(1), result.NewEpoch)
require.Equal(2, result.ActiveSigners)
require.Equal(nodes[1].String(), result.RemovedNodeID)
require.False(vm.HasSigner(nodes[1]))
// Verify epoch incremented
// Removing someone who is not in the set changes nothing.
result, err = vm.RemoveSigner(ids.GenerateTestNodeID(), nil)
require.NoError(err)
require.False(result.Success)
require.Equal(uint64(1), vm.signerSet.CurrentEpoch)
}
// TestRemoveSignerWithWaitlistReplacement tests automatic replacement from waitlist
func TestRemoveSignerWithWaitlistReplacement(t *testing.T) {
// TestReplacementTakesTheVacatedSlot. The removed signer was spliced out and
// the replacement appended at the end, which moved every signer above it and
// left the stored slot numbers naming positions nobody held.
func TestReplacementTakesTheVacatedSlot(t *testing.T) {
require := require.New(t)
vm := signerVM(t, 100)
nodeID1 := ids.GenerateTestNodeID()
nodeID2 := ids.GenerateTestNodeID()
waitlistNode := ids.GenerateTestNodeID()
vm := &VM{
config: BridgeConfig{
MaxSigners: 100,
ThresholdRatio: 0.67,
},
signerSet: &SignerSet{
Signers: []*SignerInfo{
{NodeID: nodeID1, SlotIndex: 0, Active: true},
{NodeID: nodeID2, SlotIndex: 1, Active: true},
},
Waitlist: []ids.NodeID{waitlistNode},
CurrentEpoch: 0,
SetFrozen: true, // Set is frozen
ThresholdT: 1,
},
nodes := make([]ids.NodeID, 0, 4)
for i := 0; i < 4; i++ {
node := ids.GenerateTestNodeID()
nodes = append(nodes, node)
require.NoError(registerSigner(vm, node))
}
replacement := ids.GenerateTestNodeID()
// Remove signer - should be replaced from waitlist
result, err := vm.RemoveSigner(nodeID1, nil)
result, err := vm.RemoveSigner(nodes[1], &replacement)
require.NoError(err)
require.True(result.Success)
require.Equal(nodeID1.String(), result.RemovedNodeID)
require.Equal(waitlistNode.String(), result.ReplacementNodeID)
require.Equal(uint64(1), result.NewEpoch)
require.Equal(2, result.ActiveSigners) // Still 2 (replaced)
require.Contains(result.Message, "waitlist")
require.Equal(replacement.String(), result.ReplacementNodeID)
require.NotEmpty(result.ReshareSession)
require.Equal(4, result.ActiveSigners)
// Waitlist should now be empty
require.Empty(vm.signerSet.Waitlist)
}
// TestHasSigner tests the HasSigner helper
func TestHasSigner(t *testing.T) {
require := require.New(t)
nodeID1 := ids.GenerateTestNodeID()
nodeID2 := ids.GenerateTestNodeID()
nodeIDNotInSet := ids.GenerateTestNodeID()
vm := &VM{
signerSet: &SignerSet{
Signers: []*SignerInfo{
{NodeID: nodeID1, SlotIndex: 0, Active: true},
{NodeID: nodeID2, SlotIndex: 1, Active: true},
},
},
require.Equal(replacement, vm.signerSet.Signers[1].NodeID, "the replacement takes the vacated slot")
for i, signer := range vm.signerSet.Signers {
require.Equal(i, signer.SlotIndex, "slot %d names a position its signer does not hold", i)
}
require.True(vm.HasSigner(nodeID1))
require.True(vm.HasSigner(nodeID2))
require.False(vm.HasSigner(nodeIDNotInSet))
}
// TestGetSignerSetInfo tests signer set info retrieval
// TestRemoveSignerWithWaitlistReplacement: with nobody named, the waitlist is
// where the replacement comes from.
func TestRemoveSignerWithWaitlistReplacement(t *testing.T) {
require := require.New(t)
vm := signerVM(t, 2)
first, second := ids.GenerateTestNodeID(), ids.GenerateTestNodeID()
require.NoError(registerSigner(vm, first))
require.NoError(registerSigner(vm, second))
waiting := ids.GenerateTestNodeID()
require.NoError(registerSigner(vm, waiting))
require.Len(vm.signerSet.Waitlist, 1)
result, err := vm.RemoveSigner(first, nil)
require.NoError(err)
require.Equal(waiting.String(), result.ReplacementNodeID)
require.Empty(vm.signerSet.Waitlist)
require.True(vm.HasSigner(waiting))
}
// TestHasSigner
func TestHasSigner(t *testing.T) {
vm := signerVM(t, 100)
node := ids.GenerateTestNodeID()
require.False(t, vm.HasSigner(node))
require.NoError(t, registerSigner(vm, node))
require.True(t, vm.HasSigner(node))
require.False(t, vm.HasSigner(ids.GenerateTestNodeID()))
}
func TestGetSignerSetInfo(t *testing.T) {
require := require.New(t)
nodeID1 := ids.GenerateTestNodeID()
nodeID2 := ids.GenerateTestNodeID()
vm := &VM{
config: BridgeConfig{
MaxSigners: 100,
ThresholdRatio: 0.67,
},
signerSet: &SignerSet{
Signers: []*SignerInfo{
{NodeID: nodeID1, SlotIndex: 0, Active: true},
{NodeID: nodeID2, SlotIndex: 1, Active: true},
},
Waitlist: []ids.NodeID{ids.GenerateTestNodeID()},
CurrentEpoch: 5,
SetFrozen: false,
ThresholdT: 1,
PublicKey: []byte{0x01, 0x02, 0x03},
},
}
vm := signerVM(t, 10)
info := vm.GetSignerSetInfo()
require.Equal(2, info.TotalSigners)
require.Equal(1, info.Threshold)
require.Equal(100, info.MaxSigners)
require.Equal(uint64(5), info.CurrentEpoch)
require.False(info.SetFrozen)
require.Equal(98, info.RemainingSlots) // 100 - 2
require.Equal(1, info.WaitlistSize)
require.Equal(2, len(info.Signers))
require.Equal("010203", info.PublicKey)
require.Zero(info.TotalSigners)
require.Equal(10, info.MaxSigners)
require.Equal(10, info.RemainingSlots)
require.Empty(info.PublicKey)
for i := 0; i < 3; i++ {
require.NoError(registerSigner(vm, ids.GenerateTestNodeID()))
}
vm.signerSet.PublicKey = []byte{0xde, 0xad}
info = vm.GetSignerSetInfo()
require.Equal(3, info.TotalSigners)
require.Equal(7, info.RemainingSlots)
require.Equal(quorum(3)-1, info.Threshold)
require.Equal("dead", info.PublicKey)
require.Len(info.Signers, 3)
}
// TestDuplicateRegistration tests that duplicate registration is rejected
// TestDuplicateRegistration: one validator, one slot.
func TestDuplicateRegistration(t *testing.T) {
require := require.New(t)
vm := signerVM(t, 100)
node := ids.GenerateTestNodeID()
nodeID := ids.GenerateTestNodeID()
vm := &VM{
config: BridgeConfig{
MaxSigners: 100,
ThresholdRatio: 0.67,
},
signerSet: &SignerSet{
Signers: []*SignerInfo{
{NodeID: nodeID, SlotIndex: 0, Active: true},
},
Waitlist: make([]ids.NodeID, 0),
CurrentEpoch: 0,
SetFrozen: false,
ThresholdT: 1,
},
}
// Try to register same node again
input := &RegisterValidatorInput{
NodeID: nodeID.String(),
}
result, err := vm.RegisterValidator(input)
require.NoError(registerSigner(vm, node))
result, err := vm.RegisterValidator(&RegisterValidatorInput{
NodeID: node.String(),
BondAmount: bondOf(minValidatorBond),
})
require.NoError(err)
require.False(result.Success)
require.Contains(result.Message, "already registered")
require.Len(vm.signerSet.Signers, 1)
_, err = vm.RegisterValidator(&RegisterValidatorInput{
NodeID: "not-a-node-id",
BondAmount: bondOf(minValidatorBond),
})
require.Error(err)
}
// TestThresholdCalculation tests that threshold is calculated correctly
func TestThresholdCalculation(t *testing.T) {
require := require.New(t)
vm := &VM{
config: BridgeConfig{
MaxSigners: 100,
ThresholdRatio: 0.67,
},
signerSet: &SignerSet{
Signers: make([]*SignerInfo, 0, 100),
Waitlist: make([]ids.NodeID, 0),
CurrentEpoch: 0,
SetFrozen: false,
ThresholdT: 0,
},
}
testCases := []struct {
numSigners int
expectedThreshold int
}{
{1, 1}, // floor(1 * 0.67) = 0, but minimum is 1
{2, 1}, // floor(2 * 0.67) = 1
{3, 2}, // floor(3 * 0.67) = 2
{10, 6}, // floor(10 * 0.67) = 6
{100, 67}, // floor(100 * 0.67) = 67
}
for _, tc := range testCases {
// Reset signer set
vm.signerSet.Signers = make([]*SignerInfo, 0, 100)
vm.signerSet.ThresholdT = 0
// Add signers
for i := 0; i < tc.numSigners; i++ {
nodeID := ids.GenerateTestNodeID()
input := &RegisterValidatorInput{
NodeID: nodeID.String(),
}
_, err := vm.RegisterValidator(input)
require.NoError(err)
}
require.Equal(tc.expectedThreshold, vm.signerSet.ThresholdT,
"threshold mismatch for %d signers", tc.numSigners)
}
}
// TestSlashSignerPartial tests partial slashing of a signer's bond
func TestSlashSignerPartial(t *testing.T) {
require := require.New(t)
vm := signerVM(t, 100)
node := ids.GenerateTestNodeID()
nodeID := ids.GenerateTestNodeID()
initialBond := uint64(1_500_000 * 1e9) // 1.5M LUX bond
// A bond well above the requirement, so a small slash leaves it a signer.
_, err := vm.RegisterValidator(&RegisterValidatorInput{
NodeID: node.String(),
BondAmount: bondOf(2 * minValidatorBond),
})
require.NoError(err)
vm := &VM{
config: BridgeConfig{
MaxSigners: 100,
ThresholdRatio: 0.67,
},
signerSet: &SignerSet{
Signers: []*SignerInfo{
{
NodeID: nodeID,
SlotIndex: 0,
Active: true,
BondAmount: initialBond,
Slashed: false,
SlashCount: 0,
},
},
CurrentEpoch: 0,
ThresholdT: 1,
},
}
// Slash 10% of bond
input := &SlashSignerInput{
NodeID: nodeID,
Reason: "failed to sign",
SlashPercent: 10,
Evidence: []byte("proof"),
}
result, err := vm.SlashSigner(input)
result, err := vm.SlashSigner(&SlashSignerInput{
NodeID: node, Reason: "equivocation", SlashPercent: 10, Evidence: []byte("proof"),
})
require.NoError(err)
require.True(result.Success)
require.Equal(nodeID.String(), result.NodeID)
require.Equal(initialBond/10, result.SlashedAmount) // 150K LUX slashed
require.Equal(initialBond-initialBond/10, result.RemainingBond) // 1.35M remaining
require.Equal(2*minValidatorBond/10, result.SlashedAmount)
require.Equal(2*minValidatorBond-2*minValidatorBond/10, result.RemainingBond)
require.Equal(1, result.TotalSlashCount)
require.False(result.RemovedFromSet) // Still above 1M minimum
// Verify signer state
require.False(result.RemovedFromSet)
require.True(vm.HasSigner(node))
require.True(vm.signerSet.Signers[0].Slashed)
require.Equal(1, vm.signerSet.Signers[0].SlashCount)
}
// TestSlashSignerRemoval tests that slashing below 1M bond removes signer
// A bond slashed below what the chain requires is not a bond, so its holder is
// not a signer. The minimum is read from the same declaration registration
// applies rather than a second copy of the number.
func TestSlashSignerRemoval(t *testing.T) {
require := require.New(t)
vm := signerVM(t, 100)
node := ids.GenerateTestNodeID()
require.NoError(registerSigner(vm, node))
require.NoError(registerSigner(vm, ids.GenerateTestNodeID()))
nodeID := ids.GenerateTestNodeID()
initialBond := uint64(1_100_000 * 1e9) // 1.1M LUX bond (just above minimum)
vm := &VM{
config: BridgeConfig{
MaxSigners: 100,
ThresholdRatio: 0.67,
},
signerSet: &SignerSet{
Signers: []*SignerInfo{
{
NodeID: nodeID,
SlotIndex: 0,
Active: true,
BondAmount: initialBond,
Slashed: false,
SlashCount: 0,
},
},
CurrentEpoch: 0,
ThresholdT: 1,
},
}
// Slash 20% — 1.1M * 0.8 = 880K remaining, below 1M minimum
input := &SlashSignerInput{
NodeID: nodeID,
Reason: "double signing",
SlashPercent: 20,
Evidence: []byte("proof"),
}
result, err := vm.SlashSigner(input)
result, err := vm.SlashSigner(&SlashSignerInput{
NodeID: node, Reason: "double sign", SlashPercent: 50,
})
require.NoError(err)
require.True(result.Success)
require.True(result.RemovedFromSet) // Removed because bond < 1M
require.Equal(uint64(1), vm.signerSet.CurrentEpoch) // Epoch incremented
// Signer should be removed
require.Empty(vm.signerSet.Signers)
require.True(result.RemovedFromSet)
require.False(vm.HasSigner(node))
require.Equal(uint64(1), vm.signerSet.CurrentEpoch)
require.Equal(quorum(1)-1, vm.signerSet.ThresholdT)
}
// TestSlashSignerNotFound tests slashing a non-existent signer
func TestSlashSignerNotFound(t *testing.T) {
require := require.New(t)
nodeID := ids.GenerateTestNodeID()
otherNodeID := ids.GenerateTestNodeID()
vm := &VM{
signerSet: &SignerSet{
Signers: []*SignerInfo{
{NodeID: nodeID, SlotIndex: 0, Active: true, BondAmount: MinBridgeBond},
},
},
}
// Try to slash a node that's not in the set
input := &SlashSignerInput{
NodeID: otherNodeID,
Reason: "misbehavior",
SlashPercent: 50,
}
result, err := vm.SlashSigner(input)
require.NoError(err)
require.False(result.Success)
require.Contains(result.Message, "not found")
vm := signerVM(t, 100)
result, err := vm.SlashSigner(&SlashSignerInput{NodeID: ids.GenerateTestNodeID(), SlashPercent: 50})
require.NoError(t, err)
require.False(t, result.Success)
require.Contains(t, result.Message, "not found")
}
// TestSlashSignerInvalidPercent tests invalid slash percentages
func TestSlashSignerInvalidPercent(t *testing.T) {
require := require.New(t)
vm := signerVM(t, 100)
node := ids.GenerateTestNodeID()
require.NoError(t, registerSigner(vm, node))
nodeID := ids.GenerateTestNodeID()
vm := &VM{
signerSet: &SignerSet{
Signers: []*SignerInfo{
{NodeID: nodeID, SlotIndex: 0, Active: true, BondAmount: MinBridgeBond},
},
},
for _, pct := range []int{0, -1, 101, 1000} {
_, err := vm.SlashSigner(&SlashSignerInput{NodeID: node, SlashPercent: pct})
require.Error(t, err, "slash percent %d was accepted", pct)
}
// Test 0%
_, err := vm.SlashSigner(&SlashSignerInput{
NodeID: nodeID,
SlashPercent: 0,
})
require.Error(err)
require.Contains(err.Error(), "between 1 and 100")
// Test 101%
_, err = vm.SlashSigner(&SlashSignerInput{
NodeID: nodeID,
SlashPercent: 101,
})
require.Error(err)
require.Contains(err.Error(), "between 1 and 100")
}
+544
View File
@@ -0,0 +1,544 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package bridgevm
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/luxfi/chains/internal/bridgeattest"
"github.com/luxfi/database/memdb"
"github.com/luxfi/ids"
"github.com/luxfi/zap"
)
// What a block may be, stated as the attacks it refuses. Every test here feeds
// a block the way a peer does — assembled by hand or handed in as wire bytes —
// because a block this node built is the one case that proves nothing.
// =============================================================================
// Replay. On a bridge this is the money: a transfer settled twice is a deposit
// released twice.
// =============================================================================
// TestOneTransferCannotRideTwiceInOneBlock. Verify read the running total once
// per transfer and never added the block's own amounts, so a block carrying
// the same transfer a hundred times passed every check a hundred times.
func TestOneTransferCannotRideTwiceInOneBlock(t *testing.T) {
vm := boot(t)
req := requestFor(1, 500)
blk := blockOn(t, vm, vm.genesisBlock, now(), req, req)
require.ErrorIs(t, blk.Verify(context.Background()), errReplay)
}
// TestATransferSettledByAnEarlierBlockCannotBeSettledAgain is replay across
// blocks. The record of what had been settled lived in a map in memory and was
// never consulted by Verify at all.
func TestATransferSettledByAnEarlierBlockCannotBeSettledAgain(t *testing.T) {
vm := boot(t)
req := requestFor(1, 500)
pend(vm, req)
first := buildAndAccept(t, vm)
require.Equal(t, req.ID, first.BridgeRequests[0].ID)
// The same transfer, presented again by a peer.
again := blockOn(t, vm, first, now(), requestFor(1, 500))
require.ErrorIs(t, again.Verify(context.Background()), errReplay)
}
// TestATransferSettledBeforeARestartCannotBeSettledAfterOne. The settled set
// was memory, so a restart forgot every transfer the chain had ever released
// and a re-observed lock was released a second time.
func TestATransferSettledBeforeARestartCannotBeSettledAgain(t *testing.T) {
db := memdb.New()
vm := bootOn(t, db, testConfig())
pend(vm, requestFor(1, 500))
first := buildAndAccept(t, vm)
restarted := bootOn(t, db, testConfig())
again := blockOn(t, restarted, first, now(), requestFor(1, 500))
require.ErrorIs(t, again.Verify(context.Background()), errReplay)
}
// TestATransferInFlightCannotAlsoRideItsSibling. Two blocks one after the
// other, neither accepted yet: the second is checked against the state its
// parent decided, not against the tip, or the same transfer settles twice as
// soon as both are accepted.
func TestATransferInFlightCannotAlsoRideItsChild(t *testing.T) {
vm := boot(t)
req := requestFor(1, 500)
first := blockOn(t, vm, vm.genesisBlock, now(), req)
require.NoError(t, first.Verify(context.Background()))
second := blockOn(t, vm, first, now(), requestFor(1, 500))
require.ErrorIs(t, second.Verify(context.Background()), errReplay,
"the child must see what its parent already settled")
}
// 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.MaxBridgeAmount = 100
cfg.DailyBridgeLimit = 150
vm := bootOn(t, memdb.New(), cfg)
at := now()
first := blockOn(t, vm, vm.genesisBlock, at, requestFor(1, 100))
require.NoError(t, first.Verify(context.Background()))
// 100 of 150 is spoken for by a block that is not accepted yet.
second := blockOn(t, vm, first, at, requestFor(2, 100))
require.ErrorContains(t, second.Verify(context.Background()), "daily cap")
// What is left, fits.
third := blockOn(t, vm, first, at, requestFor(3, 50))
require.NoError(t, third.Verify(context.Background()))
}
// =============================================================================
// A request's name is its contents.
// =============================================================================
// TestARequestMustBeTheDigestOfWhatItCarries. The id was a label the proposer
// chose. Everything downstream keys off it — the replay guard, the daily
// counter, what the release worker is handed — so a proposer could present one
// deposit under a hundred names, or one name carrying a different amount to a
// different recipient each time.
func TestARequestMustBeTheDigestOfWhatItCarries(t *testing.T) {
vm := boot(t)
renamed := requestFor(1, 500)
renamed.ID = ids.GenerateTestID()
require.ErrorContains(t,
blockOn(t, vm, vm.genesisBlock, now(), renamed).Verify(context.Background()),
"not the digest")
// And every field the digest covers: change one and the name no longer fits.
for name, mutate := range map[string]func(*BridgeRequest){
"amount": func(r *BridgeRequest) { r.Amount++ },
"recipient": func(r *BridgeRequest) { r.Recipient[0] ^= 0xff },
"nonce": func(r *BridgeRequest) { r.Nonce++ },
"asset": func(r *BridgeRequest) { r.Asset[0] ^= 0xff },
"source": func(r *BridgeRequest) { r.SrcChainID = 4242 },
"destination": func(r *BridgeRequest) { r.DstChainID = 4242 },
} {
req := requestFor(1, 500)
mutate(req)
require.ErrorContains(t,
blockOn(t, vm, vm.genesisBlock, now(), req).Verify(context.Background()),
"not the digest", "a changed %s was accepted under the original name", name)
}
}
func TestARequestMustBeWellFormed(t *testing.T) {
vm := boot(t)
cfg := &vm.config
// A recipient that is not an address cannot be released to.
short := requestFor(1, 500)
short.Recipient = short.Recipient[:19]
require.ErrorContains(t, admissible(cfg, short), "20 bytes")
// A transfer to nowhere has no gateway to route to.
noDst := requestFor(1, 500)
noDst.DstChainID = 0
require.ErrorContains(t, admissible(cfg, noDst), "destination chain id")
// A transfer from nowhere cannot have its source lock re-checked. Named by
// its own digest, so the check under test is the one that answers.
fromNowhere := transferFor(1, 500)
fromNowhere.SrcChainID = 0
require.ErrorContains(t, admissible(cfg, requestOf(fromNowhere)), "no source chain")
// A chain does not bridge to itself.
selfBridge := transferFor(1, 500)
selfBridge.DstChainID = selfBridge.SrcChainID
require.ErrorContains(t, admissible(cfg, requestOf(selfBridge)), "to itself")
// Nothing is not an amount.
zero := requestOf(transferFor(1, 0))
require.ErrorContains(t, admissible(cfg, zero), "moves nothing")
// And the per-transfer cap.
big := requestOf(transferFor(1, cfg.MaxBridgeAmount+1))
require.ErrorContains(t, admissible(cfg, big), "per-transfer cap")
}
// =============================================================================
// Where a block sits in the chain.
// =============================================================================
// TestABlockOnAnOldSiblingIsRefused. Verify asked only that a block's height
// followed its parent's, never that the parent was the tip, so an old sibling
// rewound the chain — and the height index then served that orphan to
// bootstrapping peers as the block at that height.
func TestABlockOnAnOldSiblingIsRefused(t *testing.T) {
vm := boot(t)
pend(vm, requestFor(1, 100))
one := buildAndAccept(t, vm)
pend(vm, requestFor(2, 100))
two := buildAndAccept(t, vm)
require.Equal(t, uint64(2), two.BlockHeight)
// A block built on block one, which the chain has already built past.
rewind := blockOn(t, vm, one, now(), requestFor(3, 100))
require.ErrorContains(t, rewind.Verify(context.Background()), "beneath the tip")
// And the height index still names what was accepted.
at, err := vm.GetBlockIDAtHeight(context.Background(), 2)
require.NoError(t, err)
require.Equal(t, two.ID(), at)
}
func TestABlockMustFollowItsParent(t *testing.T) {
vm := boot(t)
skipped := blockOn(t, vm, vm.genesisBlock, now(), requestFor(1, 100))
skipped.BlockHeight = 7
skipped.ID_ = skipped.computeID()
require.ErrorContains(t, skipped.Verify(context.Background()), "does not follow parent")
}
func TestABlockWithNoParentIsRefused(t *testing.T) {
vm := boot(t)
orphan := &Block{
ParentID_: ids.GenerateTestID(),
BlockHeight: 1,
BlockTimestamp: now(),
BridgeRequests: []*BridgeRequest{requestFor(1, 100)},
vm: vm,
}
orphan.ID_ = orphan.computeID()
require.ErrorContains(t, orphan.Verify(context.Background()), "parent")
}
// TestABlockInFlightCanBeAParent is the other half of the tip rule, and the
// reason Verify registers what it accepts. Tracking only self-built blocks let
// a follower verify the first block of a run and fail the second with "parent
// not found" — the ordinary shape whenever more than one block is in the air.
func TestABlockInFlightCanBeAParent(t *testing.T) {
vm := boot(t)
first := blockOn(t, vm, vm.genesisBlock, now(), requestFor(1, 100))
require.NoError(t, first.Verify(context.Background()))
// The store must be able to resolve it as a parent now.
found, err := vm.GetBlock(context.Background(), first.ID())
require.NoError(t, err)
require.Equal(t, first.ID(), found.ID())
second := blockOn(t, vm, first, now(), requestFor(2, 100))
require.NoError(t, second.Verify(context.Background()),
"a follower must be able to verify the second block of a run")
require.NoError(t, first.Accept(context.Background()))
require.NoError(t, second.Accept(context.Background()))
tip, height := vm.chain.Tip()
require.Equal(t, second.ID(), tip)
require.Equal(t, uint64(2), height)
}
// A parent this node never checked is not a parent it can account for.
func TestAParentThatWasNeverVerifiedIsRefused(t *testing.T) {
vm := boot(t)
unchecked := blockOn(t, vm, vm.genesisBlock, now(), requestFor(1, 100))
vm.chain.Track(unchecked)
child := blockOn(t, vm, unchecked, now(), requestFor(2, 100))
require.ErrorContains(t, child.Verify(context.Background()), "not been verified here")
}
// TestAcceptOnlyExtendsTheTip. A block whose parent has been built past must
// not be committed on top of what did get accepted: the height index would
// then name it, and a bootstrapping peer would take the orphan for canonical.
func TestAcceptOnlyExtendsTheTip(t *testing.T) {
vm := boot(t)
pend(vm, requestFor(1, 100))
one := buildAndAccept(t, vm)
sibling := blockOn(t, vm, vm.genesisBlock, now(), requestFor(2, 100))
require.ErrorContains(t, sibling.Accept(context.Background()), "not the tip")
tip, _ := vm.chain.Tip()
require.Equal(t, one.ID(), tip)
}
// Genesis is given, not verified. A second one is a peer handing this chain a
// new beginning.
func TestGenesisIsNotVerifiable(t *testing.T) {
vm := boot(t)
require.ErrorContains(t, vm.genesisBlock.Verify(context.Background()), "genesis")
imposter := &Block{ParentID_: ids.Empty, BlockHeight: 0, BlockTimestamp: now(), vm: vm}
imposter.ID_ = imposter.computeID()
require.ErrorContains(t, imposter.Verify(context.Background()), "genesis")
}
// =============================================================================
// Chain time.
// =============================================================================
// TestChainTimeOnlyMovesForward. Chain time cuts the window the daily cap is
// measured over, so a proposer free to stamp a block before its parent could
// step back into a window it had already spent — and one free to stamp it far
// ahead could expire every window at once.
func TestChainTimeOnlyMovesForward(t *testing.T) {
vm := boot(t)
pend(vm, requestFor(1, 100))
first := buildAndAccept(t, vm)
rewound := blockOn(t, vm, first, first.BlockTimestamp-1, requestFor(2, 100))
require.ErrorContains(t, rewound.Verify(context.Background()), "precedes parent")
future := blockOn(t, vm, first, now()+maxClockSkew+30, requestFor(2, 100))
require.ErrorIs(t, future.Verify(context.Background()), errFutureBlock)
// The same time as the parent is fine — blocks are stamped in seconds.
same := blockOn(t, vm, first, first.BlockTimestamp, requestFor(2, 100))
require.NoError(t, same.Verify(context.Background()))
}
// A proposer whose own clock is behind its parent must not build a block its
// own Verify would refuse.
func TestABuiltBlockNeverPrecedesItsParent(t *testing.T) {
vm := boot(t)
pend(vm, requestFor(1, 100))
first := blockOn(t, vm, vm.genesisBlock, now()+maxClockSkew/2, requestFor(1, 100))
require.NoError(t, first.Verify(context.Background()))
require.NoError(t, first.Accept(context.Background()))
pend(vm, requestFor(2, 100))
second := build(t, vm)
require.GreaterOrEqual(t, second.BlockTimestamp, first.BlockTimestamp)
require.NoError(t, second.Verify(context.Background()))
}
// =============================================================================
// The wire.
// =============================================================================
// TestABlockIsBoundedBeforeItIsParsed. Nothing bounded a peer's message, so an
// eight-megabyte "block" was decoded, hashed and verified before anything
// noticed it was not one.
func TestABlockIsBoundedBeforeItIsParsed(t *testing.T) {
vm := boot(t)
_, err := vm.ParseBlock(context.Background(), make([]byte, maxBlockBytes+1))
require.ErrorContains(t, err, "over the")
}
func TestTooManyTransfersIsRefusedAtBothEnds(t *testing.T) {
vm := boot(t)
reqs := make([]*BridgeRequest, 0, maxRequestsPerBlock+1)
for i := 0; i <= maxRequestsPerBlock; i++ {
reqs = append(reqs, requestFor(uint64(i+1), 1))
}
blk := blockOn(t, vm, vm.genesisBlock, now(), reqs...)
require.ErrorContains(t, blk.Verify(context.Background()), "over the")
_, err := vm.ParseBlock(context.Background(), blk.Bytes())
require.ErrorContains(t, err, "over the", "the wire must refuse it too")
}
func TestAnEmptyBlockIsRefused(t *testing.T) {
vm := boot(t)
require.ErrorContains(t,
blockOn(t, vm, vm.genesisBlock, now()).Verify(context.Background()),
"carries no transfers")
}
// TestTheEncodingIsCanonical. The block id is a hash of the block's contents,
// so an encoding with room for slack gives one block an unbounded number of
// distinct byte strings — and two nodes holding "the same" block hold
// different bytes.
func TestTheEncodingIsCanonical(t *testing.T) {
vm := boot(t)
blk := blockOn(t, vm, vm.genesisBlock, now(), requestFor(1, 100))
raw := blk.Bytes()
back, err := vm.ParseBlock(context.Background(), raw)
require.NoError(t, err)
require.Equal(t, blk.ID(), back.ID())
require.Equal(t, raw, back.Bytes())
// Bytes past the end.
_, err = vm.ParseBlock(context.Background(), append(append([]byte(nil), raw...), 0))
require.Error(t, err)
// Padding inside a request's own declared length, which leaves the block's
// id untouched and its bytes different.
padded := paddedRequestBlock(t, vm, requestFor(1, 100))
_, err = vm.ParseBlock(context.Background(), padded)
require.ErrorContains(t, err, "canonical")
// Truncation, and bytes that are not a message at all.
_, err = vm.ParseBlock(context.Background(), raw[:len(raw)-1])
require.Error(t, err)
_, err = vm.ParseBlock(context.Background(), []byte{1, 2, 3})
require.Error(t, err)
_, err = vm.ParseBlock(context.Background(), nil)
require.Error(t, err)
}
// paddedRequestBlock encodes a block whose one request carries two bytes of
// slack inside the length the block declares for it.
func paddedRequestBlock(t *testing.T, vm *VM, req *BridgeRequest) []byte {
t.Helper()
rb := append(marshalBridgeRequest(req), 0xAA, 0xBB)
bld := zap.NewBuilder(zap.HeaderSize + blkSize + len(rb) + 128)
lensOff := writeU32List(bld, []uint32{uint32(len(rb))})
ob := bld.StartObject(blkSize)
gid := vm.genesisBlock.ID()
ob.SetBytesFixed(blkParent, gid[:])
ob.SetUint64(blkHeight, 1)
ob.SetInt64(blkTime, now())
ob.SetList(blkReqLens, lensOff, 1)
ob.SetBytes(blkReqBlob, rb)
ob.FinishAsRoot()
return bld.Finish()
}
// A request length that runs past the blob it is cut from is refused rather
// than read out of bounds.
func TestARequestLengthOutsideTheBlobIsRefused(t *testing.T) {
vm := boot(t)
bld := zap.NewBuilder(zap.HeaderSize + blkSize + 256)
lensOff := writeU32List(bld, []uint32{4096})
ob := bld.StartObject(blkSize)
gid := vm.genesisBlock.ID()
ob.SetBytesFixed(blkParent, gid[:])
ob.SetUint64(blkHeight, 1)
ob.SetInt64(blkTime, now())
ob.SetList(blkReqLens, lensOff, 1)
ob.SetBytes(blkReqBlob, []byte{1, 2, 3})
ob.FinishAsRoot()
_, err := vm.ParseBlock(context.Background(), bld.Finish())
require.ErrorContains(t, err, "out of bounds")
}
// A request whose own bytes are not a message is refused, not read as zeroes.
func TestARequestThatIsNotAMessageIsRefused(t *testing.T) {
vm := boot(t)
junk := []byte{9, 9, 9, 9, 9, 9, 9, 9}
bld := zap.NewBuilder(zap.HeaderSize + blkSize + 256)
lensOff := writeU32List(bld, []uint32{uint32(len(junk))})
ob := bld.StartObject(blkSize)
gid := vm.genesisBlock.ID()
ob.SetBytesFixed(blkParent, gid[:])
ob.SetUint64(blkHeight, 1)
ob.SetInt64(blkTime, now())
ob.SetList(blkReqLens, lensOff, 1)
ob.SetBytes(blkReqBlob, junk)
ob.FinishAsRoot()
_, err := vm.ParseBlock(context.Background(), bld.Finish())
require.Error(t, err)
}
// TestTheReceivePathVerifiesWhatTheBuildPathVerifies. A block handed in as
// wire bytes and the same block held as an object must get the same verdict,
// or a check runs only on what this node built itself.
func TestTheReceivePathVerifiesWhatTheBuildPathVerifies(t *testing.T) {
vm := boot(t)
renamed := requestFor(1, 500)
renamed.ID = ids.GenerateTestID()
inMemory := blockOn(t, vm, vm.genesisBlock, now(), renamed)
fromWire, err := vm.ParseBlock(context.Background(), inMemory.Bytes())
require.NoError(t, err)
memErr := inMemory.Verify(context.Background())
wireErr := fromWire.Verify(context.Background())
require.Error(t, memErr)
require.Error(t, wireErr)
require.Equal(t, memErr.Error(), wireErr.Error(),
"the same block must get the same verdict however it arrived")
}
// =============================================================================
// Which chain a block belongs to.
// =============================================================================
// TestABlockOfAnotherChainIsNotABlockOfThisOne. The id hashed the parent, the
// height, the time and the transfers — nothing that said which chain. Two
// chains running this VM from the same genesis therefore agreed on every block
// id, so a testnet block was a mainnet block, byte for byte.
func TestABlockOfAnotherChainIsNotABlockOfThisOne(t *testing.T) {
here := boot(t)
elsewhere := boot(t)
elsewhere.chainID = ids.ID{'a', 'n', 'o', 't', 'h', 'e', 'r'}
elsewhere.genesisBlock.ID_ = elsewhere.genesisBlock.computeID()
_, _, err := elsewhere.chain.Open(elsewhere.genesisBlock, elsewhere.parseBlock)
require.NoError(t, err)
require.NotEqual(t, here.genesisBlock.ID(), elsewhere.genesisBlock.ID(),
"two chains agree on their genesis id")
pend(elsewhere, requestFor(1, 100))
theirs := build(t, elsewhere)
require.NoError(t, theirs.Verify(context.Background()))
// The same bytes, offered to this chain.
ours, err := here.ParseBlock(context.Background(), theirs.Bytes())
require.NoError(t, err)
require.NotEqual(t, theirs.ID(), ours.ID(), "one block, two chains, one id")
require.ErrorContains(t, ours.Verify(context.Background()), "parent")
}
// =============================================================================
// Booting.
// =============================================================================
// TestAChainDoesNotRestartOverItself. Open reports a fresh database on ANY
// failure to read the tip, not only on its absence. Believing that on a live
// chain commits genesis over the tip and re-opens a daily cap that has already
// been spent — durably, with Initialize returning nil.
func TestAChainDoesNotRestartOverItself(t *testing.T) {
db := memdb.New()
vm := bootOn(t, db, testConfig())
pend(vm, requestFor(1, 500))
buildAndAccept(t, vm)
// Whatever went wrong with the tip, the settlements are still there, and
// 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()))
require.ErrorContains(t, err, "refusing to restart the chain over it")
}
func TestAFreshDatabaseStartsAtGenesis(t *testing.T) {
vm := boot(t)
tip, height := vm.chain.Tip()
require.Equal(t, vm.genesisBlock.ID(), tip)
require.Zero(t, height)
id, err := vm.LastAccepted(context.Background())
require.NoError(t, err)
require.Equal(t, vm.genesisBlock.ID(), id)
got, err := vm.GetBlock(context.Background(), tip)
require.NoError(t, err)
require.Equal(t, tip, got.ID())
}
func now() int64 { return time.Now().Unix() }
func requestOf(bt bridgeattest.BridgeTransfer) *BridgeRequest {
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[:]...),
}
return req
}
+347 -433
View File
File diff suppressed because it is too large Load Diff
+51 -47
View File
@@ -5,7 +5,6 @@ package bridgevm
import (
"context"
"math"
"math/big"
"sync"
"time"
@@ -28,11 +27,12 @@ const (
// M and broadcast — so this is set for cheap polling rather than speed.
watchInterval = 5 * time.Second
// watchLag is how far behind its head a chain is read. A lock that a reorg
// takes back must never become a request, and a request is only ever
// created once, so B reads only what the source chain is unlikely to
// reconsider.
watchLag = 12
// minWatchLag is how far behind its head a chain is read at the very
// least. A lock that a reorg takes back must never become a request, and a
// request is only ever created once, so B reads only what the source chain
// is unlikely to reconsider. The chain's own MinConfirmations raises this
// when it asks for more.
minWatchLag = 12
// watchSpan bounds one pass. A node that has been down for a week asks for
// its backlog a piece at a time rather than in one query no endpoint will
@@ -48,9 +48,8 @@ type lockSource interface {
FetchLockEvents(ctx context.Context, from, to *big.Int) ([]lock, error)
}
// lock is one Locked event: the transfer it authorises, the transaction it was
// emitted by, and the block it sits in — which is what its depth is measured
// from.
// lock is one Locked event: the transfer it authorises, and the transaction it
// was emitted by.
type lock struct {
Transfer bridgeattest.BridgeTransfer
TxID ids.ID
@@ -60,7 +59,6 @@ type lock struct {
// watcher turns what the source chains locked into requests a block can carry.
type watcher struct {
vm *VM
names map[uint32]string // chain id -> the label clients are keyed by
cursor map[uint32]uint64 // chain id -> last block read
quit chan struct{}
wg sync.WaitGroup
@@ -68,13 +66,8 @@ type watcher struct {
}
func newWatcher(vm *VM, chains []ExternalChainConfig) *watcher {
names := make(map[uint32]string, len(chains))
for _, cfg := range chains {
names[uint32(cfg.ChainID)] = cfg.Name
}
w := &watcher{
vm: vm,
names: names,
cursor: make(map[uint32]uint64, len(chains)),
quit: make(chan struct{}),
}
@@ -122,21 +115,37 @@ func (w *watcher) pass() {
continue
}
if err := w.read(ctx, id, source); err != nil {
w.vm.log.Debug("Bridge watch pass failed",
w.vm.log.Debug("bridgevm: watch pass failed",
log.Uint32("chain", id), log.String("err", err.Error()))
}
}
}
// lag is how far behind a source chain's head this node reads.
//
// It is the deeper of the reorg margin and what the chain's own configuration
// demands, so anything read is by construction buried deep enough to be
// carried. Reading at a fixed margin and stamping each request with the depth
// it happened to have at that moment left a transfer observed at depth 12 by a
// 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 {
return conf
}
return minWatchLag
}
func (w *watcher) read(ctx context.Context, chain uint32, source lockSource) error {
head, err := source.HeadBlock(ctx)
if err != nil {
return err
}
if head < watchLag {
lag := w.lag()
if head < lag {
return nil
}
to := head - watchLag
to := head - lag
from, seen := w.cursor[chain]
if !seen {
@@ -160,29 +169,38 @@ func (w *watcher) read(ctx context.Context, chain uint32, source lockSource) err
return err
}
for _, l := range locks {
w.enqueue(l, head)
w.enqueue(l)
}
w.cursor[chain] = to
return nil
}
// enqueue turns a lock into a request a block can carry. The transfer's digest
// is its identity: it is what M signs and what the destination gateway keys its
// replay guard by, so two chains reusing a nonce cannot collide and the same
// lock read twice is the same request.
func (w *watcher) enqueue(l lock, head uint64) {
// is its identity: it is what M signs, what the destination gateway keys its
// replay guard by, and what this chain names the request, so two chains reusing
// a nonce cannot collide and the same lock read twice is the same request.
func (w *watcher) enqueue(l lock) {
transfer := l.Transfer
id := ids.ID(transfer.Digest())
// How deep the lock is buried, which is what decides whether a block may
// carry it yet. Reading watchLag behind the head means this is never less
// than watchLag; the chain's own policy decides whether that is enough.
var depth uint64
if head > l.Block {
depth = head - l.Block
req := &BridgeRequest{
ID: id,
SrcChainID: transfer.SrcChainID,
DstChainID: transfer.DstChainID,
Nonce: transfer.Nonce,
Asset: ids.ID(transfer.Asset),
Amount: transfer.Amount,
Recipient: append([]byte(nil), transfer.Recipient[:]...),
SourceTxID: l.TxID,
}
if depth > math.MaxUint32 {
depth = math.MaxUint32
// 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 {
w.vm.log.Warn("bridgevm: lock not admitted",
log.Stringer("requestID", id), log.String("reason", err.Error()))
return
}
w.vm.mu.Lock()
@@ -190,24 +208,10 @@ func (w *watcher) enqueue(l lock, head uint64) {
w.vm.mu.Unlock()
return
}
w.vm.pendingBridges[id] = &BridgeRequest{
ID: id,
SourceChain: w.names[transfer.SrcChainID],
DestChain: w.names[transfer.DstChainID],
SrcChainID: transfer.SrcChainID,
DstChainID: transfer.DstChainID,
Nonce: transfer.Nonce,
Asset: ids.ID(transfer.Asset),
Amount: transfer.Amount,
Recipient: append([]byte(nil), transfer.Recipient[:]...),
SourceTxID: l.TxID,
Confirmations: uint32(depth),
Status: StatusPending,
CreatedAt: time.Now(),
}
w.vm.pendingBridges[id] = req
w.vm.mu.Unlock()
w.vm.log.Info("Bridge lock observed",
w.vm.log.Info("bridgevm: lock observed",
log.Stringer("requestID", id),
log.Uint32("src", transfer.SrcChainID),
log.Uint32("dst", transfer.DstChainID),
+155 -167
View File
@@ -11,7 +11,10 @@ import (
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/luxfi/chains/internal/bridgeattest"
"github.com/luxfi/database/memdb"
"github.com/luxfi/ids"
"github.com/luxfi/log"
)
@@ -61,70 +64,32 @@ func (f *fakeSource) IsProcessed(context.Context, bridgeattest.BridgeTransfer) (
return false, nil
}
func transferAt(nonce uint64) bridgeattest.BridgeTransfer {
bt := bridgeattest.BridgeTransfer{
SrcChainID: 96368,
DstChainID: 200201,
Amount: 1000,
Nonce: nonce,
}
bt.Asset[0] = 7
bt.Recipient[0] = 9
return bt
}
// watchingVM is the smallest VM the watcher touches: a client map, a pending
// queue, a latch and a logger.
func watchingVM(src *fakeSource) *VM {
vm := &VM{
log: log.NewNoOpLogger(),
pendingBridges: make(map[ids.ID]*BridgeRequest),
evmByChainID: map[uint32]ChainClient{96368: src},
}
return vm
}
func newTestWatcher(vm *VM) *watcher {
return &watcher{
vm: vm,
names: map[uint32]string{96368: "lux-testnet-c", 200201: "zoo-testnet"},
cursor: make(map[uint32]uint64),
quit: make(chan struct{}),
}
}
// TestWatchStartsAtTheSettledHead. Anything locked before B was watching was
// either released already or is not B's to release now, so a first pass adopts
// the head rather than replaying a chain's whole history into the queue.
func TestWatchStartsAtTheSettledHead(t *testing.T) {
src := &fakeSource{head: 1000, locks: map[uint64][]lock{
500: {{Transfer: transferAt(1)}},
500: {{Transfer: transferFor(1, 1000)}},
}}
vm := watchingVM(src)
w := newTestWatcher(vm)
vm := boot(t)
w := watcherOn(vm, src)
w.pass()
if len(src.asked) != 0 {
t.Fatalf("a first pass asked for %v; it should only take its bearings", src.asked)
}
if len(vm.pendingBridges) != 0 {
t.Fatalf("a first pass queued %d requests from before it was watching", len(vm.pendingBridges))
}
if got, want := w.cursor[96368], uint64(1000-watchLag); got != want {
t.Fatalf("cursor = %d, want the settled head %d", got, want)
}
require.Empty(t, src.asked, "a first pass should only take its bearings")
require.Empty(t, vm.pendingBridges, "a first pass queued requests from before it was watching")
require.Equal(t, uint64(1000)-w.lag(), w.cursor[srcChain])
}
// TestWatchQueuesWhatWasLocked is the whole point: a lock on the source chain
// becomes a request a block can carry, and consensus is told there is work.
func TestWatchQueuesWhatWasLocked(t *testing.T) {
src := &fakeSource{head: 1000}
vm := watchingVM(src)
w := newTestWatcher(vm)
w.pass() // take bearings at 988
vm := boot(t)
w := watcherOn(vm, src)
w.pass() // take bearings
locked := transferAt(42)
locked := transferFor(42, 1000)
src.mu.Lock()
src.head = 1010
src.locks = map[uint64][]lock{995: {{Transfer: locked, TxID: ids.ID{0xAB}}}}
@@ -132,38 +97,29 @@ func TestWatchQueuesWhatWasLocked(t *testing.T) {
w.pass()
if len(vm.pendingBridges) != 1 {
t.Fatalf("queued %d requests, want 1", len(vm.pendingBridges))
}
require.Len(t, vm.pendingBridges, 1)
req, ok := vm.pendingBridges[ids.ID(locked.Digest())]
if !ok {
t.Fatal("the request is not keyed by the transfer digest the gateway replays against")
}
if req.Nonce != 42 || req.Amount != 1000 || req.SrcChainID != 96368 || req.DstChainID != 200201 {
t.Fatalf("request does not carry the locked transfer: %+v", req)
}
if req.SourceChain != "lux-testnet-c" || req.DestChain != "zoo-testnet" {
t.Fatalf("route labels not resolved: %q -> %q", req.SourceChain, req.DestChain)
}
if req.SourceTxID != (ids.ID{0xAB}) {
t.Fatalf("source transaction not carried: %v", req.SourceTxID)
}
require.True(t, ok, "the request is not keyed by the transfer digest the gateway replays against")
require.Equal(t, uint64(42), req.Nonce)
require.Equal(t, uint64(1000), req.Amount)
require.Equal(t, srcChain, req.SrcChainID)
require.Equal(t, dstChain, req.DstChainID)
require.Equal(t, ids.ID{0xAB}, req.SourceTxID)
// Consensus must have been told, or the request sits in the queue forever.
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if _, err := vm.WaitForEvent(ctx); err != nil {
t.Fatal("a lock was queued and consensus was never told; the block would never be built")
}
_, err := vm.WaitForEvent(ctx)
require.NoError(t, err, "a lock was queued and consensus was never told")
}
// The same lock read twice is the same request. Re-reading a range after a
// restart must not queue a second copy of what is already pending.
func TestWatchDoesNotQueueALockTwice(t *testing.T) {
locked := transferAt(7)
locked := transferFor(7, 1000)
src := &fakeSource{head: 1000}
vm := watchingVM(src)
w := newTestWatcher(vm)
vm := boot(t)
w := watcherOn(vm, src)
w.pass()
src.mu.Lock()
@@ -173,47 +129,69 @@ func TestWatchDoesNotQueueALockTwice(t *testing.T) {
w.pass()
// Wind the cursor back, as a restart would, and read the same block again.
w.cursor[96368] = 990
w.cursor[srcChain] = 990
src.mu.Lock()
src.head = 1020
src.mu.Unlock()
w.pass()
if len(vm.pendingBridges) != 1 {
t.Fatalf("the same lock queued %d requests", len(vm.pendingBridges))
}
require.Len(t, vm.pendingBridges, 1, "the same lock queued twice")
}
// A chain read stays behind the head so a lock a reorg takes back never becomes
// a request, and one pass is bounded so a long backlog is asked for in pieces.
func TestWatchStaysBehindTheHeadAndBoundsAPass(t *testing.T) {
src := &fakeSource{head: 100_000}
vm := watchingVM(src)
w := newTestWatcher(vm)
w.cursor[96368] = 0
vm := boot(t)
w := watcherOn(vm, src)
w.cursor[srcChain] = 0
w.pass()
if len(src.asked) != 1 {
t.Fatalf("asked %d times in one pass", len(src.asked))
}
require.Len(t, src.asked, 1, "asked more than once in one pass")
from, to := src.asked[0][0], src.asked[0][1]
if to-from+1 > watchSpan {
t.Fatalf("one pass asked for %d blocks, more than the %d bound", to-from+1, watchSpan)
}
if to > src.head-watchLag {
t.Fatalf("read to block %d, within %d of the head %d", to, watchLag, src.head)
}
require.LessOrEqual(t, to-from+1, uint64(watchSpan), "one pass asked for more than the bound")
require.LessOrEqual(t, to, src.head-w.lag(), "read too close to the head")
}
// TestWatchReadsAsDeepAsTheChainAsksFor is the defect the depth field hid.
//
// A request was stamped with the depth its lock happened to have when it was
// first seen, and the cursor then moved past that block for good. A chain
// asking for more confirmations than the fixed reorg margin therefore queued
// every transfer at a depth that would never be enough, and none of them was
// 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.MinConfirmations = 500
vm := bootOn(t, memdb.New(), cfg)
src := &fakeSource{head: 1000}
w := watcherOn(vm, src)
require.Equal(t, uint64(500), w.lag())
w.pass()
require.Equal(t, uint64(500), w.cursor[srcChain], "the cursor sits 500 blocks behind the head")
// Only what is now buried 500 deep is read.
src.mu.Lock()
src.head = 1100
src.locks = map[uint64][]lock{550: {{Transfer: transferFor(1, 1000)}}}
src.mu.Unlock()
w.pass()
require.Len(t, vm.pendingBridges, 1)
require.Equal(t, [2]uint64{501, 600}, src.asked[0])
}
// A chain that cannot be reached is left for the next pass with its cursor
// where it was, so an endpoint having a bad minute does not skip its blocks.
func TestWatchDoesNotAdvancePastAChainItCannotRead(t *testing.T) {
src := &fakeSource{head: 1000}
vm := watchingVM(src)
w := newTestWatcher(vm)
vm := boot(t)
w := watcherOn(vm, src)
w.pass()
settled := w.cursor[96368]
settled := w.cursor[srcChain]
src.mu.Lock()
src.head = 2000
@@ -221,24 +199,66 @@ func TestWatchDoesNotAdvancePastAChainItCannotRead(t *testing.T) {
src.mu.Unlock()
w.pass()
if w.cursor[96368] != settled {
t.Fatalf("cursor moved to %d while the chain was unreadable, skipping its blocks", w.cursor[96368])
}
require.Equal(t, settled, w.cursor[srcChain], "cursor moved while the chain was unreadable")
}
// TestAChainTooShortToReadIsLeftAlone: a source chain with fewer blocks than
// the read depth has nothing settled to read yet.
func TestAChainTooShortToReadIsLeftAlone(t *testing.T) {
src := &fakeSource{head: 3}
vm := boot(t)
w := watcherOn(vm, src)
w.pass()
_, seen := w.cursor[srcChain]
require.False(t, seen, "a chain shorter than the read depth was given a cursor")
}
// TestAClientThatIsNotASourceIsSkipped: a destination-only client answers none
// of the questions the watcher asks, and is not one it reads.
func TestAClientThatIsNotASourceIsSkipped(t *testing.T) {
vm := boot(t)
w := watcherOn(vm, &recordingClient{})
w.pass()
require.Empty(t, w.cursor)
}
// TestALockThatCanNeverBeCarriedIsNotHeld. A transfer no block can carry —
// over the per-transfer cap, say — used to sit in the pending set, be
// proposed, be refused, and go straight back in. Deciding it once, where the
// 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.MaxBridgeAmount = 10
vm := bootOn(t, memdb.New(), cfg)
src := &fakeSource{head: 1000}
w := watcherOn(vm, src)
w.pass()
src.mu.Lock()
src.head = 1010
src.locks = map[uint64][]lock{995: {
{Transfer: transferFor(1, 5_000)}, // over the cap
{Transfer: transferFor(2, 5)}, // fine
}}
src.mu.Unlock()
w.pass()
require.Len(t, vm.pendingBridges, 1, "an uncarryable transfer was held")
_, ok := vm.pendingBridges[ids.ID(transferFor(2, 5).Digest())]
require.True(t, ok)
}
// TestALockBecomesABlock is the whole path in one test: a gateway locks value
// on the source chain, the watcher reads it, consensus is told there is work,
// and the block that gets built carries exactly that transfer.
// and the block that gets built carries exactly that transfer — and settles.
func TestALockBecomesABlock(t *testing.T) {
vm, _ := vmWithPending(t, 0)
vm.evmByChainID = map[uint32]ChainClient{96368: nil}
vm := boot(t)
src := &fakeSource{head: 1000}
vm.evmByChainID[96368] = src
w := newTestWatcher(vm)
w := watcherOn(vm, src)
w.pass() // take bearings
locked := transferAt(11)
locked := transferFor(11, 1000)
src.mu.Lock()
src.head = 1010
src.locks = map[uint64][]lock{995: {{Transfer: locked, TxID: ids.ID{0xCD}}}}
@@ -247,28 +267,17 @@ func TestALockBecomesABlock(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if _, err := vm.WaitForEvent(ctx); err != nil {
t.Fatalf("consensus was never told there was work: %v", err)
}
_, err := vm.WaitForEvent(ctx)
require.NoError(t, err, "consensus was never told there was work")
blk, err := vm.BuildBlock(ctx)
if err != nil {
t.Fatalf("building a block from an observed lock: %v", err)
}
built, ok := blk.(*Block)
if !ok {
t.Fatalf("BuildBlock returned %T", blk)
}
if len(built.BridgeRequests) != 1 {
t.Fatalf("the block carries %d requests, want the one that was locked", len(built.BridgeRequests))
}
carried := built.BridgeRequests[0]
if carried.ID != ids.ID(locked.Digest()) {
t.Fatalf("the block carries %v, not the locked transfer %v", carried.ID, ids.ID(locked.Digest()))
}
if carried.Nonce != 11 || carried.SrcChainID != 96368 || carried.DstChainID != 200201 {
t.Fatalf("the block carries a different transfer: %+v", carried)
}
built := buildAndAccept(t, vm)
require.Len(t, built.BridgeRequests, 1)
require.Equal(t, ids.ID(locked.Digest()), built.BridgeRequests[0].ID)
require.Equal(t, uint64(1000), vm.movedToday(built.BlockTimestamp, dstChain))
vm.mu.RLock()
defer vm.mu.RUnlock()
require.Empty(t, vm.pendingBridges, "a settled transfer is no longer waiting for a block")
}
// TestHealthSaysWhatItCannotDo. Releasing needs a threshold key to attest with
@@ -278,73 +287,52 @@ func TestHealthSaysWhatItCannotDo(t *testing.T) {
// A node with no chains wired is not a relayer. That is a configuration,
// not a fault, and calling it unhealthy would take validators that were
// never meant to relay out of rotation.
plain := &VM{log: log.NewNoOpLogger(), pendingBridges: make(map[ids.ID]*BridgeRequest)}
plain := boot(t)
res, err := plain.HealthCheck(context.Background())
if err != nil {
t.Fatalf("health check: %v", err)
}
if !res.Healthy {
t.Fatalf("a validator that relays nothing reported unhealthy: %q", res.Details["status"])
}
require.NoError(t, err)
require.True(t, res.Healthy, "a validator that relays nothing reported unhealthy")
// A node that IS configured to relay but cannot attest is a different
// matter: answering healthy routes transfers at a node that cannot carry
// them.
relayer := &VM{
log: log.NewNoOpLogger(),
pendingBridges: make(map[ids.ID]*BridgeRequest),
evmByChainID: map[uint32]ChainClient{96368: &fakeSource{}},
}
relayer := boot(t)
watcherOn(relayer, &fakeSource{})
res, err = relayer.HealthCheck(context.Background())
if err != nil {
t.Fatalf("health check: %v", err)
}
if res.Healthy {
t.Fatal("a relayer with no threshold signing key reported healthy")
}
if res.Details["status"] != "no threshold signing key" {
t.Fatalf("health does not say what is missing: %q", res.Details["status"])
}
if res.Details["chains"] != "1" {
t.Fatalf("health does not say how many chains it relays: %q", res.Details["chains"])
}
require.NoError(t, err)
require.False(t, res.Healthy, "a relayer with no threshold signing key reported healthy")
require.Equal(t, "no threshold signing key", res.Details["status"])
require.Equal(t, "1", res.Details["chains"])
}
// The endpoint and the node's own check must not disagree about the same node.
func TestHealthEndpointAgreesWithTheNodeCheck(t *testing.T) {
vm := &VM{
log: log.NewNoOpLogger(),
pendingBridges: make(map[ids.ID]*BridgeRequest),
evmByChainID: map[uint32]ChainClient{96368: &fakeSource{}},
}
vm := boot(t)
watcherOn(vm, &fakeSource{})
res, err := vm.HealthCheck(context.Background())
if err != nil {
t.Fatalf("health check: %v", err)
}
require.NoError(t, err)
var reply HealthReply
if err := (&Service{vm: vm}).Health(nil, &HealthArgs{}, &reply); err != nil {
t.Fatalf("health rpc: %v", err)
}
if reply.Ready != res.Healthy || reply.Status != res.Details["status"] {
t.Fatalf("the endpoint says %q/%v and the node says %q/%v",
reply.Status, reply.Ready, res.Details["status"], res.Healthy)
}
require.NoError(t, (&Service{vm: vm}).Health(nil, &HealthArgs{}, &reply))
require.Equal(t, res.Healthy, reply.Ready)
require.Equal(t, res.Details["status"], reply.Status)
}
// The bridge's API is the JSON-RPC service, and that is what gets served.
func TestTheServedAPIIsTheRealOne(t *testing.T) {
vm := &VM{log: log.NewNoOpLogger()}
handlers, err := vm.CreateHandlers(context.Background())
if err != nil {
t.Fatalf("create handlers: %v", err)
}
if _, ok := handlers["/rpc"]; !ok {
t.Fatalf("the bridge API is not served; handlers are %v", handlers)
}
require.NoError(t, err)
require.Contains(t, handlers, "/rpc")
for _, canned := range []string{"/status", "/validators", "/bridge"} {
if _, ok := handlers[canned]; ok {
t.Errorf("%s is served again, and it answers the same thing whatever the state", canned)
}
require.NotContains(t, handlers, canned,
"%s is served again, and it answers the same thing whatever the state", canned)
}
mux, err := vm.NewHTTPHandler(context.Background())
require.NoError(t, err)
require.NotNil(t, mux)
static, err := vm.CreateStaticHandlers(context.Background())
require.NoError(t, err)
require.Nil(t, static)
}
+63 -142
View File
@@ -4,99 +4,89 @@
package bridgevm
import (
"bytes"
"fmt"
"sort"
"time"
"github.com/luxfi/ids"
"github.com/luxfi/zap"
)
// Native-ZAP struct-is-wire for B-Chain. No pcodecs, no reflection, no codec
// registry — each type owns its Marshal/Parse over a zap object. Re-genesis
// authorized, so the on-disk/wire format is these offsets (canonical: Parse
// rejects trailing bytes).
// authorized, so the on-disk/wire format is these offsets.
//
// The encoding is CANONICAL: Parse accepts only bytes Marshal would produce.
// It checks that by encoding what it read and comparing, which covers every
// way a wire object can carry slack at once — trailing bytes, padding inside
// a declared length, a longer list than its contents, signer ids out of
// order. Slack is not cosmetic here: the block id is a hash of the block's
// contents, so a block that tolerates padding has an unbounded number of
// distinct encodings under one id, and two nodes holding "the same" block
// hold different bytes.
//
// Note: Genesis is JSON at the genesis/config boundary (see vm.go), not a
// binary wire type — it is intentionally not encoded here.
// maxBlockBytes bounds what a peer can make this node parse and hash. A block
// is a parent id, a height, a timestamp and at most maxRequestsPerBlock
// transfers of brSize bytes plus a 20-byte recipient — a few tens of
// kilobytes. Anything past this is not a block of this chain, and deciding
// that before decoding is the difference between refusing a message and
// allocating whatever the sender asked for.
const maxBlockBytes = 1 << 17 // 128 KiB
// ---- BridgeRequest ----
//
// Fixed object (variable fields are bytes/list ptrs):
// Fixed object (Recipient is the one variable field):
//
// ID 32B @ 0
// ID 32B @ 0 (== BridgeTransfer.Digest of the fields below)
// Asset 32B @ 32
// SourceTxID 32B @ 64
// SrcChainID u32 @ 96
// DstChainID u32 @ 100
// Confirmations u32 @ 104
// Nonce u64 @ 108
// Amount u64 @ 116
// CreatedAt i64 @ 124 (unix nanos)
// SourceChain bytes@ 132
// DestChain bytes@ 140
// Status bytes@ 148
// Recipient bytes@ 156
// MPCSigLens list @ 164 (u32 per sig)
// MPCSigBlob bytes@ 172
// Nonce u64 @ 104
// Amount u64 @ 112
// Recipient bytes@ 120
//
// Everything but SourceTxID is covered by ID. What is NOT here is as
// deliberate: a confirmation depth and an observation time are each node's own
// reading of a source chain, so carrying them made two nodes encode the same
// block differently and made a follower check the proposer's claim instead of
// its own. A chain label is a local name for a number that is already here.
const (
brID = 0
brAsset = 32
brSourceTx = 64
brSrcChain = 96
brDstChain = 100
brConfirm = 104
brNonce = 108
brAmount = 116
brCreated = 124
brSrcName = 132
brDstName = 140
brStatus = 148
brRecip = 156
brSigLens = 164
brSigBlob = 172
brSize = 180
brNonce = 104
brAmount = 112
brRecip = 120
brSize = 128
)
// marshalBridgeRequest encodes r into a standalone wire object.
func marshalBridgeRequest(r *BridgeRequest) []byte {
b := zap.NewBuilder(zap.HeaderSize + brSize + len(r.SourceChain) + len(r.DestChain) +
len(r.Status) + len(r.Recipient) + sigBlobLen(r.MPCSignatures) + 4*len(r.MPCSignatures) + 128)
sigLensOff := writeU32List(b, sigLens(r.MPCSignatures))
b := zap.NewBuilder(zap.HeaderSize + brSize + len(r.Recipient) + 64)
ob := b.StartObject(brSize)
ob.SetBytesFixed(brID, r.ID[:])
ob.SetBytesFixed(brAsset, r.Asset[:])
ob.SetBytesFixed(brSourceTx, r.SourceTxID[:])
ob.SetUint32(brSrcChain, r.SrcChainID)
ob.SetUint32(brDstChain, r.DstChainID)
ob.SetUint32(brConfirm, r.Confirmations)
ob.SetUint64(brNonce, r.Nonce)
ob.SetUint64(brAmount, r.Amount)
ob.SetInt64(brCreated, r.CreatedAt.UnixNano())
ob.SetBytes(brSrcName, []byte(r.SourceChain))
ob.SetBytes(brDstName, []byte(r.DestChain))
ob.SetBytes(brStatus, []byte(r.Status))
ob.SetBytes(brRecip, r.Recipient)
ob.SetList(brSigLens, sigLensOff, len(r.MPCSignatures))
ob.SetBytes(brSigBlob, flattenSigs(r.MPCSignatures))
ob.FinishAsRoot()
return b.Finish()
}
func readBridgeRequest(o zap.Object) *BridgeRequest {
r := &BridgeRequest{
SrcChainID: o.Uint32(brSrcChain),
DstChainID: o.Uint32(brDstChain),
Confirmations: o.Uint32(brConfirm),
Nonce: o.Uint64(brNonce),
Amount: o.Uint64(brAmount),
CreatedAt: time.Unix(0, o.Int64(brCreated)).UTC(),
SourceChain: string(o.Bytes(brSrcName)),
DestChain: string(o.Bytes(brDstName)),
Status: BridgeRequestStatus(o.Bytes(brStatus)),
Recipient: appendBytes(o.Bytes(brRecip)),
MPCSignatures: readSigs(o, brSigLens, brSigBlob),
SrcChainID: o.Uint32(brSrcChain),
DstChainID: o.Uint32(brDstChain),
Nonce: o.Uint64(brNonce),
Amount: o.Uint64(brAmount),
Recipient: appendBytes(o.Bytes(brRecip)),
}
copy(r.ID[:], o.BytesFixedSlice(brID, 32))
copy(r.Asset[:], o.BytesFixedSlice(brAsset, 32))
@@ -111,23 +101,16 @@ func readBridgeRequest(o zap.Object) *BridgeRequest {
// BlockTimestamp i64 @ 40
// ReqLens list @ 48 (u32 per BridgeRequest wire length)
// ReqBlob bytes@ 56 (concatenated BridgeRequest wire objects)
// SigNodeIDs bytes@ 64 (concatenated 20-byte NodeIDs, sorted)
// SigLens list @ 72 (u32 per MPC sig)
// SigBlob bytes@ 80
const (
blkParent = 0
blkHeight = 32
blkTime = 40
blkReqLens = 48
blkReqBlob = 56
blkSigNodes = 64
blkSigLens = 72
blkSigBlob = 80
blkSize = 88
blkParent = 0
blkHeight = 32
blkTime = 40
blkReqLens = 48
blkReqBlob = 56
blkSize = 64
)
func (b *Block) Marshal() ([]byte, error) {
// per-request wire objects
var reqBlob []byte
reqLens := make([]uint32, 0, len(b.BridgeRequests))
for _, r := range b.BridgeRequests {
@@ -135,23 +118,9 @@ func (b *Block) Marshal() ([]byte, error) {
reqLens = append(reqLens, uint32(len(rb)))
reqBlob = append(reqBlob, rb...)
}
// sorted map -> (nodeIDs blob, sigs)
nodeIDs := make([]ids.NodeID, 0, len(b.MPCSignatures))
for id := range b.MPCSignatures {
nodeIDs = append(nodeIDs, id)
}
sort.Slice(nodeIDs, func(i, j int) bool { return nodeIDs[i].Compare(nodeIDs[j]) < 0 })
var nodeBlob []byte
sigs := make([][]byte, 0, len(nodeIDs))
for _, id := range nodeIDs {
nodeBlob = append(nodeBlob, id[:]...)
sigs = append(sigs, b.MPCSignatures[id])
}
bld := zap.NewBuilder(zap.HeaderSize + blkSize + len(reqBlob) + len(nodeBlob) +
sigBlobLen(sigs) + 4*(len(reqLens)+len(sigs)) + 256)
bld := zap.NewBuilder(zap.HeaderSize + blkSize + len(reqBlob) + 4*len(reqLens) + 128)
reqLensOff := writeU32List(bld, reqLens)
sigLensOff := writeU32List(bld, sigLens(sigs))
ob := bld.StartObject(blkSize)
ob.SetBytesFixed(blkParent, b.ParentID_[:])
@@ -159,28 +128,27 @@ func (b *Block) Marshal() ([]byte, error) {
ob.SetInt64(blkTime, b.BlockTimestamp)
ob.SetList(blkReqLens, reqLensOff, len(reqLens))
ob.SetBytes(blkReqBlob, reqBlob)
ob.SetBytes(blkSigNodes, nodeBlob)
ob.SetList(blkSigLens, sigLensOff, len(sigs))
ob.SetBytes(blkSigBlob, flattenSigs(sigs))
ob.FinishAsRoot()
return bld.Finish(), nil
}
func parseBlockBytes(data []byte, blk *Block) error {
if len(data) > maxBlockBytes {
return fmt.Errorf("bridgevm block: %d bytes over the %d cap", len(data), maxBlockBytes)
}
msg, err := zap.Parse(data)
if err != nil {
return err
}
if msg.Size() != len(data) {
return fmt.Errorf("bridgevm block: trailing bytes")
}
o := msg.Root()
copy(blk.ParentID_[:], o.BytesFixedSlice(blkParent, 32))
blk.BlockHeight = o.Uint64(blkHeight)
blk.BlockTimestamp = o.Int64(blkTime)
// requests
reqLens := readU32List(o, blkReqLens)
if len(reqLens) > maxRequestsPerBlock {
return fmt.Errorf("bridgevm block: %d transfers over the %d cap", len(reqLens), maxRequestsPerBlock)
}
reqBlob := o.Bytes(blkReqBlob)
blk.BridgeRequests = make([]*BridgeRequest, 0, len(reqLens))
pos := 0
@@ -196,24 +164,16 @@ func parseBlockBytes(data []byte, blk *Block) error {
pos += int(l)
}
// mpc signatures map
nodeBlob := o.Bytes(blkSigNodes)
sigLens := readU32List(o, blkSigLens)
sigBlob := o.Bytes(blkSigBlob)
blk.MPCSignatures = make(map[ids.NodeID][]byte, len(sigLens))
sp := 0
for i, l := range sigLens {
var id ids.NodeID
if (i+1)*20 > len(nodeBlob) {
return fmt.Errorf("bridgevm block: nodeID blob out of bounds")
}
copy(id[:], nodeBlob[i*20:(i+1)*20])
if sp+int(l) > len(sigBlob) {
return fmt.Errorf("bridgevm block: sig blob out of bounds")
}
blk.MPCSignatures[id] = appendBytes(sigBlob[sp : sp+int(l)])
sp += int(l)
// Canonical form: the bytes must be what encoding what we read produces.
// Anything else is a second encoding of one block.
again, err := blk.Marshal()
if err != nil {
return err
}
if !bytes.Equal(again, data) {
return fmt.Errorf("bridgevm block: encoding is not canonical")
}
blk.bytes = data
return nil
}
@@ -238,45 +198,6 @@ func readU32List(o zap.Object, ptrOff int) []uint32 {
return out
}
func sigLens(sigs [][]byte) []uint32 {
out := make([]uint32, len(sigs))
for i, s := range sigs {
out[i] = uint32(len(s))
}
return out
}
func flattenSigs(sigs [][]byte) []byte {
var out []byte
for _, s := range sigs {
out = append(out, s...)
}
return out
}
func sigBlobLen(sigs [][]byte) int {
n := 0
for _, s := range sigs {
n += len(s)
}
return n
}
func readSigs(o zap.Object, lensOff, blobOff int) [][]byte {
lens := readU32List(o, lensOff)
blob := o.Bytes(blobOff)
out := make([][]byte, 0, len(lens))
pos := 0
for _, l := range lens {
if pos+int(l) > len(blob) {
break
}
out = append(out, appendBytes(blob[pos:pos+int(l)]))
pos += int(l)
}
return out
}
func appendBytes(b []byte) []byte {
if len(b) == 0 {
return nil
-30
View File
@@ -1,30 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package types
// Proof is the verification artifact emitted at ceremony Finalize. It
// is consumed by Quasar 3.0's cert pipeline (LP-020) and bound into
// certificate_subject (LP-134).
//
// The substrate defines the envelope; the per-protocol payload (the
// actual signature, group public key, FHE key, etc.) is opaque bytes
// indexed by Lane. Verifiers in cert/lane.go decode it.
type Proof struct {
CeremonyID CeremonyID
Kind CeremonyKind
Lane CertLane
// Payload is the protocol-specific final artifact: a signature
// for sign-oriented ceremonies, a TFHE evaluation key for keygen
// ceremonies, etc.
Payload []byte
// Aggregate is the aggregate signature over the participant
// commitments (used when the chain itself attests to the
// ceremony before forwarding to Quasar).
Aggregate [64]byte
}
// Empty reports whether p is the zero value.
func (p Proof) Empty() bool {
return p.Kind == KindUnknown && len(p.Payload) == 0
}