mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
bridgevm: a transfer is released only once its block is durable
Accept credited the daily volume, completed the requests, advanced lastAcceptedID, dropped the block from the in-flight set and handed the transfers to the release worker — which broadcasts on an external chain — and only then wrote the block. Every one of those survived a failed write, so a release could be in flight for a transfer no block records. lastAcceptedID was never written at all. The tip lived in memory, reset to genesis on every restart, and the daily volume that bounds the bridge limit went with it. The block now commits through the shared store, which writes it with its height entry and the tip in one batch, and its effects — including the handoff to the release worker — happen only afterwards. A proposal follows the engine's preference, which the store now holds, rather than always the accepted tip. The bridge's own state keeps its own lock. The transfers in flight, the signer set, the chain clients and the release plumbing are not the chain's state, and folding them into the chain's lock would say they were. The order is the chain's lock, then the bridge's, then the registry's, and it is written down where the locks are. Still in memory only, and NOT changed here because it needs its own design: the completed-bridge registry and the daily volume. A restart still forgets both, which re-opens the daily bridge limit.
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
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
|
||||
// broadcasts on an external chain — and only then wrote the block. A write
|
||||
// that failed left all of it done, including a release in flight for a
|
||||
// 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)
|
||||
|
||||
built, err := vm.BuildBlock(context.Background())
|
||||
require.NoError(t, err)
|
||||
blk := built.(*Block)
|
||||
|
||||
tipBefore, heightBefore := vm.chain.Tip()
|
||||
|
||||
db.refuse = true
|
||||
require.ErrorIs(t, blk.Accept(context.Background()), errRefused)
|
||||
|
||||
// The irreversible part: nothing was handed to the release worker.
|
||||
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")
|
||||
|
||||
vm.mu.RLock()
|
||||
_, stillPending := vm.pendingBridges[req.ID]
|
||||
vm.mu.RUnlock()
|
||||
require.True(t, stillPending, "the transfer is still in flight, so it is not lost")
|
||||
|
||||
tip, height := vm.chain.Tip()
|
||||
require.Equal(t, tipBefore, tip)
|
||||
require.Equal(t, heightBefore, height)
|
||||
|
||||
// The block is not committed. It is still in flight, which is right — the
|
||||
// engine may put it to a vote again — but nothing about it is on disk.
|
||||
require.False(t, vm.chain.Accepted(blk.ID()))
|
||||
found, err := vm.GetBlock(context.Background(), blk.ID())
|
||||
require.NoError(t, err, "a block in flight is still findable as a parent")
|
||||
require.Equal(t, blk.ID(), found.ID())
|
||||
|
||||
// And it can be applied again once the database will take it.
|
||||
db.refuse = false
|
||||
require.NoError(t, blk.Accept(context.Background()))
|
||||
require.True(t, vm.chain.Accepted(blk.ID()))
|
||||
require.Len(t, vm.releaser.queue, 1)
|
||||
}
|
||||
|
||||
// TestAnAcceptedBlockIsDurableAndReleased is the other half, and pins the tip
|
||||
// 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)
|
||||
|
||||
built, err := vm.BuildBlock(context.Background())
|
||||
require.NoError(t, err)
|
||||
blk := built.(*Block)
|
||||
|
||||
require.NoError(t, blk.Accept(context.Background()))
|
||||
|
||||
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)
|
||||
|
||||
tip, height := vm.chain.Tip()
|
||||
require.Equal(t, blk.ID(), tip)
|
||||
require.Equal(t, uint64(1), height)
|
||||
|
||||
// A second store over the same database is a restart: the tip is where the
|
||||
// chain left it, not back at genesis.
|
||||
restarted := chain.New[*Block](db, nil)
|
||||
at, fresh, err := restarted.Open(vm.genesisBlock, vm.parseBlock)
|
||||
require.NoError(t, err)
|
||||
require.False(t, fresh)
|
||||
require.Equal(t, blk.ID(), at.ID())
|
||||
}
|
||||
+32
-32
@@ -11,7 +11,9 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/chains/chain"
|
||||
"github.com/luxfi/consensus/core/choices"
|
||||
"github.com/luxfi/database"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/log"
|
||||
"github.com/luxfi/threshold/pkg/ecdsa"
|
||||
@@ -72,37 +74,47 @@ func (b *Block) computeID() ids.ID {
|
||||
return ids.ID(h.Sum(nil))
|
||||
}
|
||||
|
||||
// Accept marks the block as accepted
|
||||
// Accept records the block and everything it decides, in one commit.
|
||||
//
|
||||
// It used to credit the daily volume, complete the requests, advance
|
||||
// lastAcceptedID and hand the transfers to the release worker — which
|
||||
// broadcasts on an external chain — and only then write the block. A write
|
||||
// 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 {
|
||||
b.vm.mu.Lock()
|
||||
defer b.vm.mu.Unlock()
|
||||
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 }
|
||||
|
||||
// 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
|
||||
// started for a transfer the chain has durably recorded.
|
||||
func (b *Block) Publish() {
|
||||
b.status = choices.Accepted
|
||||
|
||||
// Process bridge requests
|
||||
for _, req := range b.BridgeRequests {
|
||||
// Update bridge registry
|
||||
b.vm.bridgeRegistry.mu.Lock()
|
||||
|
||||
completed := &CompletedBridge{
|
||||
RequestID: req.ID,
|
||||
SourceTxID: req.SourceTxID,
|
||||
CompletedAt: time.Now(),
|
||||
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
|
||||
|
||||
// Update daily volume
|
||||
volume := b.vm.bridgeRegistry.DailyVolume[req.DestChain]
|
||||
b.vm.bridgeRegistry.DailyVolume[req.DestChain] = volume + req.Amount
|
||||
|
||||
b.vm.bridgeRegistry.DailyVolume[req.DestChain] += req.Amount
|
||||
b.vm.bridgeRegistry.mu.Unlock()
|
||||
|
||||
// Remove from pending
|
||||
b.vm.mu.Lock()
|
||||
delete(b.vm.pendingBridges, req.ID)
|
||||
b.vm.mu.Unlock()
|
||||
|
||||
b.vm.log.Info("completed bridge request",
|
||||
log.Stringer("requestID", req.ID),
|
||||
@@ -111,33 +123,19 @@ func (b *Block) Accept(ctx context.Context) error {
|
||||
)
|
||||
|
||||
// Hand the confirmed transfer to the release worker (relayer nodes only).
|
||||
// Non-blocking: the actual attestation + EVM broadcast happen off the
|
||||
// consensus path so Accept never waits on network I/O. Every relayer
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// Update state
|
||||
b.status = choices.Accepted
|
||||
b.vm.lastAcceptedID = b.ID()
|
||||
|
||||
// Remove from pending blocks
|
||||
delete(b.vm.pendingBlocks, b.ID())
|
||||
|
||||
// Persist block
|
||||
return b.vm.putBlock(b)
|
||||
}
|
||||
|
||||
// Reject marks the block as rejected
|
||||
func (b *Block) Reject(ctx context.Context) error {
|
||||
b.vm.mu.Lock()
|
||||
defer b.vm.mu.Unlock()
|
||||
|
||||
b.status = choices.Rejected
|
||||
delete(b.vm.pendingBlocks, b.ID())
|
||||
|
||||
b.vm.chain.Drop(b.ID())
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -329,3 +327,5 @@ func (b *Block) Bytes() []byte {
|
||||
b.bytes = bytes
|
||||
return bytes
|
||||
}
|
||||
|
||||
var _ chain.Block = (*Block)(nil)
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/luxfi/chains/chain"
|
||||
"github.com/luxfi/database/memdb"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/log"
|
||||
@@ -22,18 +23,17 @@ 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)},
|
||||
}
|
||||
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()
|
||||
require.NoError(t, vm.putBlock(parent))
|
||||
vm.lastAcceptedID = parent.ID()
|
||||
_, _, err := vm.chain.Open(parent, vm.parseBlock)
|
||||
require.NoError(t, err)
|
||||
|
||||
want := make([]ids.ID, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
|
||||
+4
-17
@@ -4,25 +4,12 @@
|
||||
package bridgevm
|
||||
|
||||
import (
|
||||
"github.com/luxfi/chains/chain"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/log"
|
||||
"github.com/luxfi/node/vms"
|
||||
)
|
||||
|
||||
var _ vms.Factory = (*Factory)(nil)
|
||||
|
||||
// VMID is the unique identifier for BridgeVM (B-Chain)
|
||||
var VMID = ids.ID{'b', 'r', 'i', 'd', 'g', 'e', 'v', 'm', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
|
||||
var VMID = ids.ID{'b', 'r', 'i', 'd', 'g', 'e', 'v', 'm'}
|
||||
|
||||
// Factory creates new BridgeVM instances
|
||||
type Factory struct{}
|
||||
|
||||
// New returns a new instance of the BridgeVM
|
||||
func (f *Factory) New(logger log.Logger) (interface{}, error) {
|
||||
return &VM{
|
||||
pendingBlocks: make(map[ids.ID]*Block),
|
||||
pendingBridges: make(map[ids.ID]*BridgeRequest),
|
||||
chainClients: make(map[string]ChainClient),
|
||||
evmByChainID: make(map[uint32]ChainClient),
|
||||
}, nil
|
||||
}
|
||||
// Factory creates B-Chain VM instances.
|
||||
type Factory = chain.Factory[VM]
|
||||
|
||||
+7
-26
@@ -4,36 +4,17 @@
|
||||
package bridgevm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/luxfi/constants"
|
||||
"github.com/luxfi/node/vms/types/fee"
|
||||
)
|
||||
|
||||
// newFeePolicy returns the canonical B-Chain FeePolicy. B-Chain accepts
|
||||
// user-submitted bridge transfers (InitiateBridgeTransfer) that drive
|
||||
// MPC signing capacity, so it MUST charge a non-zero floor; see
|
||||
// vms/types/fee/policy.go.
|
||||
func newFeePolicy(networkID uint32) fee.Policy {
|
||||
return fee.FlatPolicy{
|
||||
Fee: fee.MinTxFeeFloor,
|
||||
AssetID: constants.UTXOAssetIDFor(networkID),
|
||||
}
|
||||
}
|
||||
// gateUserBridgeFee refuses a bridge transfer whose declared fee does not meet
|
||||
// the chain's declared floor (LP-0130 §8: B-Chain fees). Called on the user-tx
|
||||
// entry before any M-Chain signing capacity is consumed.
|
||||
func (vm *VM) gateUserBridgeFee(paid uint64) error { return vm.fee.Admit(paid) }
|
||||
|
||||
// gateUserBridgeFee refuses a bridge transfer whose declared fee does not
|
||||
// satisfy the configured FeePolicy (LP-0130 §8: B-Chain fees). Called on
|
||||
// the user-tx entry before any M-Chain signing capacity is consumed.
|
||||
func (vm *VM) gateUserBridgeFee(paid uint64) error {
|
||||
if vm.feePolicy == nil {
|
||||
return fmt.Errorf("bridgevm: fee policy not initialized")
|
||||
}
|
||||
return vm.feePolicy.ValidateFee(paid, constants.UTXOAssetIDFor(vm.networkID))
|
||||
}
|
||||
|
||||
// FeePolicy exposes the chain's declared fee policy for diagnostics
|
||||
// and the boot-time Validate gate.
|
||||
func (vm *VM) FeePolicy() fee.Policy { return vm.feePolicy }
|
||||
// FeePolicy exposes the chain's declared fee policy for diagnostics and the
|
||||
// boot-time Validate gate.
|
||||
func (vm *VM) FeePolicy() fee.Policy { return vm.fee.Policy() }
|
||||
|
||||
// mpcGroupPublicKey returns the serialized CGGMP21 custody group public key
|
||||
// (established by M-Chain's dealerless keygen), or nil if keygen has not
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/chains/chain"
|
||||
"github.com/luxfi/log"
|
||||
"github.com/luxfi/node/vms/types/fee"
|
||||
)
|
||||
@@ -16,8 +17,7 @@ import (
|
||||
// custody keygen handshake, 1M LUX bond validation, etc.).
|
||||
func newBridgeVMWithPolicy(networkID uint32) *VM {
|
||||
v := &VM{log: log.NewNoOpLogger()}
|
||||
v.networkID = networkID
|
||||
v.feePolicy = newFeePolicy(networkID)
|
||||
v.fee = chain.Floor(networkID)
|
||||
return v
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -84,8 +84,9 @@ func newReleaser(vm *VM) *releaser {
|
||||
return r
|
||||
}
|
||||
|
||||
// enqueue is a non-blocking submit called from Block.Accept (under vm.mu). If the
|
||||
// queue is full the request is dropped with a warning; it will be re-observed.
|
||||
// enqueue is a non-blocking submit, called once a block carrying the transfer
|
||||
// has committed. If the queue is full the request is dropped with a warning; it
|
||||
// will be re-observed.
|
||||
func (r *releaser) enqueue(req *BridgeRequest) {
|
||||
select {
|
||||
case r.queue <- req:
|
||||
|
||||
+106
-136
@@ -16,8 +16,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/chains/chain"
|
||||
"github.com/luxfi/chains/internal/warpmsg"
|
||||
"github.com/luxfi/database"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/log"
|
||||
"github.com/luxfi/node/version"
|
||||
@@ -27,12 +27,12 @@ import (
|
||||
"github.com/luxfi/threshold/pkg/pool"
|
||||
"github.com/luxfi/threshold/protocols/cmp/config"
|
||||
vmcore "github.com/luxfi/vm"
|
||||
"github.com/luxfi/vm/chain"
|
||||
vmchain "github.com/luxfi/vm/chain"
|
||||
"github.com/luxfi/warp"
|
||||
)
|
||||
|
||||
var (
|
||||
_ chain.ChainVM = (*VM)(nil)
|
||||
_ vmchain.ChainVM = (*VM)(nil)
|
||||
|
||||
Version = &version.Semantic{
|
||||
Major: 1,
|
||||
@@ -180,7 +180,6 @@ const maxRequestsPerBlock = 100
|
||||
// VM implements the Bridge VM for cross-chain interoperability
|
||||
type VM struct {
|
||||
rt *runtime.Runtime
|
||||
db database.Database
|
||||
config BridgeConfig
|
||||
toEngine chan<- vmcore.Message
|
||||
log log.Logger
|
||||
@@ -226,17 +225,23 @@ type VM struct {
|
||||
// source chain reports a lock.
|
||||
work vmcore.Latch
|
||||
|
||||
// Block management
|
||||
preferred ids.ID
|
||||
lastAcceptedID ids.ID
|
||||
pendingBlocks map[ids.ID]*Block
|
||||
// chain is the durable state, the blocks in flight and the tip, under its
|
||||
// own lock. What a block decides is written and committed there.
|
||||
chain *chain.Store[*Block]
|
||||
genesisBlock *Block
|
||||
|
||||
// Fee policy gating user-submitted bridge transfers
|
||||
// (InitiateBridgeTransfer). FlatPolicy at MinTxFeeFloor;
|
||||
// consensus-internal paths bypass.
|
||||
feePolicy fee.Policy
|
||||
networkID uint32
|
||||
// fee is what this chain charges to admit a user-submitted transfer, before
|
||||
// any M-Chain signing capacity is consumed.
|
||||
fee chain.Fee
|
||||
|
||||
// mu guards the bridge's own state above — the transfers in flight, the
|
||||
// signer set, the chain clients, the release plumbing. That is not the
|
||||
// chain's state and does not share the chain's lock.
|
||||
//
|
||||
// LOCK ORDER, without exception: the chain's lock, then this one, then the
|
||||
// registry's. A block publishes under the chain's lock and completes its
|
||||
// transfers inside it, so any path taking them the other way round would
|
||||
// deadlock.
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
@@ -308,7 +313,6 @@ func (vm *VM) Initialize(
|
||||
) error {
|
||||
// Convert chain runtime to Runtime.
|
||||
vm.rt = vmInit.Runtime
|
||||
vm.db = vmInit.DB
|
||||
vm.toEngine = vmInit.ToEngine
|
||||
|
||||
if logger, ok := vm.rt.Log.(log.Logger); ok {
|
||||
@@ -317,19 +321,18 @@ func (vm *VM) Initialize(
|
||||
return errors.New("invalid logger type")
|
||||
}
|
||||
|
||||
vm.pendingBlocks = make(map[ids.ID]*Block)
|
||||
vm.pendingBridges = make(map[ids.ID]*BridgeRequest)
|
||||
vm.chainClients = make(map[string]ChainClient)
|
||||
vm.evmByChainID = make(map[uint32]ChainClient)
|
||||
|
||||
// Pin fee policy. B-Chain accepts user-submitted bridge transfers
|
||||
// so attach the canonical FlatPolicy at MinTxFeeFloor; fee.Validate
|
||||
// refuses zero-fee user-facing chains at boot.
|
||||
// B-Chain accepts user-submitted bridge transfers, so it declares the
|
||||
// floor; the node's boot-time Validate refuses a zero-fee user-facing chain.
|
||||
var networkID uint32
|
||||
if vm.rt != nil {
|
||||
vm.networkID = vm.rt.NetworkID
|
||||
networkID = vm.rt.NetworkID
|
||||
}
|
||||
vm.feePolicy = newFeePolicy(vm.networkID)
|
||||
if err := fee.Validate(vm.feePolicy); err != nil {
|
||||
vm.fee = chain.Floor(networkID)
|
||||
if err := fee.Validate(vm.fee.Policy()); err != nil {
|
||||
return fmt.Errorf("bridgevm: fee policy: %w", err)
|
||||
}
|
||||
|
||||
@@ -419,97 +422,95 @@ func (vm *VM) Initialize(
|
||||
}
|
||||
|
||||
genesisBlock.ID_ = genesisBlock.computeID()
|
||||
vm.lastAcceptedID = genesisBlock.ID()
|
||||
vm.genesisBlock = genesisBlock
|
||||
|
||||
return vm.putBlock(genesisBlock)
|
||||
_, _, err := vm.chain.Open(genesisBlock, vm.parseBlock)
|
||||
return err
|
||||
}
|
||||
|
||||
// BuildBlock implements the chain.ChainVM interface
|
||||
func (vm *VM) BuildBlock(ctx context.Context) (chain.Block, error) {
|
||||
vm.mu.Lock()
|
||||
defer vm.mu.Unlock()
|
||||
|
||||
// Check if we have pending bridge requests
|
||||
if len(vm.pendingBridges) == 0 {
|
||||
return nil, errors.New("no pending bridge requests")
|
||||
// parseBlock decodes a block belonging to this VM. The store reads accepted
|
||||
// blocks back through it, so there is one decoder rather than one per caller.
|
||||
func (vm *VM) parseBlock(raw []byte) (*Block, error) {
|
||||
blk := &Block{vm: vm}
|
||||
if err := parseBlockBytes(raw, blk); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get parent block
|
||||
parentID := vm.preferred
|
||||
if parentID == ids.Empty {
|
||||
parentID = vm.lastAcceptedID
|
||||
}
|
||||
|
||||
parent, err := vm.getBlock(parentID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get parent block: %w", err)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if req.Confirmations >= vm.config.MinConfirmations {
|
||||
requests = append(requests, req)
|
||||
}
|
||||
}
|
||||
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 {
|
||||
return nil, errors.New("no ready bridge requests")
|
||||
}
|
||||
|
||||
// Create new block
|
||||
blk := &Block{
|
||||
ParentID_: parentID,
|
||||
BlockHeight: parent.Height() + 1,
|
||||
BlockTimestamp: time.Now().Unix(),
|
||||
BridgeRequests: requests,
|
||||
vm: vm,
|
||||
}
|
||||
|
||||
blk.ID_ = blk.computeID()
|
||||
|
||||
// Store pending block
|
||||
vm.pendingBlocks[blk.ID()] = blk
|
||||
|
||||
vm.log.Info("built bridge block",
|
||||
log.Stringer("blockID", blk.ID()),
|
||||
log.Int("numRequests", len(requests)),
|
||||
)
|
||||
|
||||
return blk, nil
|
||||
}
|
||||
|
||||
// GetBlock implements the chain.ChainVM interface
|
||||
func (vm *VM) GetBlock(ctx context.Context, id ids.ID) (chain.Block, error) {
|
||||
vm.mu.RLock()
|
||||
defer vm.mu.RUnlock()
|
||||
|
||||
// Check pending blocks first (nil-safe for early calls before initialization)
|
||||
if vm.pendingBlocks != nil {
|
||||
if blk, exists := vm.pendingBlocks[id]; exists {
|
||||
return blk, nil
|
||||
// BuildBlock implements the chain.ChainVM interface
|
||||
func (vm *VM) BuildBlock(ctx context.Context) (vmchain.Block, error) {
|
||||
built, err := vm.chain.Propose(func(parentID ids.ID, parentHeight uint64) (*Block, error) {
|
||||
// The transfers in flight are the bridge's state, so they are read under
|
||||
// the bridge's lock — taken here, inside the chain's, which is the one
|
||||
// order these are ever taken in.
|
||||
vm.mu.RLock()
|
||||
if len(vm.pendingBridges) == 0 {
|
||||
vm.mu.RUnlock()
|
||||
return nil, errors.New("no pending bridge requests")
|
||||
}
|
||||
}
|
||||
|
||||
return vm.getBlock(id)
|
||||
// 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 {
|
||||
if req.Confirmations >= vm.config.MinConfirmations {
|
||||
requests = append(requests, req)
|
||||
}
|
||||
}
|
||||
vm.mu.RUnlock()
|
||||
|
||||
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 {
|
||||
return nil, errors.New("no ready bridge requests")
|
||||
}
|
||||
|
||||
// Create new block
|
||||
blk := &Block{
|
||||
ParentID_: parentID,
|
||||
BlockHeight: parentHeight + 1,
|
||||
BlockTimestamp: time.Now().Unix(),
|
||||
BridgeRequests: requests,
|
||||
vm: vm,
|
||||
}
|
||||
blk.ID_ = blk.computeID()
|
||||
|
||||
vm.log.Info("built bridge block",
|
||||
log.Stringer("blockID", blk.ID()),
|
||||
log.Int("numRequests", len(requests)),
|
||||
)
|
||||
return blk, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return built, nil
|
||||
}
|
||||
|
||||
// GetBlock implements the chain.ChainVM interface
|
||||
func (vm *VM) GetBlock(ctx context.Context, id ids.ID) (vmchain.Block, error) {
|
||||
if vm.genesisBlock != nil && id == vm.genesisBlock.ID() {
|
||||
return vm.genesisBlock, nil
|
||||
}
|
||||
return vm.chain.Block(id, vm.parseBlock)
|
||||
}
|
||||
|
||||
// ParseBlock implements the chain.ChainVM interface
|
||||
func (vm *VM) ParseBlock(ctx context.Context, bytes []byte) (chain.Block, error) {
|
||||
func (vm *VM) ParseBlock(ctx context.Context, bytes []byte) (vmchain.Block, error) {
|
||||
blk := &Block{vm: vm}
|
||||
if err := parseBlockBytes(bytes, blk); err != nil {
|
||||
return nil, err
|
||||
@@ -521,19 +522,14 @@ func (vm *VM) ParseBlock(ctx context.Context, bytes []byte) (chain.Block, error)
|
||||
|
||||
// SetPreference implements the chain.ChainVM interface
|
||||
func (vm *VM) SetPreference(ctx context.Context, id ids.ID) error {
|
||||
vm.mu.Lock()
|
||||
defer vm.mu.Unlock()
|
||||
|
||||
vm.preferred = id
|
||||
vm.chain.Prefer(id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// LastAccepted implements the chain.ChainVM interface
|
||||
func (vm *VM) LastAccepted(ctx context.Context) (ids.ID, error) {
|
||||
vm.mu.RLock()
|
||||
defer vm.mu.RUnlock()
|
||||
|
||||
return vm.lastAcceptedID, nil
|
||||
id, _ := vm.chain.Tip()
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// CreateHandlers implements the common.VM interface. The bridge's API is the
|
||||
@@ -572,9 +568,9 @@ func (vm *VM) readiness() (ready bool, reason string, chains int) {
|
||||
|
||||
// HealthCheck implements the common.VM interface, reporting what this node can
|
||||
// do so traffic reaches one that can bridge.
|
||||
func (vm *VM) HealthCheck(ctx context.Context) (chain.HealthResult, error) {
|
||||
func (vm *VM) HealthCheck(ctx context.Context) (vmchain.HealthResult, error) {
|
||||
ready, reason, chains := vm.readiness()
|
||||
return chain.HealthResult{
|
||||
return vmchain.HealthResult{
|
||||
Healthy: ready,
|
||||
Details: map[string]string{
|
||||
"status": reason,
|
||||
@@ -597,7 +593,7 @@ func (vm *VM) CreateStaticHandlers(ctx context.Context) (map[string]http.Handler
|
||||
}
|
||||
|
||||
// Connected implements the common.VM interface
|
||||
func (vm *VM) Connected(ctx context.Context, nodeID ids.NodeID, nodeVersion *chain.VersionInfo) error {
|
||||
func (vm *VM) Connected(ctx context.Context, nodeID ids.NodeID, nodeVersion *vmchain.VersionInfo) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -686,32 +682,6 @@ func (vm *VM) WaitForEvent(ctx context.Context) (vmcore.Message, error) {
|
||||
return vm.work.WaitForEvent(ctx)
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
func (vm *VM) putBlock(blk *Block) error {
|
||||
bytes, err := blk.Marshal()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id := blk.ID()
|
||||
return vm.db.Put(id[:], bytes)
|
||||
}
|
||||
|
||||
func (vm *VM) getBlock(id ids.ID) (*Block, error) {
|
||||
bytes, err := vm.db.Get(id[:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
blk := &Block{vm: vm}
|
||||
if err := parseBlockBytes(bytes, blk); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
blk.ID_ = id
|
||||
return blk, nil
|
||||
}
|
||||
|
||||
// HTTP handler methods
|
||||
|
||||
// Genesis represents the genesis state
|
||||
|
||||
+24
-8
@@ -65,9 +65,10 @@ type Store[B Block] struct {
|
||||
|
||||
reload func() error
|
||||
|
||||
flight map[ids.ID]B
|
||||
tip ids.ID
|
||||
height uint64
|
||||
flight map[ids.ID]B
|
||||
tip ids.ID
|
||||
height uint64
|
||||
preferred ids.ID
|
||||
}
|
||||
|
||||
// Key namespaces. Blocks, the height index and the tip pointer are the store's
|
||||
@@ -183,10 +184,20 @@ func (s *Store[B]) Seed(write func(database.Database) error) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Propose hands the caller the tip to build on and tracks whatever it builds,
|
||||
// in one step. Reading the tip and registering the child as two steps leaves a
|
||||
// window in which a block is accepted between them, and the proposal is then
|
||||
// built on a parent that is no longer the tip.
|
||||
// Prefer records the block the engine wants the next one built on. It is the
|
||||
// tip until the engine says otherwise, and a preference the store no longer
|
||||
// holds — pruned, or never tracked — falls back to the tip rather than naming
|
||||
// a parent nothing can resolve.
|
||||
func (s *Store[B]) Prefer(id ids.ID) {
|
||||
s.Lock()
|
||||
defer s.Unlock()
|
||||
s.preferred = id
|
||||
}
|
||||
|
||||
// Propose hands the caller the block to build on and tracks whatever it
|
||||
// builds, in one step. Reading the parent and registering the child as two
|
||||
// steps leaves a window in which a block is accepted between them, and the
|
||||
// proposal is then built on a parent that has moved.
|
||||
//
|
||||
// A build with nothing to propose says so with an error, and nothing is
|
||||
// tracked.
|
||||
@@ -194,7 +205,12 @@ func (s *Store[B]) Propose(build func(parent ids.ID, height uint64) (B, error))
|
||||
s.Lock()
|
||||
defer s.Unlock()
|
||||
|
||||
b, err := build(s.tip, s.height)
|
||||
parent, height := s.tip, s.height
|
||||
if b, ok := s.flight[s.preferred]; ok {
|
||||
parent, height = b.ID(), b.Height()
|
||||
}
|
||||
|
||||
b, err := build(parent, height)
|
||||
if err != nil {
|
||||
var nothing B
|
||||
return nothing, err
|
||||
|
||||
@@ -491,3 +491,39 @@ func TestConcurrentReadersDoNotRaceAnAccept(t *testing.T) {
|
||||
_, height := f.store.Tip()
|
||||
require.Equal(t, uint64(50), height)
|
||||
}
|
||||
|
||||
func TestAProposalFollowsTheEnginesPreference(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
one := newBlock(1, f.genesis.ID(), 1)
|
||||
require.NoError(t, f.store.Accept(one))
|
||||
|
||||
// A second block in flight above the tip, which the engine prefers.
|
||||
two := newBlock(2, one.ID(), 2)
|
||||
f.store.Track(two)
|
||||
f.store.Prefer(two.ID())
|
||||
|
||||
var sawParent ids.ID
|
||||
var sawHeight uint64
|
||||
_, err := f.store.Propose(func(parent ids.ID, height uint64) (*testBlock, error) {
|
||||
sawParent, sawHeight = parent, height
|
||||
return newBlock(3, parent, height+1), nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, two.ID(), sawParent, "a proposal extends the preferred fork")
|
||||
require.Equal(t, uint64(2), sawHeight)
|
||||
}
|
||||
|
||||
func TestAPreferenceTheStoreCannotResolveFallsBackToTheTip(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
one := newBlock(1, f.genesis.ID(), 1)
|
||||
require.NoError(t, f.store.Accept(one))
|
||||
f.store.Prefer(ids.ID{99})
|
||||
|
||||
var sawParent ids.ID
|
||||
_, err := f.store.Propose(func(parent ids.ID, height uint64) (*testBlock, error) {
|
||||
sawParent = parent
|
||||
return newBlock(3, parent, height+1), nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, one.ID(), sawParent)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user