mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
The caps decide which transfers a block may carry — admissible() refuses a transfer over the per-transfer cap and admit() refuses one over the daily cap, both inside block verification. Reading them from the operator's chain config meant two validators could hold different numbers and disagree about whether a transfer was admissible, and the first one they disagreed about is a transfer the builder keeps proposing and everyone else keeps refusing. A cap each operator sets for themselves is not a cap. Limits (minConfirmations, the two spend caps, the signer bond, the set size) move to Genesis, where the network declares them once and every validator reads the same numbers. BridgeConfig keeps only ExternalChains — endpoints and KMS paths, how THIS node reaches the chains it was told to bridge, which cannot change which transfers are valid. The dead BridgeFee field goes. MaxSigners keeps its default: it is a capacity, not a risk decision.
258 lines
8.6 KiB
Go
258 lines
8.6 KiB
Go
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package bridgevm
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"errors"
|
|
"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"
|
|
)
|
|
|
|
// 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"`
|
|
|
|
// 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("bridgevm: invalid block")
|
|
errFutureBlock = errors.New("bridgevm: block timestamp is in the future")
|
|
|
|
// 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
|
|
func (b *Block) ID() ids.ID {
|
|
if b.ID_ == ids.Empty {
|
|
b.ID_ = b.computeID()
|
|
}
|
|
return b.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_[:])
|
|
|
|
var num [8]byte
|
|
binary.BigEndian.PutUint64(num[:], b.BlockHeight)
|
|
h.Write(num[:])
|
|
|
|
binary.BigEndian.PutUint64(num[:], uint64(b.BlockTimestamp))
|
|
h.Write(num[:])
|
|
|
|
// 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[:])
|
|
}
|
|
|
|
return ids.ID(h.Sum(nil))
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// Whether this block still extends the tip is the store's to decide, under the
|
|
// lock that commits. Asking here read the tip, released it, and only then
|
|
// asked for the lock — so a tip that moved in between was answered with a
|
|
// reading taken before it moved.
|
|
func (b *Block) Accept(ctx context.Context) error { return b.vm.chain.Accept(b) }
|
|
|
|
// 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
|
|
// started for a transfer the chain has durably recorded.
|
|
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 {
|
|
b.vm.mu.Lock()
|
|
delete(b.vm.pendingBridges, req.ID)
|
|
releaser := b.vm.releaser
|
|
b.vm.mu.Unlock()
|
|
|
|
b.vm.log.Info("bridgevm: transfer settled",
|
|
log.Stringer("requestID", req.ID),
|
|
log.Uint32("dst", req.DstChainID),
|
|
log.Uint64("amount", req.Amount),
|
|
)
|
|
|
|
// Hand the confirmed transfer to the release worker (relayer nodes only).
|
|
// 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 releaser != nil {
|
|
releaser.enqueue(req)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Status returns the block's status
|
|
func (b *Block) Status() uint8 {
|
|
return uint8(b.status)
|
|
}
|
|
|
|
// ParentID returns the parent block ID
|
|
func (b *Block) ParentID() ids.ID {
|
|
return b.ParentID_
|
|
}
|
|
|
|
// Parent returns the parent block (for block.Block interface compatibility)
|
|
func (b *Block) Parent() ids.ID {
|
|
return b.ParentID_
|
|
}
|
|
|
|
// 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 {
|
|
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)
|
|
}
|
|
|
|
parent, err := b.vm.chain.Block(b.ParentID_, b.vm.parseBlock)
|
|
if err != nil {
|
|
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)
|
|
}
|
|
|
|
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.limits, 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
|
|
}
|
|
|
|
// Height returns the block height
|
|
func (b *Block) Height() uint64 {
|
|
return b.BlockHeight
|
|
}
|
|
|
|
// Timestamp returns the block timestamp
|
|
func (b *Block) Timestamp() time.Time {
|
|
return time.Unix(b.BlockTimestamp, 0)
|
|
}
|
|
|
|
// Bytes returns the block's encoding, computed once.
|
|
func (b *Block) Bytes() []byte {
|
|
if b.bytes == nil {
|
|
b.bytes = b.Marshal()
|
|
}
|
|
return b.bytes
|
|
}
|
|
|
|
var _ chain.Block = (*Block)(nil)
|