Files
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

458 lines
18 KiB
Go

// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package fhevm
import (
"bytes"
"crypto/sha256"
"encoding/binary"
"testing"
"github.com/luxfi/ids"
"github.com/luxfi/zap"
)
// sampleTx is a fully-populated transaction whose every field is non-empty, so
// the canonical round-trip exercises every offset.
func sampleTx() *Transaction {
tx := &Transaction{
Type: TxRegisterCiphertext,
Scheme: "ckks-n14",
Subject: [32]byte{9, 8, 7, 6, 5, 4, 3, 2, 1},
GasLimit: 81000,
Nonce: 42,
Payload: []byte("register-ciphertext-payload"),
Auth: []byte("payer-public-key-bytes"),
Sig: []byte("payer-signature-bytes"),
}
copy(tx.Payer[:], []byte("payer-address-20byte"))
return tx
}
// TestWireCanonicalRoundTrip proves Bytes()→ParseTransaction is a faithful,
// idempotent round-trip and that the id is the content hash of the canonical
// wire (id == sha256(Bytes())).
func TestWireCanonicalRoundTrip(t *testing.T) {
tx := sampleTx()
data := tx.Bytes()
parsed, err := ParseTransaction(data)
if err != nil {
t.Fatalf("ParseTransaction(canonical) failed: %v", err)
}
if got := parsed.Bytes(); string(got) != string(data) {
t.Fatalf("re-serialization not canonical: parsed.Bytes() != input\n in =%x\n out=%x", data, got)
}
if want := ids.ID(sha256.Sum256(data)); parsed.ID() != want {
t.Fatalf("id != sha256(Bytes()): got %s want %s", parsed.ID(), want)
}
// Field fidelity across the seam.
if parsed.Type != tx.Type || parsed.Scheme != tx.Scheme ||
parsed.GasLimit != tx.GasLimit || parsed.Nonce != tx.Nonce ||
parsed.Payer != tx.Payer || parsed.Subject != tx.Subject ||
string(parsed.Payload) != string(tx.Payload) ||
string(parsed.Auth) != string(tx.Auth) || string(parsed.Sig) != string(tx.Sig) {
t.Fatalf("field mismatch after round-trip:\n in =%+v\n out=%+v", tx, parsed)
}
}
// TestWireContentIsThePrefixTheSignatureCovers proves what is authenticated and
// what is transmitted cannot come apart: the content object is a genuine
// byte-prefix of the wire, zapLen names exactly its length, and the signed
// preimage ENDS with that same prefix. So the only thing the signature covers
// that the wire does not carry is the chain id, which the verifier supplies.
func TestWireContentIsThePrefixTheSignatureCovers(t *testing.T) {
tx := sampleTx()
content := tx.content()
full := tx.Bytes()
if len(content) > len(full) || !bytes.Equal(full[:len(content)], content) {
t.Fatal("content() is not a prefix of Bytes()")
}
n, err := zapLen(full)
if err != nil {
t.Fatalf("zapLen: %v", err)
}
if n != len(content) {
t.Fatalf("zapLen = %d, want the content length %d", n, len(content))
}
pre := tx.SigningBytes(testChainID)
if !bytes.HasSuffix(pre, content) {
t.Fatal("the signed preimage does not end with the transmitted content")
}
if want := len(txDomain) + 32 + len(content); len(pre) != want {
t.Fatalf("preimage is %d bytes, want domain+chain+content = %d", len(pre), want)
}
}
// TestWireSigningBytesBindTheChain proves a preimage names ONE chain. Without
// it, a transaction signed on one F-Chain authenticates on every other — the
// payer's address is the hash of its public key, so the same account exists on
// all of them, and the replay burns a balance there for an operation nobody
// asked for.
func TestWireSigningBytesBindTheChain(t *testing.T) {
tx := sampleTx()
other := ids.ID{'o', 't', 'h', 'e', 'r'}
if bytes.Equal(tx.SigningBytes(testChainID), tx.SigningBytes(other)) {
t.Fatal("two chains produced one preimage: a signature would replay across them")
}
// And the domain separates a transaction preimage from a bare content
// encoding, so no other digest this chain computes can stand in for one.
if !bytes.HasPrefix(tx.SigningBytes(testChainID), []byte(txDomain)) {
t.Fatal("the preimage is not domain-separated")
}
}
// TestWireSigningBytesBindEveryField proves the signed preimage changes when
// ANY semantically-meaningful field changes — including Subject, which names
// the object the operation acts on. A field the signature did not cover could
// be swapped in flight.
func TestWireSigningBytesBindEveryField(t *testing.T) {
base := string(sampleTx().SigningBytes(testChainID))
for name, mutate := range map[string]func(*Transaction){
"Type": func(tx *Transaction) { tx.Type = TxGrantPermit },
"Scheme": func(tx *Transaction) { tx.Scheme = "bfv-n13" },
"Payer": func(tx *Transaction) { tx.Payer[0] ^= 0xff },
"Subject": func(tx *Transaction) { tx.Subject[0] ^= 0xff },
"GasLimit": func(tx *Transaction) { tx.GasLimit++ },
"Nonce": func(tx *Transaction) { tx.Nonce++ },
"Payload": func(tx *Transaction) { tx.Payload = append(tx.Payload, 'x') },
} {
tx := sampleTx()
mutate(tx)
if string(tx.SigningBytes(testChainID)) == base {
t.Fatalf("changing %s left the signed preimage unchanged", name)
}
}
// Auth and Sig are deliberately EXCLUDED from the preimage: the signature
// cannot cover itself.
tx := sampleTx()
tx.Auth = []byte("a-different-public-key")
tx.Sig = []byte("a-different-signature")
if string(tx.SigningBytes(testChainID)) != base {
t.Fatal("Auth/Sig must not appear in the signed preimage")
}
}
// TestWireRejectsNonCanonical proves the canonical gate: zap follows the root
// offset and ignores unreferenced padding inside a message's declared size, so
// a twin buffer can decode to identical fields yet hash differently. Parse must
// reject any input that is not already byte-equal to its re-serialized form —
// exactly one byte-string authenticates per logical tx (no id-malleability).
func TestWireRejectsNonCanonical(t *testing.T) {
tx := sampleTx()
data := tx.Bytes()
// Split the two concatenated zap messages (signing ‖ sig).
n, err := zapLen(data)
if err != nil {
t.Fatalf("zapLen: %v", err)
}
// Craft a non-canonical twin: append 8 unreferenced bytes to the trailing
// (sig) message and bump its declared size field so a naive
// n+gm.Size()==len(data) trailing-bytes check still passes. The root offset
// does not reference the padding, so the fields decode identically.
sig := append([]byte(nil), data[n:]...)
sig = append(sig, make([]byte, 8)...)
binary.LittleEndian.PutUint32(sig[12:16], uint32(len(sig)))
twin := append(append([]byte(nil), data[:n]...), sig...)
if string(twin) == string(data) {
t.Fatal("twin construction failed to differ from canonical")
}
if got := int(binary.LittleEndian.Uint32(twin[n+12 : n+16])); got != len(sig) {
t.Fatalf("twin size field not bumped: got %d want %d", got, len(sig))
}
if _, err := ParseTransaction(twin); err == nil {
t.Fatal("ParseTransaction accepted a non-canonical (padded) twin — id-malleability not closed")
}
}
// TestWireParseRejectsTruncated confirms the parser fails closed on a short
// buffer rather than reading past the end.
func TestWireParseRejectsTruncated(t *testing.T) {
data := sampleTx().Bytes()
for _, cut := range []int{0, 4, zap.HeaderSize, len(data) - 1} {
if _, err := ParseTransaction(data[:cut]); err == nil {
t.Fatalf("ParseTransaction accepted truncated buffer of len %d", cut)
}
}
}
// TestWireParseRejectsTrailing confirms bytes appended after a complete
// transaction are refused rather than ignored.
func TestWireParseRejectsTrailing(t *testing.T) {
data := append(sampleTx().Bytes(), 0xde, 0xad)
if _, err := ParseTransaction(data); err == nil {
t.Fatal("ParseTransaction accepted trailing bytes")
}
}
// wireVM is the least VM a block needs to name itself: a chain id. A block id
// commits to the chain it belongs to, so there is no such thing as a block
// without one.
func wireVM() *VM { return &VM{chainID: testChainID} }
// TestBlockWireRoundTrip proves a block serializes and re-parses with its
// transactions intact and its id unchanged.
func TestBlockWireRoundTrip(t *testing.T) {
a := sampleTx()
b := sampleTx()
b.Nonce = 43
b.Payload = []byte("second")
vm := wireVM()
blk := &Block{
parentID: ids.ID{1, 2, 3},
height: 7,
timestamp: timeAt(1_700_000_000),
transactions: []*Transaction{a, b},
vm: vm,
}
blk.id = blk.computeID()
parsed, err := parseBlock(vm, blk.Bytes())
if err != nil {
t.Fatalf("parseBlock: %v", err)
}
if parsed.id != blk.id {
t.Fatalf("block id changed across the wire: %s != %s", parsed.id, blk.id)
}
if parsed.height != blk.height || parsed.parentID != blk.parentID ||
!parsed.timestamp.Equal(blk.timestamp) {
t.Fatalf("header mismatch:\n in =%+v\n out=%+v", blk, parsed)
}
if len(parsed.transactions) != 2 {
t.Fatalf("got %d transactions, want 2", len(parsed.transactions))
}
for i, tx := range parsed.transactions {
if tx.ID() != blk.transactions[i].ID() {
t.Fatalf("transaction %d id changed across the wire", i)
}
}
}
// TestBlockWireRejectsTrailing confirms a block with appended bytes is refused.
func TestBlockWireRejectsTrailing(t *testing.T) {
vm := wireVM()
blk := &Block{parentID: ids.ID{1}, height: 1, timestamp: timeAt(1), vm: vm}
if _, err := parseBlock(vm, append(blk.Bytes(), 0xff)); err == nil {
t.Fatal("parseBlock accepted trailing bytes")
}
}
// TestBlockIDBindsTheChain proves the same block bytes name DIFFERENT blocks on
// different chains, and — the case that matters most — that two chains sharing
// a genesis timestamp do not share a genesis id. They did: the id hashed parent,
// height, time and transactions, none of which distinguishes one F-Chain from
// another, so every chain's height-0 block had the same name and a block built
// on one resolved its parent on all of them.
func TestBlockIDBindsTheChain(t *testing.T) {
mk := func(chain ids.ID) ids.ID {
vm := &VM{chainID: chain}
blk := &Block{parentID: ids.Empty, height: 0, timestamp: timeAt(testGenesisTime), vm: vm}
return blk.computeID()
}
if mk(testChainID) == mk(ids.ID{'o', 't', 'h', 'e', 'r'}) {
t.Fatal("two chains share a genesis id")
}
if mk(testChainID) != mk(testChainID) {
t.Fatal("a block id must be a function of its content, not of the call")
}
}
// TestBuildBudgetNeverUnderstatesTheBlock pins the relation BuildBlock's
// selection loop depends on: its running total — emptyBlockSize plus each
// transaction's wire length plus txEntry — is never LESS than what Bytes()
// actually produces. If it could understate, a proposer would select its way
// past MaxBlockSize and build a block its own Verify refuses, which is the
// halt-shaped bug of a builder and a checker that disagree.
//
// Payload lengths vary per step so the eight-byte alignment txEntry covers is
// exercised at every offset, not only where the numbers happen to divide.
func TestBuildBudgetNeverUnderstatesTheBlock(t *testing.T) {
vm := wireVM()
var txs []*Transaction
budget := emptyBlockSize
for n := 0; n <= 24; n++ {
blk := &Block{parentID: ids.ID{7}, height: uint64(n), timestamp: timeAt(1), transactions: txs, vm: vm}
if actual := len(blk.Bytes()); budget < actual {
t.Fatalf("%d txs: budget %d understates the block's %d bytes", n, budget, actual)
}
tx := sampleTx()
tx.Nonce = uint64(n)
tx.Payload = bytes.Repeat([]byte{byte(n)}, n)
txs = append(txs, tx)
budget += len(tx.Bytes()) + txEntry
}
}
// TestParseRefusesEveryMalformedBlock walks what a peer can send that is not a
// block. Each is refused with an error rather than a partial decode, and none
// of them costs a signature check.
func TestParseRefusesEveryMalformedBlock(t *testing.T) {
vm := wireVM()
tx := sampleTx()
blk := &Block{parentID: ids.ID{3}, height: 4, timestamp: timeAt(1_700_000_000),
transactions: []*Transaction{tx}, vm: vm}
sound := blk.Bytes()
// A header that is not a zap message at all.
garbage := make([]byte, zap.HeaderSize+16)
binary.LittleEndian.PutUint32(garbage[12:16], uint32(len(garbage)))
if _, err := parseBlock(vm, garbage); err == nil {
t.Fatal("parseBlock accepted a message that decodes to nothing")
}
// A length entry that runs past the blob it indexes.
over := blockBytesWithTxLens(blk, []uint32{uint32(len(tx.Bytes()) + 64)})
if _, err := parseBlock(vm, over); err == nil {
t.Fatal("parseBlock accepted a length running past the blob")
}
// A length entry that stops short, so the transaction under it does not
// decode.
short := blockBytesWithTxLens(blk, []uint32{uint32(len(tx.Bytes()) - 8)})
if _, err := parseBlock(vm, short); err == nil {
t.Fatal("parseBlock accepted a truncated transaction")
}
// A block over the size bound is refused before it is decoded at all.
if _, err := parseBlock(vm, make([]byte, MaxBlockSize+1)); err == nil {
t.Fatal("parseBlock accepted a message over the size bound")
}
// The control: the sound encoding parses and names the same block.
got, err := parseBlock(vm, sound)
if err != nil {
t.Fatalf("parseBlock(sound): %v", err)
}
if got.id != blk.computeID() {
t.Fatal("a sound block did not name itself")
}
}
// TestParseRefusesANonCanonicalBlock proves a block has exactly one encoding.
// zap ignores padding the root does not reference, so without this a block has
// as many byte-strings as an attacker cares to make and one id.
func TestParseRefusesANonCanonicalBlock(t *testing.T) {
vm := wireVM()
blk := &Block{parentID: ids.ID{5}, height: 2, timestamp: timeAt(1_700_000_000),
transactions: []*Transaction{sampleTx()}, vm: vm}
data := blk.Bytes()
// Grow the message's declared size and pad it. The root offset does not
// reference the padding, so every field still decodes identically.
padded := append(append([]byte(nil), data...), make([]byte, 16)...)
binary.LittleEndian.PutUint32(padded[12:16], uint32(len(padded)))
if bytes.Equal(padded, data) {
t.Fatal("the twin is not a twin")
}
if _, err := parseBlock(vm, padded); err == nil {
t.Fatal("parseBlock accepted a padded twin — a block with two encodings")
}
}
// TestParseRefusesAMalformedTransaction covers the seam between a
// transaction's two concatenated messages: the trailing Auth/Sig object must
// itself be a message.
func TestParseRefusesAMalformedTransaction(t *testing.T) {
data := sampleTx().Bytes()
n, err := zapLen(data)
if err != nil {
t.Fatalf("zapLen: %v", err)
}
broken := append(append([]byte(nil), data[:n]...), make([]byte, zap.HeaderSize)...)
binary.LittleEndian.PutUint32(broken[n+12:n+16], uint32(zap.HeaderSize))
if _, err := ParseTransaction(broken); err == nil {
t.Fatal("ParseTransaction accepted a trailing object that decodes to nothing")
}
}
// TestAppendBytesDistinguishesEmptyFromNil proves an absent field comes back
// nil rather than an empty non-nil slice, so a re-serialization of what was
// parsed is byte-identical to what arrived — which is the whole canonical rule.
func TestAppendBytesDistinguishesEmptyFromNil(t *testing.T) {
if got := appendBytes(nil); got != nil {
t.Fatalf("appendBytes(nil) = %v, want nil", got)
}
if got := appendBytes([]byte{}); got != nil {
t.Fatalf("appendBytes(empty) = %v, want nil", got)
}
if got := appendBytes([]byte{1, 2}); !bytes.Equal(got, []byte{1, 2}) {
t.Fatalf("appendBytes copied wrong: %v", got)
}
// A transaction with no scheme and no payload round-trips, which is what
// that distinction buys.
bare := &Transaction{Type: TxRevokePermit, Nonce: 1}
again, err := ParseTransaction(bare.Bytes())
if err != nil {
t.Fatalf("a bare transaction did not round-trip: %v", err)
}
if again.Scheme != "" || again.Payload != nil || again.Auth != nil || again.Sig != nil {
t.Fatalf("absent fields came back present: %+v", again)
}
}
// blockBytesWithTxLens rebuilds a block's wire form with a length list the
// caller chooses, so the parser can be tested against lengths a builder would
// never produce.
func blockBytesWithTxLens(b *Block, txLens []uint32) []byte {
var txBlob []byte
for _, tx := range b.transactions {
txBlob = append(txBlob, tx.Bytes()...)
}
bld := zap.NewBuilder(zap.HeaderSize + blkSize + len(txBlob) + 4*len(txLens) + 128)
off := writeU32List(bld, txLens)
ob := bld.StartObject(blkSize)
ob.SetBytesFixed(blkParent, b.parentID[:])
ob.SetUint64(blkHeight, b.height)
ob.SetInt64(blkTime, b.timestamp.Unix())
ob.SetList(blkTxLens, off, len(txLens))
ob.SetBytes(blkTxBlob, txBlob)
ob.FinishAsRoot()
return bld.Finish()
}
// TestParseRefusesATransactionWithNoAuthObject proves the second of a
// transaction's two concatenated messages must actually be there. A declared
// length covering the whole buffer leaves nothing for the Auth/Sig object, and
// an empty remainder is not a message.
func TestParseRefusesATransactionWithNoAuthObject(t *testing.T) {
data := append([]byte(nil), sampleTx().Bytes()...)
binary.LittleEndian.PutUint32(data[12:16], uint32(len(data)))
if _, err := ParseTransaction(data); err == nil {
t.Fatal("ParseTransaction accepted a transaction with no Auth/Sig object")
}
}
// TestParseRefusesAContentObjectThatIsNotOne proves the leading message is held
// to more than its declared length. zapLen reads only the size field, so a
// buffer can name a plausible length and still not be a message at all — a
// wrong magic, or a wire version this build does not speak.
func TestParseRefusesAContentObjectThatIsNotOne(t *testing.T) {
sound := sampleTx().Bytes()
for name, corrupt := range map[string]func([]byte){
"a magic this is not": func(b []byte) { b[0] ^= 0xff },
"a version we do not speak": func(b []byte) { binary.LittleEndian.PutUint16(b[4:6], 0xbeef) },
} {
t.Run(name, func(t *testing.T) {
data := append([]byte(nil), sound...)
corrupt(data)
if _, err := zapLen(data); err != nil {
t.Fatalf("the length field is still sound; zapLen must pass: %v", err)
}
if _, err := ParseTransaction(data); err == nil {
t.Fatal("ParseTransaction accepted a buffer that is not a message")
}
})
}
if _, err := ParseTransaction(sound); err != nil {
t.Fatalf("the control failed: %v", err)
}
}