bridgevm: build a block from its inputs, not from map iteration order

BuildBlock collected the block's bridge requests by ranging vm.pendingBridges, a
map. Go randomises map iteration, and that iteration decided two things that must
be functions of the pending set:

  - the ORDER of BridgeRequests, which is hashed into the block id
    (block.go computeID writes each req.ID in slice order) and serialised to the
    wire. The same pending set therefore produced a different block on every
    build. The sibling map in the same wire format is already sorted by NodeID
    (wire.go:140) — BridgeRequests was the one that was missed.
  - WHICH requests the block carries, because the 100-request cap broke out of
    the range. With more than 100 ready, the included subset was a random draw,
    so a request could be passed over indefinitely for no reason.

Proven: two builds over one 24-request pending set produced different orders, and
the capped build over 140 requests carried a random subset.

Candidates are now sorted by request id and the cap takes a prefix of that order.

Tests: TestBuildBlockOrdersRequestsDeterministically,
TestBuildBlockCapIsADeterministicPrefix.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-07-29 16:48:10 -07:00
co-authored by Hanzo Dev
parent c7aa384e15
commit 9a2e4fa7d3
2 changed files with 121 additions and 8 deletions
+99
View File
@@ -0,0 +1,99 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package bridgevm
import (
"bytes"
"context"
"sort"
"testing"
"time"
"github.com/stretchr/testify/require"
"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.
func vmWithPending(t *testing.T, n int) (*VM, []ids.ID) {
t.Helper()
vm := &VM{
db: memdb.New(),
log: log.NewNoOpLogger(),
config: BridgeConfig{MinConfirmations: 1, MaxBridgeAmount: 1 << 40, DailyBridgeLimit: 1 << 40},
pendingBridges: make(map[ids.ID]*BridgeRequest, n),
pendingBlocks: make(map[ids.ID]*Block),
bridgeRegistry: &BridgeRegistry{DailyVolume: make(map[string]uint64)},
}
parent := &Block{BlockHeight: 0, BlockTimestamp: 0, ParentID_: ids.Empty, BridgeRequests: []*BridgeRequest{}, vm: vm}
parent.ID_ = parent.computeID()
require.NoError(t, vm.putBlock(parent))
vm.lastAcceptedID = parent.ID()
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)
}
sort.Slice(want, func(i, j int) bool { return bytes.Compare(want[i][:], want[j][:]) < 0 })
return vm, want
}
// TestBuildBlockOrdersRequestsDeterministically proves a block is a function of
// its inputs. BuildBlock collected requests by ranging pendingBridges, a map, so
// Go's randomised iteration decided the order — and that order is hashed into the
// block id (computeID) and written to the wire. The same pending set produced a
// different block on every build.
func TestBuildBlockOrdersRequestsDeterministically(t *testing.T) {
require := require.New(t)
vm, want := vmWithPending(t, 24)
blk, err := vm.BuildBlock(context.Background())
require.NoError(err)
got := make([]ids.ID, 0, len(want))
for _, req := range blk.(*Block).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)
require.Equal(blk.ID(), again.ID(), "the same pending set must build the same block")
require.Equal(blk.Bytes(), again.Bytes())
}
// TestBuildBlockCapIsADeterministicPrefix proves the size cap selects a defined
// subset. Applied while ranging a map it picked 100 requests at random out of the
// ready set, so a request could be passed over indefinitely for no reason and two
// builds carried different work.
func TestBuildBlockCapIsADeterministicPrefix(t *testing.T) {
require := require.New(t)
vm, want := vmWithPending(t, maxRequestsPerBlock+40)
blk, err := vm.BuildBlock(context.Background())
require.NoError(err)
got := make([]ids.ID, 0, maxRequestsPerBlock)
for _, req := range blk.(*Block).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")
}
+22 -8
View File
@@ -4,11 +4,13 @@
package bridgevm
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"sort"
"sync"
"time"
@@ -169,6 +171,10 @@ const (
MPCRequestRefresh
)
// maxRequestsPerBlock caps how many bridge requests one block carries. Applied to
// the id-sorted candidate list, so the cap is a deterministic prefix.
const maxRequestsPerBlock = 100
// VM implements the Bridge VM for cross-chain interoperability
type VM struct {
rt *runtime.Runtime
@@ -428,18 +434,26 @@ func (vm *VM) BuildBlock(ctx context.Context) (chain.Block, error) {
return nil, fmt.Errorf("failed to get parent block: %w", err)
}
// Collect bridge requests that are ready
var requests []*BridgeRequest
// Collect bridge requests that are ready, then order them by request id.
// pendingBridges is a map, and Go randomises map iteration: taking the first
// maxRequestsPerBlock straight out of the range made both the ORDER of the
// block's requests and, once more than that many are ready, WHICH requests it
// carries vary run to run. That order is hashed into the block id
// (block.go computeID) and written to the wire, so the same pending set
// produced a different block every time and a request could be starved at
// random. Sorting first makes the block a function of its inputs and the cap a
// deterministic prefix.
requests := make([]*BridgeRequest, 0, len(vm.pendingBridges))
for _, req := range vm.pendingBridges {
// Check if request has enough confirmations
if req.Confirmations >= vm.config.MinConfirmations {
requests = append(requests, req)
}
// Limit block size
if len(requests) >= 100 {
break
}
}
sort.Slice(requests, func(i, j int) bool {
return bytes.Compare(requests[i].ID[:], requests[j].ID[:]) < 0
})
if len(requests) > maxRequestsPerBlock {
requests = requests[:maxRequestsPerBlock]
}
if len(requests) == 0 {