mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
Four defects, each confirmed by executing it before it was fixed and by mutation after. A block whose parent was an OLD accepted block satisfied height == parent+1, so Verify passed it and Accept applied it: the chain at height 3 rewound to 2, the last-accepted pointer and the height index moved with it, and the index went on naming the abandoned block at 3. A peer bootstrapping from it was served an incoherent chain. The parent must now be the tip or a block verified above it; Accept decides again under its own hold of the lock, because the tip moves between the two. Nothing bound a transaction or a block to the chain it was meant for. An address is the hash of a public key, so the same payer exists on every F-Chain, and a captured transaction authenticated verbatim on all of them and burned a balance there. Every chain sharing a genesis timestamp also shared a genesis id, so one chain's block resolved its parent on the others. The chain id now enters the signing preimage and the block id without travelling on the wire: each side supplies its own, so a mismatch is a failed signature and an unresolvable parent rather than a check someone can forget. A read that FAILED was reported as a read that found NOTHING. An unreadable last-accepted pointer left a live chain looking like a fresh one, with Initialize returning nil and the node ready to build height 1 over height 2. The same conflation on the epoch pointer seats a committee nobody elected, and on a nonce it reopens the replay window a nonce exists to close. One reader now separates absent from unreadable, and nonceOf reports rather than returns zero. HealthCheck likewise stops reporting a ledger it could not read as zero burned. MaxBlockTxs was applied by Verify, and Verify runs after the parse, so an 8 MB message carrying 1,524 transactions parsed to completion before any check ran. Bytes and count are both bounded at the first byte, and neither implies the other; selection stops at whichever comes first, so a proposer cannot build what its own parser refuses. Deleted: the tx-blob accounting check, which the canonical-encoding rule subsumes exactly (a blob with unread bytes re-serializes shorter), and the empty-path branch in NewHTTPHandler, whose one producer returns a literal. fhevm 89.7% -> 98.9%. The twelve statements left are named unreachable in the report, not padded. The harness now holds constant what production holds constant: one chain id and one genesis across every node of a chain, rather than a fresh id per node.
303 lines
11 KiB
Go
303 lines
11 KiB
Go
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package fhevm
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/luxfi/chains/fee"
|
|
"github.com/luxfi/ids"
|
|
"github.com/luxfi/log"
|
|
)
|
|
|
|
// Block is an F-Chain block: an ordered batch of fee-settled confidential-
|
|
// compute operations.
|
|
type Block struct {
|
|
id ids.ID
|
|
parentID ids.ID
|
|
height uint64
|
|
timestamp time.Time
|
|
transactions []*Transaction
|
|
vm *VM
|
|
}
|
|
|
|
// computeID names the block by its content AND by the chain it belongs to. The
|
|
// chain id does not travel — a peer supplies its own — so the same bytes name
|
|
// different blocks on different chains, and chain B cannot resolve chain A's
|
|
// parent. Without it every F-Chain with the same genesis timestamp shared one
|
|
// genesis id, and a block built on one was accepted verbatim by the others.
|
|
func (b *Block) computeID() ids.ID {
|
|
h := sha256.New()
|
|
h.Write(b.vm.chainID[:])
|
|
h.Write(b.parentID[:])
|
|
var u8 [8]byte
|
|
binary.BigEndian.PutUint64(u8[:], b.height)
|
|
h.Write(u8[:])
|
|
binary.BigEndian.PutUint64(u8[:], uint64(b.timestamp.Unix()))
|
|
h.Write(u8[:])
|
|
for _, tx := range b.transactions {
|
|
id := tx.ID()
|
|
h.Write(id[:])
|
|
}
|
|
return ids.ID(h.Sum(nil))
|
|
}
|
|
|
|
func (b *Block) ID() ids.ID {
|
|
if b.id == ids.Empty {
|
|
b.id = b.computeID()
|
|
}
|
|
return b.id
|
|
}
|
|
|
|
func (b *Block) ParentID() ids.ID { return b.parentID }
|
|
func (b *Block) Parent() ids.ID { return b.parentID }
|
|
func (b *Block) Height() uint64 { return b.height }
|
|
func (b *Block) Timestamp() time.Time { return b.timestamp }
|
|
|
|
// MaxFutureSkew is how far ahead of the verifying node's clock a block's
|
|
// timestamp may be. Chain time drives every expiry F enforces, so without a cap
|
|
// a proposer stamping year 36812 would expire every permit and every pending
|
|
// request at once.
|
|
const MaxFutureSkew = 60 * time.Second
|
|
|
|
// Verify checks the block can be accepted WITHOUT mutating state: it sits
|
|
// correctly on its parent in height and time, and every transaction is
|
|
// well-formed, authenticated, correctly ordered and paid for. A block that
|
|
// fails any check is never accepted (fail closed); verifying a block never
|
|
// moves funds.
|
|
//
|
|
// Verify does NOT decide authorization. That verdict depends on state earlier
|
|
// transactions in the same block may change, so a block-time verdict can differ
|
|
// from the application-time one — and a block every validator certifies and no
|
|
// validator can apply halts the chain. Authorization is decided once, in
|
|
// Accept, where failing it reverts the one transaction instead of the block.
|
|
func (b *Block) Verify(ctx context.Context) error {
|
|
if b.height == 0 {
|
|
return fmt.Errorf("fhevm: %w: genesis is not a proposed block", ErrInvalidBlock)
|
|
}
|
|
parent, err := b.vm.GetBlock(ctx, b.parentID)
|
|
if err != nil {
|
|
return fmt.Errorf("fhevm: verify parent: %w", err)
|
|
}
|
|
// Chain time and height are the parent's, advanced. Without this a proposer
|
|
// picks both freely: it can rewind time to revive an expired permit, or jump
|
|
// forward to expire everything at once.
|
|
if b.height != parent.Height()+1 {
|
|
return fmt.Errorf("fhevm: %w: height %d does not follow parent %d",
|
|
ErrInvalidBlock, b.height, parent.Height())
|
|
}
|
|
if b.timestamp.Before(parent.Timestamp()) {
|
|
return fmt.Errorf("fhevm: %w: timestamp %d precedes parent %d",
|
|
ErrInvalidBlock, b.timestamp.Unix(), parent.Timestamp().Unix())
|
|
}
|
|
if b.timestamp.After(b.vm.clock.Time().Add(MaxFutureSkew)) {
|
|
return fmt.Errorf("fhevm: %w: timestamp %d is beyond the %s skew allowance",
|
|
ErrInvalidBlock, b.timestamp.Unix(), MaxFutureSkew)
|
|
}
|
|
if len(b.transactions) == 0 {
|
|
return fmt.Errorf("fhevm: %w: empty block", ErrInvalidBlock)
|
|
}
|
|
if len(b.transactions) > MaxBlockTxs {
|
|
return fmt.Errorf("fhevm: %w: %d transactions exceeds %d",
|
|
ErrInvalidBlock, len(b.transactions), MaxBlockTxs)
|
|
}
|
|
// A block this node built in memory is held to the same size a block off the
|
|
// wire is held to, so a proposer cannot produce one its own peers refuse to
|
|
// parse.
|
|
if n := len(b.Bytes()); n > MaxBlockSize {
|
|
return fmt.Errorf("fhevm: %w: %d bytes exceeds %d", ErrInvalidBlock, n, MaxBlockSize)
|
|
}
|
|
|
|
if err := func() error {
|
|
b.vm.stateLock.RLock()
|
|
defer b.vm.stateLock.RUnlock()
|
|
|
|
// The parent must be one this chain can still build on: the accepted tip,
|
|
// or a block verified above it and not yet decided. Height alone is not
|
|
// that check — a block whose parent is an OLD accepted block satisfies
|
|
// height == parent+1 perfectly well, and accepting it rewinds the chain
|
|
// and leaves the height index naming an orphan as canonical.
|
|
if err := b.vm.onTip(b.parentID); err != nil {
|
|
return err
|
|
}
|
|
|
|
batch := newBatch(b.vm)
|
|
for _, tx := range b.transactions {
|
|
if err := batch.admit(tx); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}(); err != nil {
|
|
return err
|
|
}
|
|
|
|
// 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 meant a follower verified b1 and then
|
|
// failed b2 with "verify parent: not found", which is the ordinary shape
|
|
// whenever more than one block is in flight.
|
|
//
|
|
// The read lock above cannot be upgraded, so the checks finish under it and
|
|
// the registration takes the write lock on its own. pendingBlocks keeps ONE
|
|
// owner — reaching it under a second mutex is the bug this VM already has a
|
|
// test against.
|
|
b.vm.stateLock.Lock()
|
|
b.vm.trackVerified(b)
|
|
b.vm.stateLock.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// Accept settles and applies the block atomically. For each transaction it
|
|
// METERS the operation's gas, BURNS the fee from the payer (debit + supply
|
|
// reduction), then APPLIES the state effect — all written through the VM's
|
|
// versiondb, which is committed exactly once. Any failure aborts the whole
|
|
// block (no partial application, no unpaid operation): the versiondb is rolled
|
|
// back and the caches are reloaded from the unchanged base DB.
|
|
func (b *Block) Accept(ctx context.Context) error {
|
|
now := b.timestamp.Unix()
|
|
|
|
b.vm.stateLock.Lock()
|
|
defer b.vm.stateLock.Unlock()
|
|
|
|
// A block extends the tip or it is not accepted. Verify reached the same
|
|
// verdict earlier, against the tip AT THAT TIME; between the two the chain
|
|
// moves, and it is this hold of the lock that decides. Without it Accept
|
|
// writes the height index, the last-accepted pointer and the in-memory height
|
|
// for a block on an abandoned branch — the chain rewinds, and every peer
|
|
// bootstrapping from the index is served an orphan as canonical.
|
|
if b.parentID != b.vm.lastAccepted {
|
|
return fmt.Errorf("fhevm: %w: parent %s is not the accepted tip %s",
|
|
ErrNotOnTip, b.parentID, b.vm.lastAccepted)
|
|
}
|
|
|
|
if err := b.settleAndApply(now); err != nil {
|
|
b.abort()
|
|
return err
|
|
}
|
|
|
|
// Persist block, height index and last-accepted pointer in the same commit.
|
|
if err := b.vm.state.Put(append([]byte(BlockPrefix), b.id[:]...), b.Bytes()); err != nil {
|
|
b.abort()
|
|
return err
|
|
}
|
|
if err := b.vm.state.Put(heightKey(b.height), b.id[:]); err != nil {
|
|
b.abort()
|
|
return err
|
|
}
|
|
if err := b.vm.state.Put(lastAcceptedKey, b.id[:]); err != nil {
|
|
b.abort()
|
|
return err
|
|
}
|
|
if err := b.vm.versdb.Commit(); err != nil {
|
|
b.abort()
|
|
return fmt.Errorf("fhevm: commit block %s: %w", b.id, err)
|
|
}
|
|
|
|
b.vm.lastAccepted = b.id
|
|
b.vm.lastBlock = b
|
|
b.vm.height = b.height
|
|
delete(b.vm.pendingBlocks, b.id)
|
|
b.vm.release(b.transactions)
|
|
|
|
b.vm.log.Info("F-Chain block accepted",
|
|
log.String("blockID", b.id.String()),
|
|
log.Uint64("height", b.height),
|
|
log.Int("txs", len(b.transactions)),
|
|
)
|
|
return nil
|
|
}
|
|
|
|
// abort rolls back the versiondb and reloads caches from the unchanged base DB.
|
|
// The caller (Accept) holds stateLock.
|
|
func (b *Block) abort() {
|
|
b.vm.versdb.Abort()
|
|
if err := b.vm.loadStateLocked(); err != nil {
|
|
b.vm.log.Error("fhevm: reload caches after abort", log.String("error", err.Error()))
|
|
}
|
|
}
|
|
|
|
// settleAndApply burns each tx fee and applies its effect. Caller holds
|
|
// stateLock and is responsible for Abort on error.
|
|
//
|
|
// A transaction that fails AUTHORIZATION here REVERTS rather than aborting the
|
|
// block: its fee is burned, its nonce is consumed, and its state effect does
|
|
// not happen. Every validator reaches that verdict from the same committed
|
|
// state in the same order, so a reverted transaction is not a disagreement —
|
|
// and the alternative, aborting, would mean a block every validator certified
|
|
// and no validator could apply, which halts the chain at that height. The payer
|
|
// pays for the block space it used either way, so a revert is not free.
|
|
//
|
|
// The error return is therefore reserved for what a validator genuinely cannot
|
|
// proceed past: a failed write, a failed burn, a nonce that Verify should have
|
|
// caught. Those abort the block and roll it back whole.
|
|
func (b *Block) settleAndApply(now int64) error {
|
|
for _, tx := range b.transactions {
|
|
// Replay/order guard: nonce must be exactly the payer's next. Reads the
|
|
// versiondb so earlier txs in this same block (buffered) are seen.
|
|
committed, err := b.vm.nonceOf(tx.Payer)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tx.Nonce != committed+1 {
|
|
return ErrBadNonce
|
|
}
|
|
gasUsed, err := GasFor(tx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Meter the operation against the payer's gas limit.
|
|
meter := fee.NewGasMeter(fee.Gas(tx.GasLimit))
|
|
if err := meter.Consume(gasUsed); err != nil {
|
|
return err
|
|
}
|
|
feeAmt, err := fee.Cost(meter.Used(), GasPrice)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Debit + burn the fee from the payer's balance.
|
|
if err := fee.Charge(b.vm.ledger, tx.Payer, feeAmt); err != nil {
|
|
return err
|
|
}
|
|
// Apply the operation's state effect (atomically with the burn) — or, if
|
|
// authorization refuses it, leave state untouched. Apply reports which.
|
|
if _, err := tx.Apply(b.vm, now); err != nil {
|
|
return err
|
|
}
|
|
// Advance the payer's nonce (atomically with the burn + effect).
|
|
if err := b.vm.setNonce(tx.Payer, tx.Nonce); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Reject discards the block. Its transactions were never removed from the
|
|
// mempool — BuildBlock selects from the mempool rather than draining it — so
|
|
// there is nothing to give back and nothing that can be lost by an engine that
|
|
// drops a block without rejecting it.
|
|
func (b *Block) Reject(ctx context.Context) error {
|
|
b.vm.stateLock.Lock()
|
|
delete(b.vm.pendingBlocks, b.id)
|
|
b.vm.stateLock.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// Status returns 0=processing, 1=accepted.
|
|
func (b *Block) Status() uint8 {
|
|
b.vm.stateLock.RLock()
|
|
defer b.vm.stateLock.RUnlock()
|
|
if b.id == b.vm.lastAccepted {
|
|
return 1
|
|
}
|
|
if ok, _ := b.vm.state.Has(append([]byte(BlockPrefix), b.id[:]...)); ok {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|