diff --git a/cmd/evm/internal/t8ntool/execution.go b/cmd/evm/internal/t8ntool/execution.go
index 5178c0d4df5f..40322f73cab1 100644
--- a/cmd/evm/internal/t8ntool/execution.go
+++ b/cmd/evm/internal/t8ntool/execution.go
@@ -363,7 +363,8 @@ func (pre *Prestate) Apply(vmConfig vm.Config, chainConfig *params.ChainConfig,
for _, receipt := range receipts {
allLogs = append(allLogs, receipt.Logs...)
}
- requests, bal, err := core.PostExecution(context.Background(), chainConfig, vmContext.BlockNumber, vmContext.Time, allLogs, evm, uint32(len(receipts)+1))
+ // EIP-8304: log index tables are not built by t8n, wired in a later PR
+ requests, bal, err := core.PostExecution(context.Background(), chainConfig, vmContext.BlockNumber, vmContext.Time, allLogs, nil, evm, uint32(len(receipts)+1))
if err != nil {
return nil, nil, nil, NewError(ErrorEVM, fmt.Errorf("failed to process post-execution: %v", err))
}
diff --git a/core/chain_makers.go b/core/chain_makers.go
index 99c36b536607..3709b53eb125 100644
--- a/core/chain_makers.go
+++ b/core/chain_makers.go
@@ -326,7 +326,8 @@ func (b *BlockGen) collectRequests(readonly bool) (requests [][]byte, bal *bal.C
blockContext := NewEVMBlockContext(b.header, b.cm, &b.header.Coinbase)
evm := vm.NewEVM(blockContext, statedb, b.cm.config, vm.Config{})
- requests, bal, err := PostExecution(context.Background(), b.cm.config, b.header.Number, b.header.Time, blockLogs, evm, uint32(len(b.txs)+1))
+ // EIP-8304: log index tables are not built for test chains, wired in a later PR
+ requests, bal, err := PostExecution(context.Background(), b.cm.config, b.header.Number, b.header.Time, blockLogs, nil, evm, uint32(len(b.txs)+1))
if err != nil {
panic(fmt.Sprintf("failed to run post-execution: %v", err))
}
diff --git a/core/genesis.go b/core/genesis.go
index c0c526ccf4d8..f170a81dbf2e 100644
--- a/core/genesis.go
+++ b/core/genesis.go
@@ -747,6 +747,9 @@ func DeveloperGenesisBlock(gasLimit uint64, faucet *common.Address) *Genesis {
}
// Pre-deploy the system contracts the enabled forks call into
maps.Copy(genesis.Alloc, SystemContractAllocs())
+ // EIP-8304: pre-deploy the index contract. Kept out of SystemContractAllocs
+ // so test-chain genesis matches upstream until the fork's own genesis.
+ genesis.Alloc[params.IndexContractAddress] = types.Account{Nonce: 1, Code: params.IndexContractCode, Balance: common.Big0}
if faucet != nil {
genesis.Alloc[*faucet] = types.Account{Balance: new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(9))}
}
diff --git a/core/log_index_builder.go b/core/log_index_builder.go
new file mode 100644
index 000000000000..6b559a8e26e4
--- /dev/null
+++ b/core/log_index_builder.go
@@ -0,0 +1,39 @@
+// 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 .
+
+package core
+
+import (
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/core/types"
+)
+
+// TableWrite describes a table root that should be written to the index contract.
+type TableWrite struct {
+ FirstBlock uint64
+ TableSize uint64
+ Root common.Hash
+}
+
+// BuildLogIndexForBlock builds the EIP-8304 level-0 log index table for the given
+// block. parentHash is the hash of the parent block, whose block entry is added
+// with the spec's one-block delay (none for the genesis block). Interim
+// implementation: variable-length entries and a flat keccak root.
+func BuildLogIndexForBlock(blockNumber uint64, parentHash common.Hash, receipts types.Receipts) []TableWrite {
+ b0 := types.NewIndexBuilder()
+ b0.AddBlockEntries(parentHash, blockNumber, receipts)
+ return []TableWrite{{FirstBlock: blockNumber, TableSize: 1, Root: b0.Build()}}
+}
diff --git a/core/log_index_builder_test.go b/core/log_index_builder_test.go
new file mode 100644
index 000000000000..43788c4a7c7d
--- /dev/null
+++ b/core/log_index_builder_test.go
@@ -0,0 +1,50 @@
+// 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 .
+
+package core
+
+import (
+ "testing"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/core/types"
+)
+
+// TestBuildLogIndexForBlock pins the level-0 wrapper: one table per block,
+// carrying the builder root of the block's entries including the parent's
+// block entry (one-block delay).
+func TestBuildLogIndexForBlock(t *testing.T) {
+ parentHash := common.HexToHash("0x978ce0036b6d1c62d716045505587d15cc85a1def92f9f450937b6467295e517")
+ receipts := types.Receipts{
+ {TxHash: common.HexToHash("0xca2d12d1b8132de09d0d668cc87349dc70134bee3010e03ddb2d83f7160bd6e3"), Logs: []*types.Log{
+ {Address: common.HexToAddress("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"), Topics: []common.Hash{
+ common.HexToHash("0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"),
+ }},
+ }},
+ }
+ wantRoot := types.NewIndexBuilder()
+ wantRoot.AddBlockEntries(parentHash, 42, receipts)
+ tables := BuildLogIndexForBlock(42, parentHash, receipts)
+ if len(tables) != 1 {
+ t.Fatalf("table count = %d, want 1", len(tables))
+ }
+ if tables[0].FirstBlock != 42 || tables[0].TableSize != 1 {
+ t.Errorf("table = {first %d, size %d}, want {42, 1}", tables[0].FirstBlock, tables[0].TableSize)
+ }
+ if tables[0].Root != wantRoot.Build() {
+ t.Errorf("root %x, want %x", tables[0].Root, wantRoot.Build())
+ }
+}
diff --git a/core/logindex_contract_test.go b/core/logindex_contract_test.go
new file mode 100644
index 000000000000..0ebc0ae299dd
--- /dev/null
+++ b/core/logindex_contract_test.go
@@ -0,0 +1,225 @@
+// 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 .
+
+package core
+
+import (
+ "bytes"
+ "encoding/binary"
+ "math/big"
+ "testing"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/consensus"
+ "github.com/ethereum/go-ethereum/consensus/beacon"
+ "github.com/ethereum/go-ethereum/consensus/ethash"
+ "github.com/ethereum/go-ethereum/core/rawdb"
+ "github.com/ethereum/go-ethereum/core/state"
+ "github.com/ethereum/go-ethereum/core/types"
+ "github.com/ethereum/go-ethereum/core/vm"
+ "github.com/ethereum/go-ethereum/crypto"
+ "github.com/ethereum/go-ethereum/params"
+ "github.com/ethereum/go-ethereum/triedb"
+)
+
+// eip8304ChainContext is a minimal ChainContext for exercising the EIP-8304
+// index contract in isolation.
+type eip8304ChainContext struct{ cfg *params.ChainConfig }
+
+func (eip8304ChainContext) Engine() consensus.Engine { return beacon.New(ethash.NewFaker()) }
+func (c eip8304ChainContext) Config() *params.ChainConfig { return c.cfg }
+func (eip8304ChainContext) GetHeader(common.Hash, uint64) *types.Header { return nil }
+func (eip8304ChainContext) CurrentHeader() *types.Header { return nil }
+func (eip8304ChainContext) GetHeaderByHash(common.Hash) *types.Header { return nil }
+func (eip8304ChainContext) GetHeaderByNumber(uint64) *types.Header { return nil }
+
+// newEIP8304ContractState creates a fresh state for exercising the index
+// contract in isolation.
+func newEIP8304ContractState(t *testing.T) *state.StateDB {
+ t.Helper()
+ diskdb := rawdb.NewMemoryDatabase()
+ tdb := triedb.NewDatabase(diskdb, nil)
+ statedb, err := state.New(common.Hash{}, state.NewMPTDatabase(tdb, state.NewCodeDB(diskdb)))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return statedb
+}
+
+// newEIP8304ContractEVM creates an EVM over the given state with the given
+// block number, which drives the contract's NUMBER-based freshness checks.
+func newEIP8304ContractEVM(t *testing.T, statedb *state.StateDB, blockNumber uint64) *vm.EVM {
+ t.Helper()
+ header := &types.Header{
+ Number: new(big.Int).SetUint64(blockNumber),
+ Time: blockNumber,
+ GasLimit: 30_000_000,
+ Coinbase: common.Address{},
+ BaseFee: big.NewInt(0),
+ Difficulty: big.NewInt(0),
+ }
+ ctx := NewEVMBlockContext(header, eip8304ChainContext{params.MergedTestChainConfig}, &header.Coinbase)
+ return vm.NewEVM(ctx, statedb, params.MergedTestChainConfig, vm.Config{})
+}
+
+// deployEIP8304IndexContract deploys the EIP-8304 index contract from its init
+// code and returns the resulting address.
+func deployEIP8304IndexContract(t *testing.T, evm *vm.EVM) common.Address {
+ t.Helper()
+ _, addr, _, err := evm.Create(crypto.PubkeyToAddress(eip8304TestKey.PublicKey), params.IndexContractInitCode, vm.NewGasBudget(5_000_000, 0), common.U2560)
+ if err != nil {
+ t.Fatalf("deploy failed: %v", err)
+ }
+ return addr
+}
+
+// setCalldata builds the 96-byte set payload: first_block, table_size,
+// table_root (all big-endian 32-byte words).
+func setCalldata(firstBlock, tableSize uint64, root common.Hash) []byte {
+ calldata := make([]byte, 96)
+ binary.BigEndian.PutUint64(calldata[24:32], firstBlock)
+ binary.BigEndian.PutUint64(calldata[56:64], tableSize)
+ copy(calldata[64:96], root[:])
+ return calldata
+}
+
+// getCalldata builds the 64-byte get payload: first_block, table_size.
+func getCalldata(firstBlock, tableSize uint64) []byte {
+ calldata := make([]byte, 64)
+ binary.BigEndian.PutUint64(calldata[24:32], firstBlock)
+ binary.BigEndian.PutUint64(calldata[56:64], tableSize)
+ return calldata
+}
+
+// indexSlot is the storage slot for a table root per the EIP-8304 spec:
+// table_size * TABLES_PER_LEVEL + (first_block / table_size) % TABLES_PER_LEVEL.
+func indexSlot(firstBlock, tableSize uint64) common.Hash {
+ return common.BigToHash(new(big.Int).SetUint64(tableSize*0x400 + (firstBlock/tableSize)%0x400))
+}
+
+var eip8304TestKey, _ = crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291")
+
+// TestEIP8304IndexContract pins the behavior of the EIP-8304 index contract
+// against the spec:
+//
+// - the deployed code must be exactly params.IndexContractCode (guards the
+// init-code/runtime consistency, which has regressed before)
+// - set: only the system address may write; the root lands at
+// table_size*1024 + (first_block/table_size)%1024
+// - get: 64-byte calldata returns the stored root; reverts on wrong size or
+// a first_block that is not a multiple of table_size
+func TestEIP8304IndexContract(t *testing.T) {
+ t.Run("deployment", func(t *testing.T) {
+ statedb := newEIP8304ContractState(t)
+ evm := newEIP8304ContractEVM(t, statedb, 1)
+ addr := deployEIP8304IndexContract(t, evm)
+ if got := statedb.GetCode(addr); !bytes.Equal(got, params.IndexContractCode) {
+ t.Fatalf("deployed code mismatch: got %d bytes, want %d", len(got), len(params.IndexContractCode))
+ }
+ })
+
+ t.Run("set", func(t *testing.T) {
+ root := common.HexToHash("0x00000000000000000000000000000000000000000000000000000000deadbeef")
+ cases := []struct{ fb, ts uint64 }{
+ {2, 1}, {5, 2}, {8, 4}, {1030, 4}, {1024, 256},
+ }
+ for _, c := range cases {
+ statedb := newEIP8304ContractState(t)
+ evm := newEIP8304ContractEVM(t, statedb, 1)
+ addr := deployEIP8304IndexContract(t, evm)
+ if _, _, err := evm.Call(params.SystemAddress, addr, setCalldata(c.fb, c.ts, root), vm.NewGasBudget(5_000_000, 0), common.U2560); err != nil {
+ t.Fatalf("set(%d, %d) failed: %v", c.fb, c.ts, err)
+ }
+ if got := statedb.GetState(addr, indexSlot(c.fb, c.ts)); got != root {
+ t.Fatalf("set(%d, %d): slot %s = %x, want %x", c.fb, c.ts, indexSlot(c.fb, c.ts), got, root)
+ }
+ }
+ })
+
+ t.Run("set-caller-guard", func(t *testing.T) {
+ statedb := newEIP8304ContractState(t)
+ evm := newEIP8304ContractEVM(t, statedb, 1)
+ addr := deployEIP8304IndexContract(t, evm)
+ root := common.HexToHash("0xdeadbeef")
+ sender := crypto.PubkeyToAddress(eip8304TestKey.PublicKey)
+ if _, _, err := evm.Call(sender, addr, setCalldata(2, 1, root), vm.NewGasBudget(5_000_000, 0), common.U2560); err == nil {
+ t.Fatal("non-system caller with set calldata: expected revert")
+ }
+ if got := statedb.GetState(addr, indexSlot(2, 1)); got != (common.Hash{}) {
+ t.Fatalf("non-system caller wrote slot %s = %x", indexSlot(2, 1), got)
+ }
+ })
+
+ t.Run("get", func(t *testing.T) {
+ root := common.HexToHash("0x00000000000000000000000000000000000000000000000000000000cafebabe")
+ sender := crypto.PubkeyToAddress(eip8304TestKey.PublicKey)
+ // The get path's freshness check is one-sided: table (fb, ts) remains
+ // readable while num < fb + 1025*ts + ts/4 (the ring-buffer overwrite
+ // window plus the table_size/4 publication delay, with truncating
+ // division). There is no lower age bound. Write at block 1, then read
+ // at the boundaries.
+ for _, c := range []struct {
+ fb, ts, num uint64
+ wantErr bool
+ }{
+ {fb: 2, ts: 1, num: 100, wantErr: false}, // no lower age bound
+ {fb: 2, ts: 1, num: 1026, wantErr: false}, // last readable block
+ {fb: 2, ts: 1, num: 1027, wantErr: true}, // ring window expired
+ {fb: 8, ts: 4, num: 4108, wantErr: false}, // ts=4 window
+ {fb: 8, ts: 4, num: 4109, wantErr: true},
+ } {
+ statedb := newEIP8304ContractState(t)
+ evm := newEIP8304ContractEVM(t, statedb, 1)
+ addr := deployEIP8304IndexContract(t, evm)
+ if _, _, err := evm.Call(params.SystemAddress, addr, setCalldata(c.fb, c.ts, root), vm.NewGasBudget(5_000_000, 0), common.U2560); err != nil {
+ t.Fatalf("set(%d, %d) failed: %v", c.fb, c.ts, err)
+ }
+ evm = newEIP8304ContractEVM(t, statedb, c.num)
+ ret, _, err := evm.Call(sender, addr, getCalldata(c.fb, c.ts), vm.NewGasBudget(5_000_000, 0), common.U2560)
+ if c.wantErr {
+ if err == nil {
+ t.Fatalf("get(%d, %d) at block %d: expected revert", c.fb, c.ts, c.num)
+ }
+ continue
+ }
+ if err != nil {
+ t.Fatalf("get(%d, %d) at block %d failed: %v", c.fb, c.ts, c.num, err)
+ }
+ if got := common.BytesToHash(ret); got != root {
+ t.Fatalf("get(%d, %d) at block %d returned %x, want %x", c.fb, c.ts, c.num, got, root)
+ }
+ }
+ })
+
+ t.Run("get-guards", func(t *testing.T) {
+ root := common.HexToHash("0xcafebabe")
+ statedb := newEIP8304ContractState(t)
+ evm := newEIP8304ContractEVM(t, statedb, 259)
+ addr := deployEIP8304IndexContract(t, evm)
+ if _, _, err := evm.Call(params.SystemAddress, addr, setCalldata(2, 1, root), vm.NewGasBudget(5_000_000, 0), common.U2560); err != nil {
+ t.Fatalf("set failed: %v", err)
+ }
+ sender := crypto.PubkeyToAddress(eip8304TestKey.PublicKey)
+ // Wrong calldata size (32 bytes instead of 64).
+ if _, _, err := evm.Call(sender, addr, make([]byte, 32), vm.NewGasBudget(5_000_000, 0), common.U2560); err == nil {
+ t.Fatal("get with 32-byte calldata: expected revert")
+ }
+ // first_block not a multiple of table_size.
+ if _, _, err := evm.Call(sender, addr, getCalldata(3, 2), vm.NewGasBudget(5_000_000, 0), common.U2560); err == nil {
+ t.Fatal("get(3, 2): expected revert (3 is not a multiple of 2)")
+ }
+ })
+}
diff --git a/core/state_processor.go b/core/state_processor.go
index 05f4bee052ea..6363797fe5ec 100644
--- a/core/state_processor.go
+++ b/core/state_processor.go
@@ -18,6 +18,7 @@ package core
import (
"context"
+ "encoding/binary"
"fmt"
"math/big"
"sync/atomic"
@@ -131,7 +132,13 @@ func (p *StateProcessor) Process(ctx context.Context, block *types.Block, stated
blockAccessList.Merge(bal)
spanEnd(nil)
}
- requests, bal, err := PostExecution(ctx, config, block.Number(), block.Time(), allLogs, evm, uint32(len(block.Transactions())+1))
+ // EIP-8304: build the level-0 log index table root. No-op before the fork
+ // activates or when the contract is not deployed (non-dev chains).
+ var tablesToWrite []TableWrite
+ if config.IsAmsterdam(block.Number(), block.Time()) && evm.StateDB.GetCodeSize(params.IndexContractAddress) > 0 {
+ tablesToWrite = BuildLogIndexForBlock(block.Number().Uint64(), block.ParentHash(), receipts)
+ }
+ requests, bal, err := PostExecution(ctx, config, block.Number(), block.Time(), allLogs, tablesToWrite, evm, uint32(len(block.Transactions())+1))
if err != nil {
return nil, err
}
@@ -175,7 +182,7 @@ func PreExecution(ctx context.Context, beaconRoot *common.Hash, parent *types.He
// PostExecution processes post-execution system calls when Prague is enabled.
// If Prague is not activated, it returns null requests to differentiate from
// empty requests.
-func PostExecution(ctx context.Context, config *params.ChainConfig, number *big.Int, time uint64, allLogs []*types.Log, evm *vm.EVM, blockAccessIndex uint32) (requests [][]byte, blockAccessList *bal.ConstructionBlockAccessList, err error) {
+func PostExecution(ctx context.Context, config *params.ChainConfig, number *big.Int, time uint64, allLogs []*types.Log, tablesToWrite []TableWrite, evm *vm.EVM, blockAccessIndex uint32) (requests [][]byte, blockAccessList *bal.ConstructionBlockAccessList, err error) {
_, _, spanEnd := telemetry.StartSpan(ctx, "core.postExecution")
defer spanEnd(&err)
@@ -209,6 +216,18 @@ func PostExecution(ctx context.Context, config *params.ChainConfig, number *big.
return nil, nil, fmt.Errorf("failed to process builder exit queue: %w", err)
}
}
+
+ // EIP-8304: write log index table roots to the on-chain contract.
+ if config.IsAmsterdam(number, time) {
+ if len(tablesToWrite) > 0 && blockAccessList == nil {
+ blockAccessList = bal.NewConstructionBlockAccessList()
+ }
+ for _, tw := range tablesToWrite {
+ if err := ProcessLogIndexWrites(tw, rules, evm, blockAccessIndex, blockAccessList); err != nil {
+ return nil, nil, fmt.Errorf("failed to process log index write: %w", err)
+ }
+ }
+ }
return requests, blockAccessList, nil
}
@@ -379,6 +398,47 @@ func ProcessWithdrawalQueue(requests *[][]byte, rules params.Rules, evm *vm.EVM,
return processRequestsSystemCall(requests, rules, evm, 0x01, params.WithdrawalQueueAddress, blockAccessIndex, blockAccessList)
}
+// ProcessLogIndexWrites performs the EIP-8304 system call to write a table root
+// to the on-chain index contract. It follows the same pattern as processRequestsSystemCall
+// (EIP-7685) to ensure state changes persist correctly.
+func ProcessLogIndexWrites(tw TableWrite, rules params.Rules, evm *vm.EVM, blockAccessIndex uint32, blockAccessList *bal.ConstructionBlockAccessList) error {
+ if tracer := evm.Config.Tracer; tracer != nil {
+ onSystemCallStart(tracer, evm.GetVMContext())
+ if tracer.OnSystemCallEnd != nil {
+ defer tracer.OnSystemCallEnd()
+ }
+ }
+ gasLimit, gasBudget := systemCallGasBudget(evm)
+ // Build 96-byte calldata: first_block(32) + table_size(32) + table_root(32)
+ calldata := make([]byte, 96)
+ binary.BigEndian.PutUint64(calldata[24:32], tw.FirstBlock)
+ binary.BigEndian.PutUint64(calldata[56:64], tw.TableSize)
+ copy(calldata[64:96], tw.Root[:])
+ msg := &Message{
+ From: params.SystemAddress,
+ GasLimit: gasLimit,
+ GasPrice: uint256.NewInt(0),
+ GasFeeCap: uint256.NewInt(0),
+ GasTipCap: uint256.NewInt(0),
+ To: ¶ms.IndexContractAddress,
+ Data: calldata,
+ }
+ evm.SetTxContext(NewEVMTxContext(msg))
+ evm.StateDB.Prepare(rules, common.Address{}, common.Address{}, nil, nil, nil)
+ evm.StateDB.SetTxContext(common.Hash{}, 0, blockAccessIndex)
+ evm.StateDB.AddAddressToAccessList(params.IndexContractAddress)
+ _, _, err := evm.Call(msg.From, *msg.To, msg.Data, gasBudget, common.U2560)
+ if evm.StateDB.AccessEvents() != nil {
+ evm.StateDB.AccessEvents().Merge(evm.AccessEvents)
+ }
+ bal := evm.StateDB.Finalise(evm.GetRules())
+ if err != nil {
+ return fmt.Errorf("EIP-8304 system call failed: %v", err)
+ }
+ blockAccessList.Merge(bal)
+ return nil
+}
+
// ProcessConsolidationQueue calls the EIP-7251 consolidation queue contract.
// It returns the opaque request data returned by the contract.
func ProcessConsolidationQueue(requests *[][]byte, rules params.Rules, evm *vm.EVM, blockAccessIndex uint32, blockAccessList *bal.ConstructionBlockAccessList) error {
diff --git a/core/state_processor_parallel.go b/core/state_processor_parallel.go
index 6b4df1af85e0..619cf7409931 100644
--- a/core/state_processor_parallel.go
+++ b/core/state_processor_parallel.go
@@ -219,7 +219,8 @@ func (p *StateProcessor) processParallel(ctx context.Context, block *types.Block
if precompileCache != nil {
postEVM.SetPrecompileCache(precompileCache)
}
- requests, postBAL, err := PostExecution(ctx, config, header.Number, header.Time, allLogs, postEVM, postIndex)
+ // EIP-8304: log index tables are not built on the parallel path, wired in a later PR
+ requests, postBAL, err := PostExecution(ctx, config, header.Number, header.Time, allLogs, nil, postEVM, postIndex)
postEVM.Release()
if err != nil {
return nil, err
diff --git a/core/types/index_builder.go b/core/types/index_builder.go
new file mode 100644
index 000000000000..09bcb9949905
--- /dev/null
+++ b/core/types/index_builder.go
@@ -0,0 +1,81 @@
+// 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 .
+
+package types
+
+import (
+ "sort"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/crypto"
+)
+
+// IndexBuilder accumulates IndexEntry values and computes a Merkle root.
+type IndexBuilder struct {
+ entries []IndexEntry
+}
+
+func NewIndexBuilder() *IndexBuilder {
+ return &IndexBuilder{}
+}
+
+// AddBlockEntries adds the EIP-8304 entries of one block: the parent block's
+// block entry (one-block delay per the spec; none for the genesis block), one
+// transaction entry per receipt carrying the cumulative log count of the block
+// before that transaction, and per-log address and topic entries with log
+// indices relative to their transaction.
+func (b *IndexBuilder) AddBlockEntries(parentHash common.Hash, blockNumber uint64, receipts Receipts) {
+ if blockNumber > 0 {
+ // The table for block N contains the block entry of block N-1.
+ b.entries = append(b.entries, EncodeEntry(EntryTypeBlock, parentHash[:], blockNumber-1, 0, 0))
+ }
+ var cumLogs uint32
+ for txIdx, receipt := range receipts {
+ // Transaction entry: the trailing field is the cumulative log count
+ // of the block before this transaction.
+ b.entries = append(b.entries, EncodeEntry(EntryTypeTransaction, receipt.TxHash[:], blockNumber, uint32(txIdx), cumLogs))
+ for logIdx, log := range receipt.Logs {
+ // Log indices are relative to the transaction.
+ b.entries = append(b.entries, EncodeEntry(EntryTypeLogAddress, log.Address[:], blockNumber, uint32(txIdx), uint32(logIdx)))
+ // Topic entries
+ for topicIdx, topic := range log.Topics {
+ if topicIdx < 4 {
+ b.entries = append(b.entries, EncodeEntry(EntryTypeLogTopic0+EntryType(topicIdx), topic[:], blockNumber, uint32(txIdx), uint32(logIdx)))
+ }
+ }
+ }
+ cumLogs += uint32(len(receipt.Logs))
+ }
+}
+
+// Entries returns the accumulated entries slice (unsorted). Callers that need
+// sorted entries should call Build() or sort the returned slice themselves.
+func (b *IndexBuilder) Entries() []IndexEntry {
+ return b.entries
+}
+
+// Build sorts entries and returns a simple hash.
+func (b *IndexBuilder) Build() common.Hash {
+ sort.Slice(b.entries, func(i, j int) bool {
+ return CompareEntries(b.entries[i], b.entries[j]) < 0
+ })
+ // Simple hash: Keccak256 of all concatenated entries
+ var buf []byte
+ for _, e := range b.entries {
+ buf = append(buf, e...)
+ }
+ return crypto.Keccak256Hash(buf)
+}
diff --git a/core/types/index_builder_test.go b/core/types/index_builder_test.go
new file mode 100644
index 000000000000..60e62a7a6828
--- /dev/null
+++ b/core/types/index_builder_test.go
@@ -0,0 +1,208 @@
+// 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 .
+
+package types
+
+import (
+ "bytes"
+ "encoding/hex"
+ "testing"
+
+ "github.com/ethereum/go-ethereum/common"
+)
+
+// Spec worked example (EIP-8304, pinned at
+// ethereum/EIPs@a9b074cffb5bea63b201ee5fabf19553dbfe483a): blocks 40-43 of a
+// chain whose 42-43 contain WETH/USDT transfers.
+//
+// Note on the spec's internal inconsistency: the chronological table lists the
+// 42tx1 hash as 0xa75590c9... while the binary table encodes 0x75590c9c...
+// This test pins the binary table (which is the authoritative encoding).
+var (
+ specHash39 = common.HexToHash("0xbf98e6cb26f6ff312586968d1f343a3d3c439a8c5c86233aff2a82f1a68263df")
+ specHash40 = common.HexToHash("0x42f66a2e9f9c68e223e8d826145d7cfacb00520dba6a9555803121de29790b65")
+ specHash41 = common.HexToHash("0x978ce0036b6d1c62d716045505587d15cc85a1def92f9f450937b6467295e517")
+ specHash42 = common.HexToHash("0x66f42ef12b140e8004ad39a760191457f485204ee6c9f990c33f14014e521f20")
+
+ specTx42_0 = common.HexToHash("0xca2d12d1b8132de09d0d668cc87349dc70134bee3010e03ddb2d83f7160bd6e3")
+ specTx42_1 = common.HexToHash("0x75590c9ced72898d6207c917e11b452949043404a1802b893f7717a9c1c8f45d")
+ specTx43_0 = common.HexToHash("0x7046035b326ab22a3142e4416ed4300a28a483a31a1e04cf62bec575b2e7cf09")
+
+ specWETH = common.HexToAddress("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2")
+ specUSDT = common.HexToAddress("0xdac17f958d2ee523a2206206994597c13d831ec7")
+
+ specTransfer = common.HexToHash("0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef")
+ specWithdrawal = common.HexToHash("0x7fcf532c15f0a6db0bd6d0e038bea71d30d808c7d98cb3bf7268a95bf5081b65")
+ specAlice = common.HexToHash("0x0000000000000000000000004a5e9a3bf35df5e0ea4b4cd3289e0111f1deadf7")
+ specBob = common.HexToHash("0x000000000000000000000000f2a5fd4d5bb24b651d1198bc014941a16f6edde5")
+ specCarol = common.HexToHash("0x000000000000000000000000ac8cbe20039c2e9547da9107456179bd4f39a734")
+)
+
+// TestIndexBuilderSpecWorkedExample reproduces the spec's 22-entry worked
+// example byte-for-byte: four single-block tables (blocks 40-43) whose entries
+// are the lexicographically sorted binary encodings listed in the spec.
+func TestIndexBuilderSpecWorkedExample(t *testing.T) {
+ receipts42 := Receipts{
+ {TxHash: specTx42_0, Logs: []*Log{
+ {Address: specWETH, Topics: []common.Hash{specTransfer, specAlice, specBob}},
+ {Address: specUSDT, Topics: []common.Hash{specTransfer, specBob, specAlice}},
+ }},
+ {TxHash: specTx42_1, Logs: []*Log{
+ {Address: specWETH, Topics: []common.Hash{specWithdrawal, specBob}},
+ }},
+ }
+ receipts43 := Receipts{
+ {TxHash: specTx43_0, Logs: []*Log{
+ {Address: specUSDT, Topics: []common.Hash{specTransfer, specAlice, specCarol}},
+ }},
+ }
+ // The sorted binary table from the spec, verbatim (22 entries).
+ want := []string{
+ "000042f66a2e9f9c68e223e8d826145d7cfacb00520dba6a9555803121de29790b650000000000000028",
+ "000066f42ef12b140e8004ad39a760191457f485204ee6c9f990c33f14014e521f20000000000000002a",
+ "0000978ce0036b6d1c62d716045505587d15cc85a1def92f9f450937b6467295e5170000000000000029",
+ "0000bf98e6cb26f6ff312586968d1f343a3d3c439a8c5c86233aff2a82f1a68263df0000000000000027",
+ "00017046035b326ab22a3142e4416ed4300a28a483a31a1e04cf62bec575b2e7cf09000000000000002b0000000000000000",
+ "000175590c9ced72898d6207c917e11b452949043404a1802b893f7717a9c1c8f45d000000000000002a0000000100000002",
+ "0001ca2d12d1b8132de09d0d668cc87349dc70134bee3010e03ddb2d83f7160bd6e3000000000000002a0000000000000000",
+ "0002c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000002a0000000000000000",
+ "0002c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000002a0000000100000000",
+ "0002dac17f958d2ee523a2206206994597c13d831ec7000000000000002a0000000000000001",
+ "0002dac17f958d2ee523a2206206994597c13d831ec7000000000000002b0000000000000000",
+ "00037fcf532c15f0a6db0bd6d0e038bea71d30d808c7d98cb3bf7268a95bf5081b65000000000000002a0000000100000000",
+ "0003ddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef000000000000002a0000000000000000",
+ "0003ddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef000000000000002a0000000000000001",
+ "0003ddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef000000000000002b0000000000000000",
+ "00040000000000000000000000004a5e9a3bf35df5e0ea4b4cd3289e0111f1deadf7000000000000002a0000000000000000",
+ "00040000000000000000000000004a5e9a3bf35df5e0ea4b4cd3289e0111f1deadf7000000000000002b0000000000000000",
+ "0004000000000000000000000000f2a5fd4d5bb24b651d1198bc014941a16f6edde5000000000000002a0000000000000001",
+ "0004000000000000000000000000f2a5fd4d5bb24b651d1198bc014941a16f6edde5000000000000002a0000000100000000",
+ "00050000000000000000000000004a5e9a3bf35df5e0ea4b4cd3289e0111f1deadf7000000000000002a0000000000000001",
+ "0005000000000000000000000000ac8cbe20039c2e9547da9107456179bd4f39a734000000000000002b0000000000000000",
+ "0005000000000000000000000000f2a5fd4d5bb24b651d1198bc014941a16f6edde5000000000000002a0000000000000000",
+ }
+
+ b := NewIndexBuilder()
+ b.AddBlockEntries(specHash39, 40, nil)
+ b.AddBlockEntries(specHash40, 41, nil)
+ b.AddBlockEntries(specHash41, 42, receipts42)
+ b.AddBlockEntries(specHash42, 43, receipts43)
+ if got := len(b.Entries()); got != 22 {
+ t.Fatalf("entry count = %d, want 22", got)
+ }
+ b.Build() // sorts in place
+ for i, e := range b.Entries() {
+ if got := hex.EncodeToString(e); got != want[i] {
+ t.Errorf("entry %d: encoded %s, want %s", i, got, want[i])
+ }
+ }
+
+ // Semantics the prose describes, decoded from the sorted entries:
+ // cumulative log counts, per-transaction log indices, one-block delay.
+ checks := []struct {
+ index int
+ typ EntryType
+ value []byte
+ block uint64
+ tx uint32
+ log uint32
+ }{
+ // 42tx1's transaction entry carries cumulative log count 2.
+ {5, EntryTypeTransaction, specTx42_1[:], 42, 1, 2},
+ // 42tx0's transaction entry has cumulative log count 0.
+ {6, EntryTypeTransaction, specTx42_0[:], 42, 0, 0},
+ // WETH log of 42tx1 has log index 0, not cumulative 2.
+ {8, EntryTypeLogAddress, specWETH[:], 42, 1, 0},
+ // The table for block 43 holds block 42's entry (one-block delay).
+ {1, EntryTypeBlock, specHash42[:], 42, 0, 0},
+ }
+ for _, c := range checks {
+ typ, content, block, tx, log, err := EntryFields(b.Entries()[c.index])
+ if err != nil {
+ t.Fatalf("entry %d: unexpected error: %v", c.index, err)
+ }
+ if typ != c.typ || !bytes.Equal(content, c.value) || block != c.block || tx != c.tx || log != c.log {
+ t.Errorf("entry %d: decoded (type %d, %x, %d, %d, %d), want (%d, %x, %d, %d, %d)",
+ c.index, typ, content, block, tx, log, c.typ, c.value, c.block, c.tx, c.log)
+ }
+ }
+}
+
+// TestIndexBuilderPerTableCounts pins the entry distribution across the four
+// tables of the worked example: 1, 1, 14 and 6 entries.
+func TestIndexBuilderPerTableCounts(t *testing.T) {
+ receipts42 := Receipts{
+ {TxHash: specTx42_0, Logs: []*Log{
+ {Address: specWETH, Topics: []common.Hash{specTransfer, specAlice, specBob}},
+ {Address: specUSDT, Topics: []common.Hash{specTransfer, specBob, specAlice}},
+ }},
+ {TxHash: specTx42_1, Logs: []*Log{
+ {Address: specWETH, Topics: []common.Hash{specWithdrawal, specBob}},
+ }},
+ }
+ receipts43 := Receipts{
+ {TxHash: specTx43_0, Logs: []*Log{
+ {Address: specUSDT, Topics: []common.Hash{specTransfer, specAlice, specCarol}},
+ }},
+ }
+ tests := []struct {
+ block uint64
+ parent common.Hash
+ receipts Receipts
+ want int
+ }{
+ {40, specHash39, nil, 1},
+ {41, specHash40, nil, 1},
+ {42, specHash41, receipts42, 14},
+ {43, specHash42, receipts43, 6},
+ }
+ for _, tt := range tests {
+ b := NewIndexBuilder()
+ b.AddBlockEntries(tt.parent, tt.block, tt.receipts)
+ if got := len(b.Entries()); got != tt.want {
+ t.Errorf("block %d: entry count = %d, want %d", tt.block, got, tt.want)
+ }
+ }
+}
+
+// TestAddBlockEntriesGenesis covers the genesis edge cases: no block entry for
+// block 0 and exactly the parent's entry for block 1.
+func TestAddBlockEntriesGenesis(t *testing.T) {
+ b := NewIndexBuilder()
+ b.AddBlockEntries(specHash39, 0, nil)
+ if got := len(b.Entries()); got != 0 {
+ t.Fatalf("block 0: entry count = %d, want 0", got)
+ }
+
+ b = NewIndexBuilder()
+ b.AddBlockEntries(specHash39, 1, nil)
+ if got := len(b.Entries()); got != 1 {
+ t.Fatalf("block 1: entry count = %d, want 1", got)
+ }
+ typ, content, block, tx, log, err := EntryFields(b.Entries()[0])
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if typ != EntryTypeBlock || !bytes.Equal(content, specHash39[:]) || block != 0 || tx != 0 || log != 0 {
+ t.Errorf("block 1 entry: decoded (type %d, %x, %d, %d, %d), want block entry of block 0", typ, content, block, tx, log)
+ }
+
+ // An empty builder still hashes (the genesis block never gets a table).
+ if root := NewIndexBuilder().Build(); root == (common.Hash{}) {
+ t.Errorf("empty builder root should be the keccak of an empty table")
+ }
+}
+
diff --git a/core/types/logindex_entry.go b/core/types/logindex_entry.go
new file mode 100644
index 000000000000..1ba8c49ed343
--- /dev/null
+++ b/core/types/logindex_entry.go
@@ -0,0 +1,228 @@
+// 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 .
+
+package types
+
+import (
+ "bytes"
+ "encoding/binary"
+ "fmt"
+)
+
+// EntryType discriminates the kind of lookup a single index entry provides.
+//
+// The entry type determines how clients interpret the index value and which
+// position fields are meaningful:
+//
+// - EntryTypeBlock: index value is a block hash; no position tail.
+// - EntryTypeTransaction: index value is a tx hash; the trailing position
+// field holds the cumulative log count of the block before the tx.
+// - EntryTypeLogAddress: index value is the log's contract address.
+// - EntryTypeLogTopic0-3: index value is the log's topic[i].
+//
+// On the wire the type id is a 2-byte big-endian field.
+type EntryType uint16
+
+const (
+ EntryTypeBlock EntryType = 0 // block hash lookup
+ EntryTypeTransaction EntryType = 1 // transaction hash lookup
+ EntryTypeLogAddress EntryType = 2 // log address lookup
+ EntryTypeLogTopic0 EntryType = 3 // log topic[0] lookup
+ EntryTypeLogTopic1 EntryType = 4 // log topic[1] lookup
+ EntryTypeLogTopic2 EntryType = 5 // log topic[2] lookup
+ EntryTypeLogTopic3 EntryType = 6 // log topic[3] lookup
+)
+
+// Field sizes of the EIP-8304 entry encodings. All numbers are fixed-size
+// big-endian. The layouts are parameterized here on purpose: if the spec
+// changes a field size, edit this block and the offset arithmetic in
+// EncodeEntry and EntryFields stays correct by construction.
+const (
+ EntryTypeSize = 2 // type id
+ BlockHashSize = 32
+ TxHashSize = 32
+ AddressSize = 20
+ TopicSize = 32
+ BlockNumberSize = 8
+ TxIndexSize = 4
+ LogIndexSize = 4 // log index within a transaction
+ CumulativeLogSize = 4 // logs before the transaction in its block
+)
+
+// Total sizes of the four entry kinds, derived at compile time from the field
+// sizes above: type || content || block number || tx index || log index.
+const (
+ BlockEntrySize = EntryTypeSize + BlockHashSize + BlockNumberSize // 42
+ TransactionEntrySize = EntryTypeSize + TxHashSize + BlockNumberSize + TxIndexSize + CumulativeLogSize // 50
+ LogAddressEntrySize = EntryTypeSize + AddressSize + BlockNumberSize + TxIndexSize + LogIndexSize // 38
+ LogTopicEntrySize = EntryTypeSize + TopicSize + BlockNumberSize + TxIndexSize + LogIndexSize // 50
+)
+
+// IndexEntry is a single variable-length record of a sorted EIP-8304 index
+// table.
+//
+// Layout (big-endian):
+//
+// type id (2) || content || block number (8) [|| tx index (4) || log index (4)]
+//
+// The content field is 32 bytes for block, transaction and topic entries and
+// 20 bytes for address entries, giving the total sizes BlockEntrySize,
+// TransactionEntrySize, LogAddressEntrySize and LogTopicEntrySize. Block
+// entries stop after the block number; the other entry kinds carry the tx
+// index and log index tail, which for transaction entries is the cumulative
+// log count of the block before the transaction.
+//
+// Sorting is lexicographical on the full encoding, giving the ordering:
+// type id, content, block number, tx index, log index.
+type IndexEntry []byte
+
+// contentSize returns the encoded length of the searchable content field for
+// the given entry type.
+func contentSize(typ EntryType) int {
+ switch typ {
+ case EntryTypeBlock:
+ return BlockHashSize
+ case EntryTypeTransaction:
+ return TxHashSize
+ case EntryTypeLogAddress:
+ return AddressSize
+ case EntryTypeLogTopic0, EntryTypeLogTopic1, EntryTypeLogTopic2, EntryTypeLogTopic3:
+ return TopicSize
+ default:
+ panic(fmt.Sprintf("types: unknown entry type %d", typ))
+ }
+}
+
+// entrySize returns the total encoded length of an entry of the given type.
+func entrySize(typ EntryType) int {
+ switch typ {
+ case EntryTypeBlock:
+ return BlockEntrySize
+ case EntryTypeTransaction:
+ return TransactionEntrySize
+ case EntryTypeLogAddress:
+ return LogAddressEntrySize
+ case EntryTypeLogTopic0, EntryTypeLogTopic1, EntryTypeLogTopic2, EntryTypeLogTopic3:
+ return LogTopicEntrySize
+ default:
+ panic(fmt.Sprintf("types: unknown entry type %d", typ))
+ }
+}
+
+// EncodeEntry packs the components into a variable-length index entry. content
+// must be exactly AddressSize (20) bytes for EntryTypeLogAddress and 32 bytes
+// otherwise. For EntryTypeTransaction, logIdx is the cumulative log count of
+// the block before the transaction; for all other types it is the log index
+// within the transaction. Block entries have no position tail, so txIdx and
+// logIdx are ignored for them.
+func EncodeEntry(typ EntryType, content []byte, blockNum uint64, txIdx uint32, logIdx uint32) IndexEntry {
+ if want := contentSize(typ); len(content) != want {
+ panic(fmt.Sprintf("types: entry content length %d, want %d for type %d", len(content), want, typ))
+ }
+ e := make(IndexEntry, entrySize(typ))
+ off := 0
+ binary.BigEndian.PutUint16(e[off:off+EntryTypeSize], uint16(typ))
+ off += EntryTypeSize
+ copy(e[off:], content)
+ off += len(content)
+ binary.BigEndian.PutUint64(e[off:off+BlockNumberSize], blockNum)
+ off += BlockNumberSize
+ if typ == EntryTypeBlock {
+ return e
+ }
+ binary.BigEndian.PutUint32(e[off:off+TxIndexSize], txIdx)
+ off += TxIndexSize
+ binary.BigEndian.PutUint32(e[off:off+LogIndexSize], logIdx)
+ return e
+}
+
+// EntryFields decodes an entry back into its components. content is a fresh
+// copy and does not alias the entry. It returns an error when the entry is
+// shorter than the type id, carries an unknown type id, or its length does not
+// match the type's fixed layout.
+func EntryFields(entry IndexEntry) (typ EntryType, content []byte, blockNum uint64, txIdx uint32, logIdx uint32, err error) {
+ if len(entry) < EntryTypeSize {
+ return 0, nil, 0, 0, 0, fmt.Errorf("types: entry too short: %d bytes", len(entry))
+ }
+ typ = EntryType(binary.BigEndian.Uint16(entry[:EntryTypeSize]))
+ if typ > EntryTypeLogTopic3 {
+ return 0, nil, 0, 0, 0, fmt.Errorf("types: unknown entry type %d", typ)
+ }
+ if len(entry) != entrySize(typ) {
+ return 0, nil, 0, 0, 0, fmt.Errorf("types: entry length %d, want %d for type %d", len(entry), entrySize(typ), typ)
+ }
+ content = make([]byte, contentSize(typ))
+ copy(content, entry[EntryTypeSize:EntryTypeSize+len(content)])
+ off := EntryTypeSize + len(content)
+ blockNum = binary.BigEndian.Uint64(entry[off : off+BlockNumberSize])
+ if typ == EntryTypeBlock {
+ return typ, content, blockNum, 0, 0, nil
+ }
+ off += BlockNumberSize
+ txIdx = binary.BigEndian.Uint32(entry[off : off+TxIndexSize])
+ off += TxIndexSize
+ logIdx = binary.BigEndian.Uint32(entry[off : off+LogIndexSize])
+ return typ, content, blockNum, txIdx, logIdx, nil
+}
+
+// CompareEntries returns the result of a lexicographic byte comparison
+// between two index entries.
+//
+// Return values follow bytes.Compare:
+//
+// -1 if a < b
+// 0 if a == b
+// +1 if a > b
+//
+// Since every field is fixed-size big-endian, this equals the field-wise
+// ordering: type id, content, block number, tx index, log index.
+func CompareEntries(a, b IndexEntry) int {
+ return bytes.Compare(a, b)
+}
+
+// EntryTypeString returns a human-readable name for the entry type,
+// useful for debugging and log output.
+func EntryTypeString(typ EntryType) string {
+ switch typ {
+ case EntryTypeBlock:
+ return "block"
+ case EntryTypeTransaction:
+ return "transaction"
+ case EntryTypeLogAddress:
+ return "log_address"
+ case EntryTypeLogTopic0:
+ return "log_topic0"
+ case EntryTypeLogTopic1:
+ return "log_topic1"
+ case EntryTypeLogTopic2:
+ return "log_topic2"
+ case EntryTypeLogTopic3:
+ return "log_topic3"
+ default:
+ return "unknown"
+ }
+}
+
+// TableLevelCount is the number of chained-table levels in the index hierarchy.
+// Level i covers a block range of TableSizes[i] blocks with staggered update
+// schedules, forming a provable history of the full log index.
+const TableLevelCount = 5
+
+// TableSizes gives the block-range size for each level of the chained-table
+// hierarchy. Level 0 uses single-block tables (updated every block), level 1
+// uses 4-block tables, level 2 uses 16-block tables, level 3 uses 64-block
+// tables, and level 4 uses 256-block tables.
+var TableSizes = [TableLevelCount]int{1, 4, 16, 64, 256}
diff --git a/core/types/logindex_entry_test.go b/core/types/logindex_entry_test.go
new file mode 100644
index 000000000000..19cceadc91b2
--- /dev/null
+++ b/core/types/logindex_entry_test.go
@@ -0,0 +1,189 @@
+// 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 .
+
+package types
+
+import (
+ "bytes"
+ "encoding/hex"
+ "strings"
+ "testing"
+)
+
+// TestEncodeEntry pins the exact wire layout of every entry type: 2-byte
+// big-endian type id, content (32 bytes, 20 for addresses), then the
+// position fields. Non-trivial values prove the big-endian byte order.
+func TestEncodeEntry(t *testing.T) {
+ var (
+ blockNum = uint64(0x0102030405060708)
+ txIdx = uint32(0x01020304)
+ logIdx = uint32(0x0a0b0c0d)
+ hashCtx = bytes.Repeat([]byte{0x11}, 32)
+ addrCtx = bytes.Repeat([]byte{0x22}, 20)
+ hashHex = strings.Repeat("11", 32)
+ addrHex = strings.Repeat("22", 20)
+ tailHex = "0102030405060708" + "01020304" + "0a0b0c0d"
+ )
+ tests := []struct {
+ typ EntryType
+ content []byte
+ want string
+ }{
+ {EntryTypeBlock, hashCtx, "0000" + hashHex + "0102030405060708"},
+ {EntryTypeTransaction, hashCtx, "0001" + hashHex + tailHex},
+ {EntryTypeLogAddress, addrCtx, "0002" + addrHex + tailHex},
+ {EntryTypeLogTopic0, hashCtx, "0003" + hashHex + tailHex},
+ {EntryTypeLogTopic1, hashCtx, "0004" + hashHex + tailHex},
+ {EntryTypeLogTopic2, hashCtx, "0005" + hashHex + tailHex},
+ {EntryTypeLogTopic3, hashCtx, "0006" + hashHex + tailHex},
+ }
+ for _, tt := range tests {
+ e := EncodeEntry(tt.typ, tt.content, blockNum, txIdx, logIdx)
+ if got := hex.EncodeToString(e); got != tt.want {
+ t.Errorf("type %d: encoded %s, want %s", tt.typ, got, tt.want)
+ }
+ if len(e) != entrySize(tt.typ) {
+ t.Errorf("type %d: length %d, want %d", tt.typ, len(e), entrySize(tt.typ))
+ }
+ }
+}
+
+// TestEncodeEntryPanics asserts the encoder rejects wrong content sizes and
+// unknown types instead of silently mis-encoding.
+func TestEncodeEntryPanics(t *testing.T) {
+ mustPanic := func(name string, f func()) {
+ defer func() {
+ if recover() == nil {
+ t.Errorf("%s: expected panic", name)
+ }
+ }()
+ f()
+ }
+ mustPanic("block with 20-byte content", func() { EncodeEntry(EntryTypeBlock, make([]byte, 20), 0, 0, 0) })
+ mustPanic("address with 32-byte content", func() { EncodeEntry(EntryTypeLogAddress, make([]byte, 32), 0, 0, 0) })
+ mustPanic("topic with 20-byte content", func() { EncodeEntry(EntryTypeLogTopic2, make([]byte, 20), 0, 0, 0) })
+ mustPanic("unknown type", func() { EncodeEntry(EntryType(7), make([]byte, 32), 0, 0, 0) })
+}
+
+// TestEntryFields round-trips every entry type and asserts the decoder
+// rejects malformed and unknown-type entries without panicking.
+func TestEntryFields(t *testing.T) {
+ var (
+ blockNum = uint64(0x0102030405060708)
+ txIdx = uint32(0x01020304)
+ logIdx = uint32(0x0a0b0c0d)
+ )
+ tests := []EntryType{
+ EntryTypeBlock, EntryTypeTransaction, EntryTypeLogAddress,
+ EntryTypeLogTopic0, EntryTypeLogTopic1, EntryTypeLogTopic2, EntryTypeLogTopic3,
+ }
+ for _, typ := range tests {
+ content := make([]byte, contentSize(typ))
+ for i := range content {
+ content[i] = byte(i)
+ }
+ e := EncodeEntry(typ, content, blockNum, txIdx, logIdx)
+ gotTyp, gotContent, gotBlock, gotTx, gotLog, err := EntryFields(e)
+ if err != nil {
+ t.Fatalf("type %d: unexpected error: %v", typ, err)
+ }
+ // Block entries have no position tail; txIdx and logIdx are ignored
+ // for them, so skip the tail comparison.
+ wantTx, wantLog := txIdx, logIdx
+ if typ == EntryTypeBlock {
+ wantTx, wantLog = 0, 0
+ }
+ if gotTyp != typ || !bytes.Equal(gotContent, content) || gotBlock != blockNum || gotTx != wantTx || gotLog != wantLog {
+ t.Errorf("type %d: round-trip mismatch: typ %d content %x block %d tx %d log %d", typ, gotTyp, gotContent, gotBlock, gotTx, gotLog)
+ }
+ // The decoded content must not alias the entry.
+ gotContent[0] ^= 0xff
+ if bytes.Equal(gotContent, content) {
+ t.Errorf("type %d: decoded content aliases the entry", typ)
+ }
+ }
+
+ // Malformed entries: too short, truncated, oversized, unknown type id.
+ valid := EncodeEntry(EntryTypeTransaction, make([]byte, TxHashSize), 0, 0, 0)
+ unknownID := EncodeEntry(EntryTypeLogTopic3, make([]byte, TopicSize), 0, 0, 0)
+ unknownID[0], unknownID[1] = 0x00, 0x07
+ bad := []IndexEntry{
+ nil,
+ valid[:1],
+ valid[:len(valid)-1],
+ append(IndexEntry{}, append(valid, 0)...),
+ unknownID,
+ {0xff, 0xff, 0x00},
+ }
+ for _, e := range bad {
+ if _, _, _, _, _, err := EntryFields(e); err == nil {
+ t.Errorf("entry %x: expected error", e)
+ }
+ }
+}
+
+// TestCompareEntries asserts the ordering implied by the spec's
+// lexicographical sort: type id, then content, then block number, then tx
+// index, then log index.
+func TestCompareEntries(t *testing.T) {
+ hashOf := func(v byte) []byte { return bytes.Repeat([]byte{v}, 32) }
+ addrOf := func(v byte) []byte { return bytes.Repeat([]byte{v}, 20) }
+ tests := []struct {
+ name string
+ a, b IndexEntry
+ want int
+ }{
+ {"type ordering", EncodeEntry(EntryTypeBlock, hashOf(0), 0, 0, 0), EncodeEntry(EntryTypeTransaction, hashOf(0), 0, 0, 0), -1},
+ {"type ordering 2", EncodeEntry(EntryTypeTransaction, hashOf(0), 0, 0, 0), EncodeEntry(EntryTypeLogAddress, addrOf(0), 0, 0, 0), -1},
+ {"type ordering 3", EncodeEntry(EntryTypeLogAddress, addrOf(0), 0, 0, 0), EncodeEntry(EntryTypeLogTopic0, hashOf(0), 0, 0, 0), -1},
+ {"topic ordering", EncodeEntry(EntryTypeLogTopic1, hashOf(0), 0, 0, 0), EncodeEntry(EntryTypeLogTopic2, hashOf(0), 0, 0, 0), -1},
+ {"content ordering", EncodeEntry(EntryTypeBlock, hashOf(0), 0, 0, 0), EncodeEntry(EntryTypeBlock, hashOf(1), 0, 0, 0), -1},
+ {"block ordering", EncodeEntry(EntryTypeBlock, hashOf(0), 1, 0, 0), EncodeEntry(EntryTypeBlock, hashOf(0), 2, 0, 0), -1},
+ {"tx ordering", EncodeEntry(EntryTypeTransaction, hashOf(0), 0, 1, 0), EncodeEntry(EntryTypeTransaction, hashOf(0), 0, 2, 0), -1},
+ {"log ordering", EncodeEntry(EntryTypeLogAddress, addrOf(0), 0, 0, 1), EncodeEntry(EntryTypeLogAddress, addrOf(0), 0, 0, 2), -1},
+ {"equal", EncodeEntry(EntryTypeBlock, hashOf(0), 0, 0, 0), EncodeEntry(EntryTypeBlock, hashOf(0), 0, 0, 0), 0},
+ }
+ for _, tt := range tests {
+ if got := CompareEntries(tt.a, tt.b); got != tt.want {
+ t.Errorf("%s: CompareEntries = %d, want %d", tt.name, got, tt.want)
+ }
+ if got := CompareEntries(tt.b, tt.a); got != -tt.want {
+ t.Errorf("%s: comparison not antisymmetric", tt.name)
+ }
+ }
+}
+
+// TestEntryTypeString covers the human-readable names used in logs.
+func TestEntryTypeString(t *testing.T) {
+ want := map[EntryType]string{
+ EntryTypeBlock: "block", EntryTypeTransaction: "transaction",
+ EntryTypeLogAddress: "log_address", EntryTypeLogTopic0: "log_topic0",
+ EntryTypeLogTopic1: "log_topic1", EntryTypeLogTopic2: "log_topic2",
+ EntryTypeLogTopic3: "log_topic3", EntryType(7): "unknown",
+ }
+ for typ, wantStr := range want {
+ if got := EntryTypeString(typ); got != wantStr {
+ t.Errorf("type %d: got %q, want %q", typ, got, wantStr)
+ }
+ }
+}
+
+// TestEntrySizes guards the derived totals against the spec's fixed sizes.
+func TestEntrySizes(t *testing.T) {
+ if BlockEntrySize != 42 || TransactionEntrySize != 50 || LogAddressEntrySize != 38 || LogTopicEntrySize != 50 {
+ t.Errorf("derived sizes: block %d, tx %d, address %d, topic %d; want 42, 50, 38, 50", BlockEntrySize, TransactionEntrySize, LogAddressEntrySize, LogTopicEntrySize)
+ }
+}
diff --git a/eth/api_debug_replay.go b/eth/api_debug_replay.go
index 74b649eaaf84..ee18694f46d1 100644
--- a/eth/api_debug_replay.go
+++ b/eth/api_debug_replay.go
@@ -183,7 +183,8 @@ func (api *DebugAPI) replayBuild(ctx context.Context, block *types.Block, stated
for _, r := range receipts {
allLogs = append(allLogs, r.Logs...)
}
- _, postBal, err := core.PostExecution(ctx, config, header.Number, header.Time, allLogs, evm, uint32(tcount+1))
+ // EIP-8304: log index tables are not built for replay, wired in a later PR
+ _, postBal, err := core.PostExecution(ctx, config, header.Number, header.Time, allLogs, nil, evm, uint32(tcount+1))
if err != nil {
return nil, nil, 0, common.Hash{}, err
}
diff --git a/internal/ethapi/simulate.go b/internal/ethapi/simulate.go
index 7e174551befc..7b6b0a379a61 100644
--- a/internal/ethapi/simulate.go
+++ b/internal/ethapi/simulate.go
@@ -393,8 +393,8 @@ func (sim *simulator) processBlock(ctx context.Context, block *simBlock, header,
header.BlobGasUsed = &blobGasUsed
}
- // Process EIP-7685 requests
- requests, bal, err := core.PostExecution(ctx, sim.chainConfig, header.Number, header.Time, allLogs, evm, uint32(len(block.Calls)+1))
+ // Process EIP-7685 requests; EIP-8304 tables are nil here, wired in a later PR
+ requests, bal, err := core.PostExecution(ctx, sim.chainConfig, header.Number, header.Time, allLogs, nil, evm, uint32(len(block.Calls)+1))
if err != nil {
return nil, nil, nil, err
}
diff --git a/miner/worker.go b/miner/worker.go
index c690fb7446d1..a55f52441079 100644
--- a/miner/worker.go
+++ b/miner/worker.go
@@ -223,8 +223,16 @@ func (miner *Miner) generateWork(ctx context.Context, genParam *generateParams,
allLogs = append(allLogs, r.Logs...)
}
+ // EIP-8304: build log index tables for the system call inside PostExecution.
+ // No-op before the fork activates or when the contract is not deployed
+ // (non-dev chains).
+ var tablesToWrite []core.TableWrite
+ if miner.chainConfig.IsAmsterdam(work.header.Number, work.header.Time) && work.evm.StateDB.GetCodeSize(params.IndexContractAddress) > 0 {
+ tablesToWrite = core.BuildLogIndexForBlock(work.header.Number.Uint64(), work.header.ParentHash, work.receipts)
+ }
+
// Collect consensus-layer requests if Prague is enabled.
- requests, bal, err := core.PostExecution(ctx, miner.chainConfig, work.header.Number, work.header.Time, allLogs, work.evm, uint32(work.tcount+1))
+ requests, bal, err := core.PostExecution(ctx, miner.chainConfig, work.header.Number, work.header.Time, allLogs, tablesToWrite, work.evm, uint32(work.tcount+1))
if err != nil {
return &newPayloadResult{err: err}
}
diff --git a/params/protocol_params.go b/params/protocol_params.go
index 3cc8e2320c9c..efa4a10f52a6 100644
--- a/params/protocol_params.go
+++ b/params/protocol_params.go
@@ -249,6 +249,11 @@ var (
BeaconRootsAddress = common.HexToAddress("0x000F3df6D732807Ef1319fB7B8bB8522d0Beac02")
BeaconRootsCode = common.FromHex("3373fffffffffffffffffffffffffffffffffffffffe14604d57602036146024575f5ffd5b5f35801560495762001fff810690815414603c575f5ffd5b62001fff01545f5260205ff35b5f5ffd5b62001fff42064281555f359062001fff015500")
+ // EIP-8304 - Trustless Log and Transaction Index
+ IndexContractAddress = common.HexToAddress("0x0000000000000000000000000000000000008304") // TBD: per-spec address
+ IndexContractInitCode = common.FromHex("60758060095f395ff33373fffffffffffffffffffffffffffffffffffffffe1460605760403603605c576020358060801c605c576104008160048104430304828202925f35818106605c5704908103196103ff10605c570601548015605c575f5260205ff35b5f5ffd5b604035602035610400818102915f350406015500")
+ IndexContractCode = common.FromHex("3373fffffffffffffffffffffffffffffffffffffffe1460605760403603605c576020358060801c605c576104008160048104430304828202925f35818106605c5704908103196103ff10605c570601548015605c575f5260205ff35b5f5ffd5b604035602035610400818102915f350406015500")
+
// EIP-2935 - Serve historical block hashes from state
HistoryStorageAddress = common.HexToAddress("0x0000F90827F1C53a10cb7A02335B175320002935")
HistoryStorageCode = common.FromHex("3373fffffffffffffffffffffffffffffffffffffffe14604657602036036042575f35600143038111604257611fff81430311604257611fff9006545f5260205ff35b5f5ffd5b5f35611fff60014303065500")