go-ethereum/core/state/reader_bintrie_test.go
CPerezz 2915a9c30d
core/state,triedb/pathdb: add integration tests for storage tombstone, multi-block evolution, and contract code generation
Addresses review test gaps T8, T9, T10.

TestBintrieFlatReaderStorageTombstone (T8):
  Write slot=0x42 in block 1; clear to zero in block 2. Read at
  block 2 → common.Hash{} (tombstone, not absent). Read at block 1 →
  0x42 (original value preserved in the diff layer chain).

TestBintrieFlatReaderMultiBlockEvolution (T9):
  Write nonce=1/balance=100 in block 1, nonce=2 in block 2,
  balance=200 in block 3. Open StateReaders at each root and assert
  the correct snapshot for each block. Validates diff-layer chaining
  under the bintrie path.

TestBintrieGeneratorWithContractCode (T10):
  Build a bintrie with a contract having ~100 bytes of code (4
  chunks at offsets 128..131). Run the generator and verify code-chunk
  offsets appear in the resulting stem blob. Validates the plan's
  claim that code chunks are handled by the generator.
2026-04-15 15:00:41 +02:00

474 lines
16 KiB
Go

// Copyright 2026 The go-ethereum Authors
// This file is part of the go-ethereum library.
//
// The go-ethereum library is free software: you can redistribute it and/or modify
// it under the terms of the GNU Lesser General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// The go-ethereum library is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.
package state
import (
"errors"
"testing"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/rawdb"
"github.com/ethereum/go-ethereum/core/tracing"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/triedb"
"github.com/holiman/uint256"
)
// TestBintrieFlatReaderEndToEnd is the integration test that exercises
// the full Commit-10 read path for a binary-trie database:
//
// 1. Build a fresh verkle pathdb-backed StateDB.
// 2. Mutate accounts (balance, nonce, code) and storage slots; the
// binaryHasher produces leaf writes via DrainStemWrites under the
// hood (Commit 7).
// 3. Commit through the standard StateDB.Commit pipeline. This drives
// stateUpdate.encodeBinary (Commit 8) which converts the leaves
// into per-offset accountData entries that flow into pathdb's
// stateSet, then are persisted to disk via the bintrie codec's
// Flush method (Commit 8).
// 4. Open a StateReader for the resulting root. CachingDB.StateReader
// installs a bintrieFlatReader (Commit 10) ahead of the trie
// reader because db.TrieDB().IsVerkle() is true.
// 5. Read the accounts and one storage slot back through the
// StateReader and assert the values round-trip exactly.
//
// This is the canonical "does the bintrie flat-state read path actually
// work end-to-end" test. If it fails, something between the hasher's
// leaf production and the disk-layer reads is wrong.
func TestBintrieFlatReaderEndToEnd(t *testing.T) {
disk := rawdb.NewMemoryDatabase()
tdb := triedb.NewDatabase(disk, triedb.VerkleDefaults)
sdb := NewDatabase(tdb, nil)
// A fresh verkle pathdb's disk layer is keyed by EmptyVerkleHash
// (all-zero hash), not EmptyRootHash. The TestVerkleCodeSizePreserved
// helper documents this gotcha.
state, err := New(types.EmptyVerkleHash, sdb)
if err != nil {
t.Fatalf("init state: %v", err)
}
var (
addrA = common.HexToAddress("0xAAaaAAaaAAaaAAaaAAaaAAaaAAaaAAaaAAaaAAaa")
addrB = common.HexToAddress("0xBBbbBBbbBBbbBBbbBBbbBBbbBBbbBBbbBBbbBBbb")
balance = uint256.NewInt(0xCAFE)
slot = common.HexToHash("0x07")
value = common.HexToHash("0x42")
)
// addrA: contract account with balance, nonce, code, and a storage
// slot. Slot 7 is in the EIP-7864 header range so it shares a stem
// with the BasicData leaf, exercising the per-stem RMW path.
state.SetBalance(addrA, balance, tracing.BalanceChangeUnspecified)
state.SetNonce(addrA, 5, tracing.NonceChangeUnspecified)
state.SetCode(addrA, []byte{0x60, 0x80, 0x60, 0x40}, tracing.CodeChangeUnspecified)
state.SetState(addrA, slot, value)
// addrB: EOA with only a balance set. Lives at a different stem so
// it tests two distinct stems landing in the same flush.
state.SetBalance(addrB, uint256.NewInt(0xBEEF), tracing.BalanceChangeUnspecified)
root, err := state.Commit(0, true, false)
if err != nil {
t.Fatalf("commit: %v", err)
}
// Now read the state back via a StateReader for the new root. The
// dispatch in CachingDB.StateReader uses bintrieFlatReader because
// IsVerkle() is true.
reader, err := sdb.StateReader(root)
if err != nil {
t.Fatalf("StateReader: %v", err)
}
gotA, err := reader.Account(addrA)
if err != nil {
t.Fatalf("Account A: %v", err)
}
if gotA == nil {
t.Fatal("addrA: account is nil after commit")
}
if gotA.Nonce != 5 {
t.Errorf("addrA nonce: got %d, want 5", gotA.Nonce)
}
if gotA.Balance.Cmp(balance) != 0 {
t.Errorf("addrA balance: got %s, want %s", gotA.Balance, balance)
}
if len(gotA.CodeHash) != 32 {
t.Errorf("addrA code hash: got %d-byte hash, want 32", len(gotA.CodeHash))
}
gotB, err := reader.Account(addrB)
if err != nil {
t.Fatalf("Account B: %v", err)
}
if gotB == nil {
t.Fatal("addrB: account is nil after commit")
}
if gotB.Balance.Uint64() != 0xBEEF {
t.Errorf("addrB balance: got %s, want 0xBEEF", gotB.Balance)
}
// Storage slot round-trip: SetState wrote value at slot 7 of addrA.
// The bintrieFlatReader.Storage call derives the bintrie storage
// key locally and looks it up via pathdb's AccountRLP path.
gotSlot, err := reader.Storage(addrA, slot)
if err != nil {
t.Fatalf("Storage: %v", err)
}
if gotSlot != value {
t.Errorf("storage slot: got %x, want %x", gotSlot, value)
}
}
// TestBintrieFlatReaderMissingAccountSentinel verifies that the bintrie
// flat reader returns errBintrieFlatStateMiss (a non-nil error sentinel)
// for an account that was never written to the flat state.
//
// Post-A2: the flat reader returns errBintrieFlatStateMiss so the
// multiStateReader falls through to the trie reader. This is the
// correct behavior: the flat state does not have the entry, so the
// trie reader should be the gatekeeper.
//
// KNOWN ISSUE: BinaryTrie.GetAccount does NOT verify stem membership —
// it returns the closest stem's data for ANY address query. So the trie
// reader currently returns wrong data for non-existent addresses. That
// is a pre-existing bintrie bug (not introduced by A2). This test
// therefore verifies the FLAT READER's sentinel error directly, in
// isolation from the buggy trie reader fallthrough path.
func TestBintrieFlatReaderMissingAccountSentinel(t *testing.T) {
disk := rawdb.NewMemoryDatabase()
tdb := triedb.NewDatabase(disk, triedb.VerkleDefaults)
sdb := NewDatabase(tdb, nil)
state, err := New(types.EmptyVerkleHash, sdb)
if err != nil {
t.Fatalf("init state: %v", err)
}
// Touch addrA so the trie has at least one stem.
addrA := common.HexToAddress("0x0101010101010101010101010101010101010101")
state.SetBalance(addrA, uint256.NewInt(1), tracing.BalanceChangeUnspecified)
root, err := state.Commit(0, true, false)
if err != nil {
t.Fatalf("commit: %v", err)
}
// Get the pathdb reader so we can test the bintrieFlatReader in
// isolation — not through multiStateReader (which would fall
// through to the buggy trie reader).
pathdbReader, err := tdb.StateReader(root)
if err != nil {
t.Fatalf("pathdb StateReader: %v", err)
}
br := newBintrieFlatReader(pathdbReader)
if br == nil {
t.Fatal("newBintrieFlatReader returned nil")
}
missing := common.HexToAddress("0xfeedfacefeedfacefeedfacefeedfacefeedface")
_, flatErr := br.Account(missing)
if flatErr == nil {
t.Fatal("expected errBintrieFlatStateMiss for missing account, got nil error")
}
if !errors.Is(flatErr, errBintrieFlatStateMiss) {
t.Fatalf("expected errBintrieFlatStateMiss, got: %v", flatErr)
}
}
// TestBintrieFlatReaderEndToEndAfterFlush is the smoking-gun regression
// test for A1 (fix bintrieFlatReader disk-layer shape). Before the A1
// remediation, `bintrieFlatCodec.ReadAccount` returned the full stem
// blob from disk while `bintrieFlatReader.Account` expected a per-offset
// 32-byte value — so every disk-layer hit errored with "bintrie
// BasicData leaf invalid length". The original TestBintrieFlatReaderEndToEnd
// did not catch this because it never flushed the write buffer to disk:
// all reads came from the in-memory diff-layer buffer (which stores
// per-offset entries correctly).
//
// This test explicitly calls `tdb.Commit(root, false)` after the state
// commit, forcing the buffer to flush. Subsequent reads MUST hit the
// disk-layer code path. If A1 regresses, the reads either error out or
// return wrong data.
func TestBintrieFlatReaderEndToEndAfterFlush(t *testing.T) {
disk := rawdb.NewMemoryDatabase()
tdb := triedb.NewDatabase(disk, triedb.VerkleDefaults)
sdb := NewDatabase(tdb, nil)
state, err := New(types.EmptyVerkleHash, sdb)
if err != nil {
t.Fatalf("init state: %v", err)
}
var (
addrA = common.HexToAddress("0xAAaaAAaaAAaaAAaaAAaaAAaaAAaaAAaaAAaaAAaa")
addrB = common.HexToAddress("0xBBbbBBbbBBbbBBbbBBbbBBbbBBbbBBbbBBbbBBbb")
balance = uint256.NewInt(0xCAFE)
slot = common.HexToHash("0x07")
value = common.HexToHash("0x42")
)
state.SetBalance(addrA, balance, tracing.BalanceChangeUnspecified)
state.SetNonce(addrA, 5, tracing.NonceChangeUnspecified)
state.SetCode(addrA, []byte{0x60, 0x80, 0x60, 0x40}, tracing.CodeChangeUnspecified)
state.SetState(addrA, slot, value)
state.SetBalance(addrB, uint256.NewInt(0xBEEF), tracing.BalanceChangeUnspecified)
root, err := state.Commit(0, true, false)
if err != nil {
t.Fatalf("commit: %v", err)
}
// Force buffer → disk flush. Without this, all reads below would hit
// the in-memory diff-layer buffer path, masking the A1 bug.
if err := tdb.Commit(root, false); err != nil {
t.Fatalf("tdb.Commit (flush to disk): %v", err)
}
// Open a fresh StateReader for the flushed root. Reads now go
// through the disk layer via `codec.ReadAccount`, which (post-A1)
// must return per-offset 32-byte values matching what the reader
// expects.
reader, err := sdb.StateReader(root)
if err != nil {
t.Fatalf("StateReader after flush: %v", err)
}
gotA, err := reader.Account(addrA)
if err != nil {
t.Fatalf("Account A after flush: %v", err)
}
if gotA == nil {
t.Fatal("addrA: account is nil after flush (A1 regression)")
}
if gotA.Nonce != 5 {
t.Errorf("addrA nonce after flush: got %d, want 5", gotA.Nonce)
}
if gotA.Balance.Cmp(balance) != 0 {
t.Errorf("addrA balance after flush: got %s, want %s", gotA.Balance, balance)
}
gotB, err := reader.Account(addrB)
if err != nil {
t.Fatalf("Account B after flush: %v", err)
}
if gotB == nil {
t.Fatal("addrB: account is nil after flush (A1 regression)")
}
if gotB.Balance.Uint64() != 0xBEEF {
t.Errorf("addrB balance after flush: got %s, want 0xBEEF", gotB.Balance)
}
gotSlot, err := reader.Storage(addrA, slot)
if err != nil {
t.Fatalf("Storage after flush: %v", err)
}
if gotSlot != value {
t.Errorf("storage slot after flush: got %x, want %x", gotSlot, value)
}
}
// TestBintrieFlatReaderMultipleOffsetsPerStem verifies that multiple
// offsets at the same stem (BasicData at offset 0, CodeHash at offset 1,
// a header storage slot at offset 64+slotnum) all round-trip correctly
// through the per-offset read path. This exercises the "same stem, many
// offsets" common case for contract accounts with header storage.
func TestBintrieFlatReaderMultipleOffsetsPerStem(t *testing.T) {
disk := rawdb.NewMemoryDatabase()
tdb := triedb.NewDatabase(disk, triedb.VerkleDefaults)
sdb := NewDatabase(tdb, nil)
state, err := New(types.EmptyVerkleHash, sdb)
if err != nil {
t.Fatalf("init state: %v", err)
}
addr := common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678")
state.SetBalance(addr, uint256.NewInt(100), tracing.BalanceChangeUnspecified)
state.SetNonce(addr, 7, tracing.NonceChangeUnspecified)
state.SetCode(addr, []byte{0xDE, 0xAD, 0xBE, 0xEF}, tracing.CodeChangeUnspecified)
// Header slots 0..63 (per EIP-7864) live at the same stem as
// BasicData/CodeHash. Set a few to exercise multi-offset per stem.
state.SetState(addr, common.HexToHash("0x00"), common.HexToHash("0x11"))
state.SetState(addr, common.HexToHash("0x01"), common.HexToHash("0x22"))
state.SetState(addr, common.HexToHash("0x05"), common.HexToHash("0x33"))
root, err := state.Commit(0, true, false)
if err != nil {
t.Fatalf("commit: %v", err)
}
// Flush so the reads hit the disk path.
if err := tdb.Commit(root, false); err != nil {
t.Fatalf("tdb.Commit: %v", err)
}
reader, err := sdb.StateReader(root)
if err != nil {
t.Fatalf("StateReader: %v", err)
}
gotAcct, err := reader.Account(addr)
if err != nil {
t.Fatalf("Account: %v", err)
}
if gotAcct == nil {
t.Fatal("account is nil")
}
if gotAcct.Nonce != 7 {
t.Errorf("nonce: got %d, want 7", gotAcct.Nonce)
}
if gotAcct.Balance.Uint64() != 100 {
t.Errorf("balance: got %s, want 100", gotAcct.Balance)
}
for _, tc := range []struct{ slot, want common.Hash }{
{common.HexToHash("0x00"), common.HexToHash("0x11")},
{common.HexToHash("0x01"), common.HexToHash("0x22")},
{common.HexToHash("0x05"), common.HexToHash("0x33")},
} {
got, err := reader.Storage(addr, tc.slot)
if err != nil {
t.Fatalf("Storage(%x): %v", tc.slot, err)
}
if got != tc.want {
t.Errorf("slot %x: got %x, want %x", tc.slot, got, tc.want)
}
}
}
// TestBintrieFlatReaderStorageTombstone verifies the bintrie "tombstone"
// convention: a storage slot set to zero is present-with-32-zero-bytes,
// which must be distinguishable from "never written" (absent). This is
// the A16/T8 integration test.
func TestBintrieFlatReaderStorageTombstone(t *testing.T) {
disk := rawdb.NewMemoryDatabase()
tdb := triedb.NewDatabase(disk, triedb.VerkleDefaults)
sdb := NewDatabase(tdb, nil)
addr := common.HexToAddress("0xABCDEF0123456789ABCDEF0123456789ABCDEF01")
slot := common.HexToHash("0x07")
nonZero := common.HexToHash("0x42")
// Block 1: set slot to non-zero.
state1, _ := New(types.EmptyVerkleHash, sdb)
state1.SetBalance(addr, uint256.NewInt(1), tracing.BalanceChangeUnspecified)
state1.SetState(addr, slot, nonZero)
root1, err := state1.Commit(0, true, false)
if err != nil {
t.Fatalf("commit block 1: %v", err)
}
// Block 2: set the same slot to zero (the bintrie writes 32 zero
// bytes as a tombstone rather than deleting the offset).
state2, _ := New(root1, sdb)
state2.SetState(addr, slot, common.Hash{})
root2, err := state2.Commit(1, true, false)
if err != nil {
t.Fatalf("commit block 2: %v", err)
}
// Read at block 2: should be the zero hash.
reader2, err := sdb.StateReader(root2)
if err != nil {
t.Fatalf("StateReader(block2): %v", err)
}
got2, err := reader2.Storage(addr, slot)
if err != nil {
t.Fatalf("Storage(block2): %v", err)
}
if got2 != (common.Hash{}) {
t.Errorf("block 2 slot: got %x, want zero", got2)
}
// Read at block 1: should still be the non-zero value.
reader1, err := sdb.StateReader(root1)
if err != nil {
t.Fatalf("StateReader(block1): %v", err)
}
got1, err := reader1.Storage(addr, slot)
if err != nil {
t.Fatalf("Storage(block1): %v", err)
}
if got1 != nonZero {
t.Errorf("block 1 slot: got %x, want %x", got1, nonZero)
}
}
// TestBintrieFlatReaderMultiBlockEvolution verifies that diff-layer
// chaining works correctly across multiple blocks for the bintrie path.
// This is the A16/T9 integration test.
func TestBintrieFlatReaderMultiBlockEvolution(t *testing.T) {
disk := rawdb.NewMemoryDatabase()
tdb := triedb.NewDatabase(disk, triedb.VerkleDefaults)
sdb := NewDatabase(tdb, nil)
addr := common.HexToAddress("0xDeaDBeefDeaDBeefDeaDBeefDeaDBeefDeaDBeef")
// Block 1: nonce=1, balance=100
state1, _ := New(types.EmptyVerkleHash, sdb)
state1.SetBalance(addr, uint256.NewInt(100), tracing.BalanceChangeUnspecified)
state1.SetNonce(addr, 1, tracing.NonceChangeUnspecified)
root1, err := state1.Commit(0, true, false)
if err != nil {
t.Fatalf("commit block 1: %v", err)
}
// Block 2: nonce=2 (balance unchanged at 100)
state2, _ := New(root1, sdb)
state2.SetNonce(addr, 2, tracing.NonceChangeUnspecified)
root2, err := state2.Commit(1, true, false)
if err != nil {
t.Fatalf("commit block 2: %v", err)
}
// Block 3: balance=200 (nonce unchanged at 2)
state3, _ := New(root2, sdb)
state3.SetBalance(addr, uint256.NewInt(200), tracing.BalanceChangeUnspecified)
root3, err := state3.Commit(2, true, false)
if err != nil {
t.Fatalf("commit block 3: %v", err)
}
// Read at each root and verify the expected snapshot.
for _, tc := range []struct {
name string
root common.Hash
nonce uint64
balance uint64
}{
{"block1", root1, 1, 100},
{"block2", root2, 2, 100},
{"block3", root3, 2, 200},
} {
reader, err := sdb.StateReader(tc.root)
if err != nil {
t.Fatalf("%s StateReader: %v", tc.name, err)
}
got, err := reader.Account(addr)
if err != nil {
t.Fatalf("%s Account: %v", tc.name, err)
}
if got == nil {
t.Fatalf("%s: account is nil", tc.name)
}
if got.Nonce != tc.nonce {
t.Errorf("%s nonce: got %d, want %d", tc.name, got.Nonce, tc.nonce)
}
if got.Balance.Uint64() != tc.balance {
t.Errorf("%s balance: got %d, want %d", tc.name, got.Balance.Uint64(), tc.balance)
}
}
}