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