Files
chains/fhevm/batch.go
zeekay e5fbbc5201 fhevm: a block extends the tip, a signature names one chain, and a failed read is not an answer
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.
2026-08-28 11:55:53 -07:00

110 lines
3.3 KiB
Go

// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package fhevm
import (
"fmt"
"github.com/luxfi/chains/fee"
)
// MaxBlockTxs bounds how many transactions one block carries. Without it a
// proposer's block size is whatever the mempool happens to hold.
const MaxBlockTxs = 1024
// batch is the running state a block's transactions must satisfy AS A
// SEQUENCE: nonces strictly in order per payer, fees affordable against a
// running per-payer debit, and no two transactions claiming one effect.
//
// One rule, two policies. Block.Verify runs it over a received block and
// refuses the whole block on the first transaction that does not fit;
// BuildBlock runs it over the mempool and simply leaves out what does not fit.
// A proposer therefore cannot build a block its own Verify would reject, and
// one unfit transaction can no longer take the rest of the mempool down with
// it.
//
// What is deliberately NOT here is authorization. checkAuth reads state that
// earlier transactions in the same block may change, so a block-time
// authorization verdict can differ from the application-time one — and a block
// that passes Verify on every validator and then fails to apply on every
// validator halts the chain. Authorization is decided once, at application
// time, and a transaction that fails it REVERTS: it pays its fee and has no
// effect (block.go settleAndApply). Verify's job is that the block is
// well-formed, authentic, ordered and paid for.
type batch struct {
vm *VM
nonce map[fee.Account]uint64 // next nonce owed by each payer
spent map[fee.Account]uint64 // running debit per payer
claimed map[[32]byte]struct{} // effects already taken in this block
}
func newBatch(vm *VM) *batch {
return &batch{
vm: vm,
nonce: make(map[fee.Account]uint64),
spent: make(map[fee.Account]uint64),
claimed: make(map[[32]byte]struct{}),
}
}
// admit tests one transaction against the running state and, if it fits,
// records its consumption of that state. Caller holds vm.stateLock.
func (b *batch) admit(tx *Transaction) error {
if err := tx.SyntacticVerify(); err != nil {
return err
}
if err := tx.authenticate(b.vm.chainID); err != nil {
return err
}
// Replay/order guard: a payer's nonces are consecutive from its committed
// one, counting the transactions already taken from it in this block.
want, seen := b.nonce[tx.Payer]
if !seen {
committed, err := b.vm.nonceOf(tx.Payer)
if err != nil {
return err
}
want = committed + 1
}
if tx.Nonce != want {
return ErrBadNonce
}
eff := tx.effect()
if _, dup := b.claimed[eff]; dup {
return ErrDuplicateEffect
}
gasUsed, err := GasFor(tx)
if err != nil {
return err
}
if uint64(gasUsed) > tx.GasLimit {
return fmt.Errorf("fhevm: %w: gas %d > limit %d", fee.ErrOutOfGas, gasUsed, tx.GasLimit)
}
feeAmt, err := fee.Cost(gasUsed, GasPrice)
if err != nil {
return err
}
bal, err := b.vm.ledger.Balance(tx.Payer)
if err != nil {
return err
}
next, over := addSpend(b.spent[tx.Payer], feeAmt)
if over || bal < next {
return fee.ErrInsufficientFunds
}
b.nonce[tx.Payer] = want + 1
b.spent[tx.Payer] = next
b.claimed[eff] = struct{}{}
return nil
}
func addSpend(a, b uint64) (uint64, bool) {
s := a + b
return s, s < a
}