From b86cca23a89d426165dcc9e1f822ff9abfe1d17f Mon Sep 17 00:00:00 2001 From: MariusVanDerWijden Date: Mon, 4 May 2026 10:49:12 +0200 Subject: [PATCH 01/17] core: implement EIP-8037: state creation gas cost increase --- cmd/evm/internal/t8ntool/transaction.go | 3 +- core/bench_test.go | 3 +- core/bintrie_witness_test.go | 6 +- core/chain_makers.go | 2 +- core/evm.go | 34 +-- core/gaspool.go | 55 ++++- core/state/statedb.go | 28 +++ core/state/statedb_hooked.go | 8 + core/state_processor.go | 6 +- core/state_transition.go | 245 +++++++++++++++------ core/state_transition_test.go | 105 ++++++--- core/txpool/validation.go | 3 +- core/vm/contract.go | 50 ++++- core/vm/contracts.go | 1 - core/vm/contracts_fuzz_test.go | 2 +- core/vm/contracts_test.go | 8 +- core/vm/eips.go | 18 +- core/vm/evm.go | 246 ++++++++++++--------- core/vm/gas_table.go | 271 ++++++++++++++++++++--- core/vm/gas_table_test.go | 12 +- core/vm/gascosts.go | 273 ++++++++++++++++++++---- core/vm/instructions.go | 61 +++--- core/vm/interface.go | 8 + core/vm/interpreter.go | 16 +- core/vm/interpreter_test.go | 4 +- core/vm/jump_table.go | 6 +- core/vm/operations_acl.go | 119 ++++++++++- core/vm/runtime/runtime.go | 22 +- eth/tracers/js/tracer_test.go | 2 +- eth/tracers/logger/logger_test.go | 2 +- params/protocol_params.go | 7 + tests/state_test.go | 6 +- tests/transaction_test_util.go | 4 +- 33 files changed, 1260 insertions(+), 376 deletions(-) diff --git a/cmd/evm/internal/t8ntool/transaction.go b/cmd/evm/internal/t8ntool/transaction.go index ca19ae3386da..26ae11e0fb81 100644 --- a/cmd/evm/internal/t8ntool/transaction.go +++ b/cmd/evm/internal/t8ntool/transaction.go @@ -133,7 +133,8 @@ func Transaction(ctx *cli.Context) error { } // Check intrinsic gas rules := chainConfig.Rules(common.Big0, true, 0) - cost, err := core.IntrinsicGas(tx.Data(), tx.AccessList(), tx.SetCodeAuthorizations(), tx.To() == nil, rules.IsHomestead, rules.IsIstanbul, rules.IsShanghai, rules.IsAmsterdam) + gasCostPerStateByte := core.CostPerStateByte(&types.Header{}, chainConfig) + cost, err := core.IntrinsicGas(tx.Data(), tx.AccessList(), tx.SetCodeAuthorizations(), tx.To() == nil, rules, gasCostPerStateByte) if err != nil { r.Error = err results = append(results, r) diff --git a/core/bench_test.go b/core/bench_test.go index 65179c54d457..d49062af06c5 100644 --- a/core/bench_test.go +++ b/core/bench_test.go @@ -89,7 +89,8 @@ func genValueTx(nbytes int) func(int, *BlockGen) { data := make([]byte, nbytes) return func(i int, gen *BlockGen) { toaddr := common.Address{} - cost, _ := IntrinsicGas(data, nil, nil, false, false, false, false, false) + gasCostPerStateByte := CostPerStateByte(gen.header, gen.cm.config) + cost, _ := IntrinsicGas(data, nil, nil, false, params.Rules{}, gasCostPerStateByte) signer := gen.Signer() gasPrice := big.NewInt(0) if gen.header.BaseFee != nil { diff --git a/core/bintrie_witness_test.go b/core/bintrie_witness_test.go index b49ac83bb55c..e151a72801d8 100644 --- a/core/bintrie_witness_test.go +++ b/core/bintrie_witness_test.go @@ -64,12 +64,12 @@ var ( func TestProcessUBT(t *testing.T) { var ( code = common.FromHex(`6060604052600a8060106000396000f360606040526008565b00`) - intrinsicContractCreationGas, _ = IntrinsicGas(code, nil, nil, true, true, true, true, false) + intrinsicContractCreationGas, _ = IntrinsicGas(code, nil, nil, true, params.Rules{IsHomestead: true, IsIstanbul: true, IsShanghai: true}, 0) // A contract creation that calls EXTCODECOPY in the constructor. Used to ensure that the witness // will not contain that copied data. // Source: https://gist.github.com/gballet/a23db1e1cb4ed105616b5920feb75985 codeWithExtCodeCopy = common.FromHex(`0x60806040526040516100109061017b565b604051809103906000f08015801561002c573d6000803e3d6000fd5b506000806101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff16021790555034801561007857600080fd5b5060008067ffffffffffffffff8111156100955761009461024a565b5b6040519080825280601f01601f1916602001820160405280156100c75781602001600182028036833780820191505090505b50905060008060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1690506020600083833c81610101906101e3565b60405161010d90610187565b61011791906101a3565b604051809103906000f080158015610133573d6000803e3d6000fd5b50600160006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff160217905550505061029b565b60d58061046783390190565b6102068061053c83390190565b61019d816101d9565b82525050565b60006020820190506101b86000830184610194565b92915050565b6000819050602082019050919050565b600081519050919050565b6000819050919050565b60006101ee826101ce565b826101f8846101be565b905061020381610279565b925060208210156102435761023e7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8360200360080261028e565b831692505b5050919050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b600061028582516101d9565b80915050919050565b600082821b905092915050565b6101bd806102aa6000396000f3fe608060405234801561001057600080fd5b506004361061002b5760003560e01c8063f566852414610030575b600080fd5b61003861004e565b6040516100459190610146565b60405180910390f35b6000600160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff166381ca91d36040518163ffffffff1660e01b815260040160206040518083038186803b1580156100b857600080fd5b505afa1580156100cc573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906100f0919061010a565b905090565b60008151905061010481610170565b92915050565b6000602082840312156101205761011f61016b565b5b600061012e848285016100f5565b91505092915050565b61014081610161565b82525050565b600060208201905061015b6000830184610137565b92915050565b6000819050919050565b600080fd5b61017981610161565b811461018457600080fd5b5056fea2646970667358221220a6a0e11af79f176f9c421b7b12f441356b25f6489b83d38cc828a701720b41f164736f6c63430008070033608060405234801561001057600080fd5b5060b68061001f6000396000f3fe6080604052348015600f57600080fd5b506004361060285760003560e01c8063ab5ed15014602d575b600080fd5b60336047565b604051603e9190605d565b60405180910390f35b60006001905090565b6057816076565b82525050565b6000602082019050607060008301846050565b92915050565b600081905091905056fea26469706673582212203a14eb0d5cd07c277d3e24912f110ddda3e553245a99afc4eeefb2fbae5327aa64736f6c63430008070033608060405234801561001057600080fd5b5060405161020638038061020683398181016040528101906100329190610063565b60018160001c6100429190610090565b60008190555050610145565b60008151905061005d8161012e565b92915050565b60006020828403121561007957610078610129565b5b60006100878482850161004e565b91505092915050565b600061009b826100f0565b91506100a6836100f0565b9250827fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff038211156100db576100da6100fa565b5b828201905092915050565b6000819050919050565b6000819050919050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052601160045260246000fd5b600080fd5b610137816100e6565b811461014257600080fd5b50565b60b3806101536000396000f3fe6080604052348015600f57600080fd5b506004361060285760003560e01c806381ca91d314602d575b600080fd5b60336047565b604051603e9190605a565b60405180910390f35b60005481565b6054816073565b82525050565b6000602082019050606d6000830184604d565b92915050565b600081905091905056fea26469706673582212209bff7098a2f526de1ad499866f27d6d0d6f17b74a413036d6063ca6a0998ca4264736f6c63430008070033`) - intrinsicCodeWithExtCodeCopyGas, _ = IntrinsicGas(codeWithExtCodeCopy, nil, nil, true, true, true, true, false) + intrinsicCodeWithExtCodeCopyGas, _ = IntrinsicGas(codeWithExtCodeCopy, nil, nil, true, params.Rules{IsHomestead: true, IsIstanbul: true, IsShanghai: true}, 0) signer = types.LatestSigner(testUBTChainConfig) testKey, _ = crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291") bcdb = rawdb.NewMemoryDatabase() // Database for the blockchain @@ -201,7 +201,7 @@ func TestProcessParentBlockHash(t *testing.T) { if isUBT { chainConfig = testUBTChainConfig } - vmContext := NewEVMBlockContext(header, nil, new(common.Address)) + vmContext := NewEVMBlockContext(header, &BlockChain{chainConfig: chainConfig}, new(common.Address)) evm := vm.NewEVM(vmContext, statedb, chainConfig, vm.Config{}) ProcessParentBlockHash(header.ParentHash, evm, bal.NewConstructionBlockAccessList()) } diff --git a/core/chain_makers.go b/core/chain_makers.go index 2e856b5161d3..cb78739882b8 100644 --- a/core/chain_makers.go +++ b/core/chain_makers.go @@ -168,7 +168,7 @@ func (b *BlockGen) AddTxWithChain(bc *BlockChain, tx *types.Transaction) { // been set, the block's coinbase is set to the zero address. // The evm interpreter can be customized with the provided vm config. func (b *BlockGen) AddTxWithVMConfig(tx *types.Transaction, config vm.Config) { - b.addTx(nil, config, tx) + b.addTx(&BlockChain{chainConfig: b.cm.config}, config, tx) } // GetBalance returns the balance of the given address at the generated block. diff --git a/core/evm.go b/core/evm.go index 73e4c01a995f..6e3938da5b67 100644 --- a/core/evm.go +++ b/core/evm.go @@ -68,21 +68,31 @@ func NewEVMBlockContext(header *types.Header, chain ChainContext, author *common } return vm.BlockContext{ - CanTransfer: CanTransfer, - Transfer: Transfer, - GetHash: GetHashFn(header, chain), - Coinbase: beneficiary, - BlockNumber: new(big.Int).Set(header.Number), - Time: header.Time, - Difficulty: new(big.Int).Set(header.Difficulty), - BaseFee: baseFee, - BlobBaseFee: blobBaseFee, - GasLimit: header.GasLimit, - Random: random, - SlotNum: slotNum, + CanTransfer: CanTransfer, + Transfer: Transfer, + GetHash: GetHashFn(header, chain), + Coinbase: beneficiary, + BlockNumber: new(big.Int).Set(header.Number), + Time: header.Time, + Difficulty: new(big.Int).Set(header.Difficulty), + BaseFee: baseFee, + BlobBaseFee: blobBaseFee, + GasLimit: header.GasLimit, + Random: random, + SlotNum: slotNum, + CostPerStateByte: CostPerStateByte(header, chain.Config()), } } +// CostPerStateByte computes the cost per one byte of state creation +// after EIP-8037. +func CostPerStateByte(header *types.Header, config *params.ChainConfig) uint64 { + if !config.IsAmsterdam(header.Number, header.Time) { + return 0 + } + return params.CostPerStateByte +} + // NewEVMTxContext creates a new transaction context for a single transaction. func NewEVMTxContext(msg *Message) vm.TxContext { ctx := vm.TxContext{ diff --git a/core/gaspool.go b/core/gaspool.go index 14f5abd93c3b..82323aafc42e 100644 --- a/core/gaspool.go +++ b/core/gaspool.go @@ -27,6 +27,11 @@ type GasPool struct { remaining uint64 initial uint64 cumulativeUsed uint64 + + // After 8037 Block gas used is + // max(cumulativeRegular, cumulativeState). + cumulativeRegular uint64 + cumulativeState uint64 } // NewGasPool initializes the gasPool with the given amount. @@ -47,6 +52,19 @@ func (gp *GasPool) SubGas(amount uint64) error { return nil } +// CheckGasAmsterdam performs the EIP-8037 per-tx 2D block-inclusion check: +// the worst-case regular contribution must fit in the regular dimension and +// the worst-case state contribution must fit in the state dimension +func (gp *GasPool) CheckGasAmsterdam(regularReservation, stateReservation uint64) error { + if gp.initial-gp.cumulativeRegular < regularReservation { + return ErrGasLimitReached + } + if gp.initial-gp.cumulativeState < stateReservation { + return ErrGasLimitReached + } + return nil +} + // ReturnGas adds the refunded gas back to the pool and updates // the cumulative gas usage accordingly. func (gp *GasPool) ReturnGas(returned uint64, gasUsed uint64) error { @@ -68,20 +86,43 @@ func (gp *GasPool) ReturnGas(returned uint64, gasUsed uint64) error { return nil } +// ChargeGasAmsterdam calculates the new remaining gas in the pool after the +// execution of a message. Previously we subtracted and re-added gas to the +// gaspool. After Amsterdam we only check if we can include the transaction and charge the +// gaspool at the end. +func (gp *GasPool) ChargeGasAmsterdam(txRegular, txState, receiptGasUsed uint64) error { + gp.cumulativeRegular += txRegular + gp.cumulativeState += txState + gp.cumulativeUsed += receiptGasUsed + + blockUsed := max(gp.cumulativeRegular, gp.cumulativeState) + if gp.initial < blockUsed { + return fmt.Errorf("%w: block gas overflow: initial %d, used %d (regular: %d, state: %d)", + ErrGasLimitReached, gp.initial, blockUsed, gp.cumulativeRegular, gp.cumulativeState) + } + // For tx inclusion, we only check if the regular dimension fits. + gp.remaining = gp.initial - gp.cumulativeRegular + return nil +} + // Gas returns the amount of gas remaining in the pool. func (gp *GasPool) Gas() uint64 { return gp.remaining } -// CumulativeUsed returns the amount of cumulative consumed gas (refunded included). +// CumulativeUsed returns the cumulative gas consumed for receipt tracking. func (gp *GasPool) CumulativeUsed() uint64 { return gp.cumulativeUsed } // Used returns the amount of consumed gas. func (gp *GasPool) Used() uint64 { + if gp.cumulativeRegular > 0 || gp.cumulativeState > 0 { + // After 8037 we return max(sum_regular, sum_state) + return max(gp.cumulativeRegular, gp.cumulativeState) + } if gp.initial < gp.remaining { - panic("gas used underflow") + panic(fmt.Sprintf("gas used underflow: %v %v", gp.initial, gp.remaining)) } return gp.initial - gp.remaining } @@ -89,9 +130,11 @@ func (gp *GasPool) Used() uint64 { // Snapshot returns the deep-copied object as the snapshot. func (gp *GasPool) Snapshot() *GasPool { return &GasPool{ - initial: gp.initial, - remaining: gp.remaining, - cumulativeUsed: gp.cumulativeUsed, + initial: gp.initial, + remaining: gp.remaining, + cumulativeUsed: gp.cumulativeUsed, + cumulativeRegular: gp.cumulativeRegular, + cumulativeState: gp.cumulativeState, } } @@ -100,6 +143,8 @@ func (gp *GasPool) Set(other *GasPool) { gp.initial = other.initial gp.remaining = other.remaining gp.cumulativeUsed = other.cumulativeUsed + gp.cumulativeRegular = other.cumulativeRegular + gp.cumulativeState = other.cumulativeState } func (gp *GasPool) String() string { diff --git a/core/state/statedb.go b/core/state/statedb.go index 1c49d460206d..5e25cce5c8ec 100644 --- a/core/state/statedb.go +++ b/core/state/statedb.go @@ -683,6 +683,34 @@ func (s *StateDB) IsNewContract(addr common.Address) bool { return obj.newContract } +// SameTxSelfDestructs returns the addresses that were both created and +// self-destructed in the current transaction (EIP-6780). +func (s *StateDB) SameTxSelfDestructs() []common.Address { + var out []common.Address + for addr, obj := range s.stateObjects { + if obj.newContract && obj.selfDestructed { + out = append(out, addr) + } + } + return out +} + +// NewStorageSlotCount returns the number of storage slots that were written +// to a non-zero value in the current transaction on the given account. +func (s *StateDB) NewStorageSlotCount(addr common.Address) int { + obj, ok := s.stateObjects[addr] + if !ok { + return 0 + } + var count int + for _, v := range obj.dirtyStorage { + if v != (common.Hash{}) { + count++ + } + } + return count +} + // Copy creates a deep, independent copy of the state. // Snapshots of the copied state cannot be applied to the copy. func (s *StateDB) Copy() *StateDB { diff --git a/core/state/statedb_hooked.go b/core/state/statedb_hooked.go index 98d01343a45e..8b4475158398 100644 --- a/core/state/statedb_hooked.go +++ b/core/state/statedb_hooked.go @@ -59,6 +59,14 @@ func (s *hookedStateDB) IsNewContract(addr common.Address) bool { return s.inner.IsNewContract(addr) } +func (s *hookedStateDB) SameTxSelfDestructs() []common.Address { + return s.inner.SameTxSelfDestructs() +} + +func (s *hookedStateDB) NewStorageSlotCount(addr common.Address) int { + return s.inner.NewStorageSlotCount(addr) +} + func (s *hookedStateDB) GetBalance(addr common.Address) *uint256.Int { return s.inner.GetBalance(addr) } diff --git a/core/state_processor.go b/core/state_processor.go index 5690a152e7e2..9af1682ce8e8 100644 --- a/core/state_processor.go +++ b/core/state_processor.go @@ -290,7 +290,7 @@ func ProcessBeaconBlockRoot(beaconRoot common.Hash, evm *vm.EVM, blockAccessList evm.StateDB.Prepare(evm.GetRules(), common.Address{}, common.Address{}, nil, nil, nil) evm.StateDB.SetTxContext(common.Hash{}, 0, 0) evm.StateDB.AddAddressToAccessList(params.BeaconRootsAddress) - _, _, _ = evm.Call(msg.From, *msg.To, msg.Data, vm.NewGasBudget(30_000_000), common.U2560) + _, _, _ = evm.Call(msg.From, *msg.To, msg.Data, vm.NewGasBudget(30_000_000, 0), common.U2560) if evm.StateDB.AccessEvents() != nil { evm.StateDB.AccessEvents().Merge(evm.AccessEvents) } @@ -319,7 +319,7 @@ func ProcessParentBlockHash(prevHash common.Hash, evm *vm.EVM, blockAccessList * evm.StateDB.Prepare(evm.GetRules(), common.Address{}, common.Address{}, nil, nil, nil) evm.StateDB.SetTxContext(common.Hash{}, 0, 0) evm.StateDB.AddAddressToAccessList(params.HistoryStorageAddress) - _, _, err := evm.Call(msg.From, *msg.To, msg.Data, vm.NewGasBudget(30_000_000), common.U2560) + _, _, err := evm.Call(msg.From, *msg.To, msg.Data, vm.NewGasBudget(30_000_000, 0), common.U2560) if err != nil { panic(err) } @@ -360,7 +360,7 @@ func processRequestsSystemCall(requests *[][]byte, rules params.Rules, evm *vm.E evm.StateDB.Prepare(rules, common.Address{}, common.Address{}, nil, nil, nil) evm.StateDB.SetTxContext(common.Hash{}, 0, blockAccessIndex) evm.StateDB.AddAddressToAccessList(addr) - ret, _, err := evm.Call(msg.From, *msg.To, msg.Data, vm.NewGasBudget(30_000_000), common.U2560) + ret, _, err := evm.Call(msg.From, *msg.To, msg.Data, vm.NewGasBudget(30_000_000, 0), common.U2560) if evm.StateDB.AccessEvents() != nil { evm.StateDB.AccessEvents().Merge(evm.AccessEvents) } diff --git a/core/state_transition.go b/core/state_transition.go index 51c583689254..7ac66b69f1a0 100644 --- a/core/state_transition.go +++ b/core/state_transition.go @@ -68,13 +68,27 @@ func (result *ExecutionResult) Revert() []byte { } // IntrinsicGas computes the 'intrinsic gas' for a message with the given data. -func IntrinsicGas(data []byte, accessList types.AccessList, authList []types.SetCodeAuthorization, isContractCreation, isHomestead, isEIP2028, isEIP3860, isAmsterdam bool) (vm.GasCosts, error) { +func IntrinsicGas(data []byte, accessList types.AccessList, authList []types.SetCodeAuthorization, isContractCreation bool, rules params.Rules, costPerStateByte uint64) (vm.GasCosts, error) { // Set the starting gas for the raw transaction - var gas uint64 - if isContractCreation && isHomestead { - gas = params.TxGasContractCreation + var gas vm.GasCosts + if isContractCreation && rules.IsHomestead { + if rules.IsAmsterdam { + gas.RegularGas = params.TxGas + params.CreateGasAmsterdam + gas.StateGas = params.AccountCreationSize * costPerStateByte + } else { + gas.RegularGas = params.TxGasContractCreation + } } else { - gas = params.TxGas + gas.RegularGas = params.TxGas + } + // Add gas for authorizations + if authList != nil { + if rules.IsAmsterdam { + gas.RegularGas += uint64(len(authList)) * params.TxAuthTupleRegularGas + gas.StateGas += uint64(len(authList)) * (params.AuthorizationCreationSize + params.AccountCreationSize) * costPerStateByte + } else { + gas.RegularGas += uint64(len(authList)) * params.CallNewAccountGas + } } dataLen := uint64(len(data)) // Bump the required gas by the amount of transactional data @@ -85,59 +99,56 @@ func IntrinsicGas(data []byte, accessList types.AccessList, authList []types.Set // Make sure we don't exceed uint64 for all data combinations nonZeroGas := params.TxDataNonZeroGasFrontier - if isEIP2028 { + if rules.IsIstanbul { nonZeroGas = params.TxDataNonZeroGasEIP2028 } - if (math.MaxUint64-gas)/nonZeroGas < nz { + if (math.MaxUint64-gas.RegularGas)/nonZeroGas < nz { return vm.GasCosts{}, ErrGasUintOverflow } - gas += nz * nonZeroGas + gas.RegularGas += nz * nonZeroGas - if (math.MaxUint64-gas)/params.TxDataZeroGas < z { + if (math.MaxUint64-gas.RegularGas)/params.TxDataZeroGas < z { return vm.GasCosts{}, ErrGasUintOverflow } - gas += z * params.TxDataZeroGas + gas.RegularGas += z * params.TxDataZeroGas - if isContractCreation && isEIP3860 { + if isContractCreation && rules.IsShanghai { lenWords := toWordSize(dataLen) - if (math.MaxUint64-gas)/params.InitCodeWordGas < lenWords { + if (math.MaxUint64-gas.RegularGas)/params.InitCodeWordGas < lenWords { return vm.GasCosts{}, ErrGasUintOverflow } - gas += lenWords * params.InitCodeWordGas + gas.RegularGas += lenWords * params.InitCodeWordGas } } if accessList != nil { addresses := uint64(len(accessList)) storageKeys := uint64(accessList.StorageKeys()) - if (math.MaxUint64-gas)/params.TxAccessListAddressGas < addresses { + if (math.MaxUint64-gas.RegularGas)/params.TxAccessListAddressGas < addresses { return vm.GasCosts{}, ErrGasUintOverflow } - gas += addresses * params.TxAccessListAddressGas - if (math.MaxUint64-gas)/params.TxAccessListStorageKeyGas < storageKeys { + gas.RegularGas += addresses * params.TxAccessListAddressGas + if (math.MaxUint64-gas.RegularGas)/params.TxAccessListStorageKeyGas < storageKeys { return vm.GasCosts{}, ErrGasUintOverflow } - gas += storageKeys * params.TxAccessListStorageKeyGas + gas.RegularGas += storageKeys * params.TxAccessListStorageKeyGas // EIP-7981: access list data is charged in addition to the base charge. - if isAmsterdam { + if rules.IsAmsterdam { const ( addressCost = common.AddressLength * params.TxCostFloorPerToken7976 * params.TxTokenPerNonZeroByte storageKeyCost = common.HashLength * params.TxCostFloorPerToken7976 * params.TxTokenPerNonZeroByte ) - if (math.MaxUint64-gas)/addressCost < addresses { + if (math.MaxUint64-gas.RegularGas)/addressCost < addresses { return vm.GasCosts{}, ErrGasUintOverflow } - gas += addresses * addressCost - if (math.MaxUint64-gas)/storageKeyCost < storageKeys { + gas.RegularGas += addresses * addressCost + if (math.MaxUint64-gas.RegularGas)/storageKeyCost < storageKeys { return vm.GasCosts{}, ErrGasUintOverflow } - gas += storageKeys * storageKeyCost + gas.RegularGas += storageKeys * storageKeyCost } } - if authList != nil { - gas += uint64(len(authList)) * params.CallNewAccountGas - } - return vm.GasCosts{RegularGas: gas}, nil + return gas, nil } // FloorDataGas computes the minimum gas required for a transaction based on its data tokens (EIP-7623). @@ -416,18 +427,19 @@ func (st *stateTransition) buyGas() error { if have, want := st.state.GetBalance(st.msg.From), balanceCheck; have.Cmp(want) < 0 { return fmt.Errorf("%w: address %v have %v want %v", ErrInsufficientFunds, st.msg.From.Hex(), have, want) } - if err := st.gp.SubGas(st.msg.GasLimit); err != nil { - return err + + // After Amsterdam we limit the regular gas to 16k, the data gas to the transaction limit + limit := st.msg.GasLimit + if st.evm.ChainConfig().IsAmsterdam(st.evm.Context.BlockNumber, st.evm.Context.Time) { + limit = min(st.msg.GasLimit, params.MaxTxGas) } + st.initialBudget = vm.NewGasBudget(limit, st.msg.GasLimit-limit) + st.gasRemaining = st.initialBudget.Copy() if st.evm.Config.Tracer.HasGasHook() { empty := vm.GasBudget{} - initial := vm.NewGasBudget(st.msg.GasLimit) - st.evm.Config.Tracer.EmitGasChange(empty.AsTracing(), initial.AsTracing(), tracing.GasChangeTxInitialBalance) + st.evm.Config.Tracer.EmitGasChange(empty.AsTracing(), st.gasRemaining.AsTracing(), tracing.GasChangeTxInitialBalance) } - st.gasRemaining = vm.NewGasBudget(st.msg.GasLimit) - st.initialBudget = st.gasRemaining.Copy() - st.state.SubBalance(st.msg.From, mgval, tracing.BalanceDecreaseGasBuy) return nil } @@ -453,7 +465,7 @@ func (st *stateTransition) preCheck() error { isAmsterdam := st.evm.ChainConfig().IsAmsterdam(st.evm.Context.BlockNumber, st.evm.Context.Time) if !msg.SkipTransactionChecks { // Verify tx gas limit does not exceed EIP-7825 cap. - if isOsaka && !isAmsterdam && msg.GasLimit > params.MaxTxGas { + if !isAmsterdam && isOsaka && msg.GasLimit > params.MaxTxGas { return fmt.Errorf("%w (cap: %d, tx: %d)", ErrGasLimitTooHigh, params.MaxTxGas, msg.GasLimit) } // Make sure the sender is an EOA @@ -559,8 +571,9 @@ func (st *stateTransition) execute() (*ExecutionResult, error) { contractCreation = msg.To == nil floorDataGas uint64 ) + // Check clauses 4-5, subtract intrinsic gas if everything is correct - cost, err := IntrinsicGas(msg.Data, msg.AccessList, msg.SetCodeAuthorizations, contractCreation, rules.IsHomestead, rules.IsIstanbul, rules.IsShanghai, rules.IsAmsterdam) + cost, err := IntrinsicGas(msg.Data, msg.AccessList, msg.SetCodeAuthorizations, contractCreation, rules, st.evm.Context.CostPerStateByte) if err != nil { return nil, err } @@ -571,17 +584,65 @@ func (st *stateTransition) execute() (*ExecutionResult, error) { if st.evm.Config.Tracer.HasGasHook() { st.evm.Config.Tracer.EmitGasChange(prior.AsTracing(), st.gasRemaining.AsTracing(), tracing.GasChangeTxIntrinsicGas) } - // Gas limit suffices for the floor data cost (EIP-7623) + + // Check if we have enough gas in the block + if rules.IsAmsterdam { + // EIP-8037 per-tx 2D block-inclusion check. For each dimension, + // the worst-case contribution is tx.gas minus the other + // dimension's intrinsic (capped at MaxTxGas for the regular + // dimension). + regularReservation := msg.GasLimit + if regularReservation > cost.StateGas { + regularReservation -= cost.StateGas + } else { + regularReservation = 0 + } + regularReservation = min(regularReservation, params.MaxTxGas) + + stateReservation := msg.GasLimit + if stateReservation > cost.RegularGas { + stateReservation -= cost.RegularGas + } else { + stateReservation = 0 + } + if err := st.gp.CheckGasAmsterdam(regularReservation, stateReservation); err != nil { + return nil, err + } + } else { + if err := st.gp.SubGas(msg.GasLimit); err != nil { + return nil, err + } + } + + // Compute the floor data cost (EIP-7623), needed for both Prague and Amsterdam validation. if rules.IsPrague { floorDataGas, err = FloorDataGas(rules, msg.Data, msg.AccessList) if err != nil { return nil, err } if msg.GasLimit < floorDataGas { - return nil, fmt.Errorf("%w: have %d, want %d", ErrFloorDataGas, msg.GasLimit, floorDataGas) + return nil, fmt.Errorf("%w: have %d, want %d", ErrIntrinsicGas, msg.GasLimit, floorDataGas) } } + if rules.IsAmsterdam { + if cost.Sum() > msg.GasLimit { + return nil, fmt.Errorf("%w: have %d, want %d", ErrIntrinsicGas, msg.GasLimit, cost.Sum()) + } + // RegularGas must by < 16M + maxRegularGas := max(cost.RegularGas, floorDataGas) + if maxRegularGas > params.MaxTxGas { + return nil, fmt.Errorf("%w: max regular gas %d exceeds limit %d", ErrIntrinsicGas, maxRegularGas, params.MaxTxGas) + } + } + before, ok := st.gasRemaining.Charge(cost) + if !ok { + return nil, fmt.Errorf("%w: have %d, want %d", ErrIntrinsicGas, st.gasRemaining.RegularGas, cost.RegularGas) + } + if st.evm.Config.Tracer.HasGasHook() { + st.evm.Config.Tracer.EmitGasChange(before.AsTracing(), st.gasRemaining.AsTracing(), tracing.GasChangeTxIntrinsicGas) + } + if rules.IsEIP4762 { st.evm.AccessEvents.AddTxOrigin(msg.From) @@ -617,7 +678,9 @@ func (st *stateTransition) execute() (*ExecutionResult, error) { vmerr error // vm errors do not effect consensus and are therefore not assigned to err ) if contractCreation { - ret, _, st.gasRemaining, vmerr = st.evm.Create(msg.From, msg.Data, st.gasRemaining, value) + var result vm.GasBudget + ret, _, result, vmerr = st.evm.Create(msg.From, msg.Data, st.gasRemaining.ForwardAll(), value) + st.gasRemaining.Absorb(result) } else { // Increment the nonce for the next transaction. st.state.SetNonce(msg.From, st.state.GetNonce(msg.From)+1, tracing.NonceChangeEoACall) @@ -626,7 +689,7 @@ func (st *stateTransition) execute() (*ExecutionResult, error) { if msg.SetCodeAuthorizations != nil { for _, auth := range msg.SetCodeAuthorizations { // Note errors are ignored, we simply skip invalid authorizations here. - st.applyAuthorization(&auth) + _ = st.applyAuthorization(rules, &auth) } } @@ -638,9 +701,32 @@ func (st *stateTransition) execute() (*ExecutionResult, error) { if addr, ok := types.ParseDelegation(st.state.GetCode(*msg.To)); ok { st.state.AddAddressToAccessList(addr) } - // Execute the transaction's call. - ret, st.gasRemaining, vmerr = st.evm.Call(msg.From, st.to(), msg.Data, st.gasRemaining, value) + var result vm.GasBudget + ret, result, vmerr = st.evm.Call(msg.From, st.to(), msg.Data, st.gasRemaining.ForwardAll(), value) + st.gasRemaining.Absorb(result) + } + if rules.IsAmsterdam { + if vmerr != nil { + // If this was a contract creation, refund the account creation costs. + if contractCreation { + refund := params.AccountCreationSize * st.evm.Context.CostPerStateByte + st.gasRemaining.RefundState(refund) + } + } else { + // Compute refunds for selfdestructed slots + cpsb := st.evm.Context.CostPerStateByte + var sdRefund uint64 + for _, addr := range st.state.SameTxSelfDestructs() { + r := params.AccountCreationSize * cpsb + r += uint64(st.state.NewStorageSlotCount(addr)) * params.StorageCreationSize * cpsb + r += uint64(st.state.GetCodeSize(addr)) * cpsb + sdRefund += r + } + if sdRefund > 0 { + st.gasRemaining.RefundState(sdRefund) + } + } } // Record the gas used excluding gas refunds. This value represents the actual @@ -648,33 +734,53 @@ func (st *stateTransition) execute() (*ExecutionResult, error) { peakGasUsed := st.gasUsed() // Compute refund counter, capped to a refund quotient. - st.gasRemaining.Refund(st.calcRefund()) + st.gasRemaining.RefundRegular(st.calcRefund()) if rules.IsPrague { // After EIP-7623: Data-heavy transactions pay the floor gas. if used := st.gasUsed(); used < floorDataGas { - prior, _ := st.gasRemaining.Charge(vm.GasCosts{RegularGas: floorDataGas - used}) - if st.evm.Config.Tracer.HasGasHook() { - st.evm.Config.Tracer.EmitGasChange(prior.AsTracing(), st.gasRemaining.AsTracing(), tracing.GasChangeTxDataFloor) + /* + prior, _ := st.gasRemaining.Charge(vm.GasCosts{RegularGas: floorDataGas - used}) + if st.evm.Config.Tracer.HasGasHook() { + st.evm.Config.Tracer.EmitGasChange(prior.AsTracing(), st.gasRemaining.AsTracing(), tracing.GasChangeTxDataFloor) + } + */ + prev := st.gasRemaining.RegularGas + // When the calldata floor exceeds actual gas used, any + // remaining state gas must also be consumed. + targetRemaining := (st.initialBudget.RegularGas + st.initialBudget.StateGas) - floorDataGas + st.gasRemaining.StateGas = 0 + st.gasRemaining.RegularGas = targetRemaining + if t := st.evm.Config.Tracer; t != nil && t.OnGasChange != nil { + t.OnGasChange(prev, st.gasRemaining.RegularGas, tracing.GasChangeTxDataFloor) } } if peakGasUsed < floorDataGas { peakGasUsed = floorDataGas } } - // Return gas to the user - st.returnGas() - // Return gas to the gas pool + returned := st.returnGas() if rules.IsAmsterdam { - // Refund is excluded for returning - err = st.gp.ReturnGas(st.initialBudget.RegularGas-peakGasUsed, st.gasUsed()) + // EIP-8037: 2D gas accounting for Amsterdam. + // st.gasRemaining.UsedRegularGas / UsedStateGas already include both + // the intrinsic charge (from st.gasRemaining.Charge(cost) above) and + // the per-frame exec contributions absorbed from evm.Call / evm.Create. + // UsedStateGas may be negative when inline refunds (SSTORE 0→x→0, + // CREATE-failure refund, 7702 auth refund, same-tx-SD refund) exceed + // intrinsic + exec state charges. Clamp at 0. + var txState uint64 + if st.gasRemaining.UsedStateGas > 0 { + txState = uint64(st.gasRemaining.UsedStateGas) + } + txRegular := max(st.gasRemaining.UsedRegularGas, floorDataGas) + if err := st.gp.ChargeGasAmsterdam(txRegular, txState, st.gasUsed()); err != nil { + return nil, err + } } else { - // Refund is included for returning - err = st.gp.ReturnGas(st.gasRemaining.RegularGas, st.gasUsed()) - } - if err != nil { - return nil, err + if err = st.gp.ReturnGas(returned, st.gasUsed()); err != nil { + return nil, err + } } effectiveTip := msg.GasPrice if rules.IsLondon { @@ -744,7 +850,7 @@ func (st *stateTransition) validateAuthorization(auth *types.SetCodeAuthorizatio } // applyAuthorization applies an EIP-7702 code delegation to the state. -func (st *stateTransition) applyAuthorization(auth *types.SetCodeAuthorization) error { +func (st *stateTransition) applyAuthorization(rules params.Rules, auth *types.SetCodeAuthorization) error { authority, err := st.validateAuthorization(auth) if err != nil { return err @@ -753,25 +859,36 @@ func (st *stateTransition) applyAuthorization(auth *types.SetCodeAuthorization) // If the account already exists in state, refund the new account cost // charged in the intrinsic calculation. if st.state.Exist(authority) { - st.state.AddRefund(params.CallNewAccountGas - params.TxAuthTupleGas) + if rules.IsAmsterdam { + // EIP-8037: refund account creation state gas to the reservoir. + st.gasRemaining.RefundState(params.AccountCreationSize * st.evm.Context.CostPerStateByte) + } else { + st.state.AddRefund(params.CallNewAccountGas - params.TxAuthTupleGas) + } } + prevDelegation, isDelegated := types.ParseDelegation(st.state.GetCode(authority)) + // Update nonce and account code. st.state.SetNonce(authority, auth.Nonce+1, tracing.NonceChangeAuthorization) if auth.Address == (common.Address{}) { // Delegation to zero address means clear. - st.state.SetCode(authority, nil, tracing.CodeChangeAuthorizationClear) + if isDelegated { + st.state.SetCode(authority, nil, tracing.CodeChangeAuthorizationClear) + } return nil } - // Otherwise install delegation to auth.Address. - st.state.SetCode(authority, types.AddressToDelegation(auth.Address), tracing.CodeChangeAuthorization) + // install delegation to auth.Address if the delegation changed + if !isDelegated || auth.Address != prevDelegation { + st.state.SetCode(authority, types.AddressToDelegation(auth.Address), tracing.CodeChangeAuthorization) + } return nil } // calcRefund computes refund counter, capped to a refund quotient. -func (st *stateTransition) calcRefund() vm.GasBudget { +func (st *stateTransition) calcRefund() uint64 { var refund uint64 if !st.evm.ChainConfig().IsLondon(st.evm.Context.BlockNumber) { // Before EIP-3529: refunds were capped to gasUsed / 2 @@ -789,12 +906,13 @@ func (st *stateTransition) calcRefund() vm.GasBudget { st.evm.Config.Tracer.EmitGasChange(st.gasRemaining.AsTracing(), after.AsTracing(), tracing.GasChangeTxRefunds) } - return vm.NewGasBudget(refund) + return refund } // returnGas returns ETH for remaining gas, // exchanged at the original rate. -func (st *stateTransition) returnGas() { +func (st *stateTransition) returnGas() uint64 { + gas := st.gasRemaining.RegularGas + st.gasRemaining.StateGas remaining := uint256.NewInt(st.gasRemaining.RegularGas) remaining.Mul(remaining, st.msg.GasPrice) st.state.AddBalance(st.msg.From, remaining, tracing.BalanceIncreaseGasReturn) @@ -804,6 +922,7 @@ func (st *stateTransition) returnGas() { after.RegularGas = 0 st.evm.Config.Tracer.EmitGasChange(st.gasRemaining.AsTracing(), after.AsTracing(), tracing.GasChangeTxLeftOverReturned) } + return gas } // gasUsed returns the amount of gas used up by the state transition. diff --git a/core/state_transition_test.go b/core/state_transition_test.go index 8aab016123e6..be2de7f5114e 100644 --- a/core/state_transition_test.go +++ b/core/state_transition_test.go @@ -155,50 +155,50 @@ func TestIntrinsicGas(t *testing.T) { isEIP2028 bool isEIP3860 bool isAmsterdam bool - want uint64 + want vm.GasCosts }{ { name: "frontier/empty-call", - want: params.TxGas, + want: vm.GasCosts{RegularGas: params.TxGas}, }, { name: "frontier/contract-creation-pre-homestead", creation: true, isHomestead: false, // pre-homestead, contract creation still uses TxGas - want: params.TxGas, + want: vm.GasCosts{RegularGas: params.TxGas}, }, { name: "homestead/contract-creation", creation: true, isHomestead: true, - want: params.TxGasContractCreation, + want: vm.GasCosts{RegularGas: params.TxGasContractCreation}, }, { name: "frontier/non-zero-data", data: bytes.Repeat([]byte{0xff}, 100), // 100 nz bytes * 68 (frontier) - want: params.TxGas + 100*params.TxDataNonZeroGasFrontier, + want: vm.GasCosts{RegularGas: params.TxGas + 100*params.TxDataNonZeroGasFrontier}, }, { name: "istanbul/non-zero-data", data: bytes.Repeat([]byte{0xff}, 100), isEIP2028: true, // 100 nz bytes * 16 (post-EIP2028) - want: params.TxGas + 100*params.TxDataNonZeroGasEIP2028, + want: vm.GasCosts{RegularGas: params.TxGas + 100*params.TxDataNonZeroGasEIP2028}, }, { name: "istanbul/zero-data", data: bytes.Repeat([]byte{0x00}, 100), isEIP2028: true, // 100 zero bytes * 4 - want: params.TxGas + 100*params.TxDataZeroGas, + want: vm.GasCosts{RegularGas: params.TxGas + 100*params.TxDataZeroGas}, }, { name: "istanbul/mixed-data", data: append(bytes.Repeat([]byte{0x00}, 50), bytes.Repeat([]byte{0xff}, 50)...), isEIP2028: true, - want: params.TxGas + 50*params.TxDataZeroGas + 50*params.TxDataNonZeroGasEIP2028, + want: vm.GasCosts{RegularGas: params.TxGas + 50*params.TxDataZeroGas + 50*params.TxDataNonZeroGasEIP2028}, }, { name: "shanghai/init-code-word-gas", @@ -208,7 +208,7 @@ func TestIntrinsicGas(t *testing.T) { isEIP2028: true, isEIP3860: true, // TxGasContractCreation + 64 zero bytes * 4 + 2 words * 2 - want: params.TxGasContractCreation + 64*params.TxDataZeroGas + 2*params.InitCodeWordGas, + want: vm.GasCosts{RegularGas: params.TxGasContractCreation + 64*params.TxDataZeroGas + 2*params.InitCodeWordGas}, }, { name: "shanghai/init-code-non-multiple-of-32", @@ -217,7 +217,7 @@ func TestIntrinsicGas(t *testing.T) { isHomestead: true, isEIP2028: true, isEIP3860: true, - want: params.TxGasContractCreation + 33*params.TxDataZeroGas + 2*params.InitCodeWordGas, + want: vm.GasCosts{RegularGas: params.TxGasContractCreation + 33*params.TxDataZeroGas + 2*params.InitCodeWordGas}, }, { name: "berlin/access-list", @@ -227,7 +227,7 @@ func TestIntrinsicGas(t *testing.T) { }, isEIP2028: true, // 2 addrs * 2400 + 3 keys * 1900 - want: params.TxGas + 2*params.TxAccessListAddressGas + 3*params.TxAccessListStorageKeyGas, + want: vm.GasCosts{RegularGas: params.TxGas + 2*params.TxAccessListAddressGas + 3*params.TxAccessListStorageKeyGas}, }, { name: "amsterdam/access-list-extra-cost", @@ -238,9 +238,9 @@ func TestIntrinsicGas(t *testing.T) { isEIP2028: true, isAmsterdam: true, // base access-list charge + EIP-7981 extra - want: params.TxGas + + want: vm.GasCosts{RegularGas: params.TxGas + 2*params.TxAccessListAddressGas + 3*params.TxAccessListStorageKeyGas + - 2*amsterdamAddressCost + 3*amsterdamStorageKeyCost, + 2*amsterdamAddressCost + 3*amsterdamStorageKeyCost}, }, { name: "prague/auth-list", @@ -250,8 +250,54 @@ func TestIntrinsicGas(t *testing.T) { {Address: addr1}, }, isEIP2028: true, - // 3 auths * 25000 - want: params.TxGas + 3*params.CallNewAccountGas, + // 3 auths * 25000 (pre-Amsterdam: CallNewAccountGas per auth tuple) + want: vm.GasCosts{RegularGas: params.TxGas + 3*params.CallNewAccountGas}, + }, + { + name: "amsterdam/contract-creation-empty", + creation: true, + isHomestead: true, + isEIP2028: true, + isAmsterdam: true, + // EIP-8037: creation regular gas is TxGas + CreateGasAmsterdam (not TxGasContractCreation), + // and account-creation cost is moved to state gas. + want: vm.GasCosts{ + RegularGas: params.TxGas + params.CreateGasAmsterdam, + StateGas: params.AccountCreationSize * params.CostPerStateByte, + }, + }, + { + name: "amsterdam/contract-creation-init-code", + data: bytes.Repeat([]byte{0x00}, 64), // 2 words of init code + creation: true, + isHomestead: true, + isEIP2028: true, + isEIP3860: true, // Shanghai gates init-code word gas + isAmsterdam: true, + want: vm.GasCosts{ + RegularGas: params.TxGas + params.CreateGasAmsterdam + + 64*params.TxDataZeroGas + 2*params.InitCodeWordGas, + StateGas: params.AccountCreationSize * params.CostPerStateByte, + }, + }, + { + name: "amsterdam/contract-creation-with-access-list", + data: bytes.Repeat([]byte{0xff}, 32), // 1 word of non-zero init code + accessList: types.AccessList{ + {Address: addr1, StorageKeys: []common.Hash{key1}}, + }, + creation: true, + isHomestead: true, + isEIP2028: true, + isEIP3860: true, + isAmsterdam: true, + want: vm.GasCosts{ + RegularGas: params.TxGas + params.CreateGasAmsterdam + + 32*params.TxDataNonZeroGasEIP2028 + 1*params.InitCodeWordGas + + 1*params.TxAccessListAddressGas + 1*params.TxAccessListStorageKeyGas + + 1*amsterdamAddressCost + 1*amsterdamStorageKeyCost, + StateGas: params.AccountCreationSize * params.CostPerStateByte, + }, }, { name: "amsterdam/combined", @@ -264,23 +310,34 @@ func TestIntrinsicGas(t *testing.T) { }, isEIP2028: true, isAmsterdam: true, - want: params.TxGas + - 100*params.TxDataNonZeroGasEIP2028 + - 1*params.TxAccessListAddressGas + 1*params.TxAccessListStorageKeyGas + - 1*amsterdamAddressCost + 1*amsterdamStorageKeyCost + - 1*params.CallNewAccountGas, + // EIP-8037 splits the auth-tuple charge into regular + state gas: + // regular: TxAuthTupleRegularGas (7500) per auth + // state: (AuthorizationCreationSize + AccountCreationSize) * CostPerStateByte per auth + want: vm.GasCosts{ + RegularGas: params.TxGas + + 100*params.TxDataNonZeroGasEIP2028 + + 1*params.TxAccessListAddressGas + 1*params.TxAccessListStorageKeyGas + + 1*amsterdamAddressCost + 1*amsterdamStorageKeyCost + + 1*params.TxAuthTupleRegularGas, + StateGas: 1 * (params.AuthorizationCreationSize + params.AccountCreationSize) * params.CostPerStateByte, + }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + rules := params.Rules{ + IsHomestead: tt.isHomestead, + IsIstanbul: tt.isEIP2028, + IsShanghai: tt.isEIP3860, + IsAmsterdam: tt.isAmsterdam, + } got, err := IntrinsicGas(tt.data, tt.accessList, tt.authList, - tt.creation, tt.isHomestead, tt.isEIP2028, tt.isEIP3860, tt.isAmsterdam) + tt.creation, rules, params.CostPerStateByte) if err != nil { t.Fatalf("unexpected error: %v", err) } - want := vm.GasCosts{RegularGas: tt.want} - if got != want { - t.Fatalf("gas mismatch: got %+v, want %+v", got, want) + if got != tt.want { + t.Fatalf("gas mismatch: got %+v, want %+v", got, tt.want) } }) } diff --git a/core/txpool/validation.go b/core/txpool/validation.go index c87bba31ac48..439d5c68ffdb 100644 --- a/core/txpool/validation.go +++ b/core/txpool/validation.go @@ -125,7 +125,8 @@ func ValidateTransaction(tx *types.Transaction, head *types.Header, signer types } // Ensure the transaction has more gas than the bare minimum needed to cover // the transaction metadata - intrGas, err := core.IntrinsicGas(tx.Data(), tx.AccessList(), tx.SetCodeAuthorizations(), tx.To() == nil, true, rules.IsIstanbul, rules.IsShanghai, rules.IsAmsterdam) + gasCostPerStateByte := core.CostPerStateByte(head, opts.Config) + intrGas, err := core.IntrinsicGas(tx.Data(), tx.AccessList(), tx.SetCodeAuthorizations(), tx.To() == nil, rules, gasCostPerStateByte) if err != nil { return err } diff --git a/core/vm/contract.go b/core/vm/contract.go index 45c879c80f20..03360ebaffd9 100644 --- a/core/vm/contract.go +++ b/core/vm/contract.go @@ -42,6 +42,8 @@ type Contract struct { IsDeployment bool IsSystemCall bool + // Gas carries the unified gas state for this frame: running balance, + // reservoir, and per-frame usage accumulators. See GasBudget. Gas GasBudget value *uint256.Int } @@ -113,7 +115,6 @@ func (c *Contract) GetOp(n uint64) OpCode { if n < uint64(len(c.Code)) { return OpCode(c.Code[n]) } - return STOP } @@ -125,9 +126,10 @@ func (c *Contract) Caller() common.Address { return c.caller } -// UseGas attempts the use gas and subtracts it and returns true on success -func (c *Contract) UseGas(cost GasCosts, logger *tracing.Hooks, reason tracing.GasChangeReason) (ok bool) { - prior, ok := c.Gas.Charge(cost) +// chargeRegular deducts regular gas only, with tracer integration. +// Returns false on OOG. Delegates the arithmetic to GasBudget.ChargeRegular. +func (c *Contract) chargeRegular(r uint64, logger *tracing.Hooks, reason tracing.GasChangeReason) bool { + prior, ok := c.Gas.ChargeRegular(r) if !ok { return false } @@ -137,15 +139,45 @@ func (c *Contract) UseGas(cost GasCosts, logger *tracing.Hooks, reason tracing.G return true } -// RefundGas refunds the leftover gas budget back to the contract. -func (c *Contract) RefundGas(refund GasBudget, logger *tracing.Hooks, reason tracing.GasChangeReason) { - prior, changed := c.Gas.Refund(refund) - if !changed { - return +// chargeState deducts state gas (spilling into regular when the reservoir is +// exhausted), with tracer integration. Returns false on OOG. +func (c *Contract) chargeState(s uint64, logger *tracing.Hooks, reason tracing.GasChangeReason) bool { + prior, ok := c.Gas.ChargeState(s) + if !ok { + return false + } + if logger.HasGasHook() && reason != tracing.GasChangeIgnored { + logger.EmitGasChange(prior.AsTracing(), c.Gas.AsTracing(), reason) } + return true +} + +// RefundGas absorbs a sub-call's leftover GasBudget into this contract's gas +// state. Thin wrapper around GasBudget.Absorb with tracer integration. +func (c *Contract) RefundGas(child GasBudget, logger *tracing.Hooks, reason tracing.GasChangeReason) { + prior := c.Gas + c.Gas.Absorb(child) + if logger.HasGasHook() && reason != tracing.GasChangeIgnored { + logger.EmitGasChange(prior.AsTracing(), c.Gas.AsTracing(), reason) + } +} + +// forwardGas drains `regular` regular gas and the entire state reservoir +// from this contract's running budget and returns the initial GasBudget for +// a child frame. The caller's UsedRegularGas is bumped by the forwarded +// amount so that the absorb-on-return path correctly reclaims the unused +// portion. Thin wrapper around GasBudget.Forward with tracer integration. +// +// Caller must ensure `regular` is no larger than the running balance (the +// opcode's dynamic gas table is expected to validate that before invoking +// the opcode handler). +func (c *Contract) forwardGas(regular uint64, logger *tracing.Hooks, reason tracing.GasChangeReason) GasBudget { + prior := c.Gas + child := c.Gas.Forward(regular) if logger.HasGasHook() && reason != tracing.GasChangeIgnored { logger.EmitGasChange(prior.AsTracing(), c.Gas.AsTracing(), reason) } + return child } // Address returns the contracts address diff --git a/core/vm/contracts.go b/core/vm/contracts.go index 71cfdbc52773..306fa0dbc3ad 100644 --- a/core/vm/contracts.go +++ b/core/vm/contracts.go @@ -266,7 +266,6 @@ func RunPrecompiledContract(stateDB StateDB, p PrecompiledContract, address comm gasCost := p.RequiredGas(input) prior, ok := gas.Charge(GasCosts{RegularGas: gasCost}) if !ok { - gas.Exhaust() return nil, gas, ErrOutOfGas } if logger.HasGasHook() { diff --git a/core/vm/contracts_fuzz_test.go b/core/vm/contracts_fuzz_test.go index 988cdb91f222..4d28df6a6a17 100644 --- a/core/vm/contracts_fuzz_test.go +++ b/core/vm/contracts_fuzz_test.go @@ -37,7 +37,7 @@ func FuzzPrecompiledContracts(f *testing.F) { return } inWant := string(input) - RunPrecompiledContract(nil, p, a, input, NewGasBudget(gas), nil, params.Rules{}) + RunPrecompiledContract(nil, p, a, input, NewGasBudget(gas, 0), nil, params.Rules{}) if inHave := string(input); inWant != inHave { t.Errorf("Precompiled %v modified input data", a) } diff --git a/core/vm/contracts_test.go b/core/vm/contracts_test.go index e7841c8552e7..c6975bd0a684 100644 --- a/core/vm/contracts_test.go +++ b/core/vm/contracts_test.go @@ -100,7 +100,7 @@ func testPrecompiled(addr string, test precompiledTest, t *testing.T) { in := common.Hex2Bytes(test.Input) gas := p.RequiredGas(in) t.Run(fmt.Sprintf("%s-Gas=%d", test.Name, gas), func(t *testing.T) { - if res, _, err := RunPrecompiledContract(nil, p, common.HexToAddress(addr), in, NewGasBudget(gas), nil, params.Rules{}); err != nil { + if res, _, err := RunPrecompiledContract(nil, p, common.HexToAddress(addr), in, NewGasBudget(gas, 0), nil, params.Rules{}); err != nil { t.Error(err) } else if common.Bytes2Hex(res) != test.Expected { t.Errorf("Expected %v, got %v", test.Expected, common.Bytes2Hex(res)) @@ -122,7 +122,7 @@ func testPrecompiledOOG(addr string, test precompiledTest, t *testing.T) { gas := test.Gas - 1 t.Run(fmt.Sprintf("%s-Gas=%d", test.Name, gas), func(t *testing.T) { - _, _, err := RunPrecompiledContract(nil, p, common.HexToAddress(addr), in, NewGasBudget(gas), nil, params.Rules{}) + _, _, err := RunPrecompiledContract(nil, p, common.HexToAddress(addr), in, NewGasBudget(gas, 0), nil, params.Rules{}) if err.Error() != "out of gas" { t.Errorf("Expected error [out of gas], got [%v]", err) } @@ -139,7 +139,7 @@ func testPrecompiledFailure(addr string, test precompiledFailureTest, t *testing in := common.Hex2Bytes(test.Input) gas := p.RequiredGas(in) t.Run(test.Name, func(t *testing.T) { - _, _, err := RunPrecompiledContract(nil, p, common.HexToAddress(addr), in, NewGasBudget(gas), nil, params.Rules{}) + _, _, err := RunPrecompiledContract(nil, p, common.HexToAddress(addr), in, NewGasBudget(gas, 0), nil, params.Rules{}) if err.Error() != test.ExpectedError { t.Errorf("Expected error [%v], got [%v]", test.ExpectedError, err) } @@ -170,7 +170,7 @@ func benchmarkPrecompiled(addr string, test precompiledTest, bench *testing.B) { start := time.Now() for bench.Loop() { copy(data, in) - res, _, err = RunPrecompiledContract(nil, p, common.HexToAddress(addr), data, NewGasBudget(reqGas), nil, params.Rules{}) + res, _, err = RunPrecompiledContract(nil, p, common.HexToAddress(addr), data, NewGasBudget(reqGas, 0), nil, params.Rules{}) } elapsed := uint64(time.Since(start)) if elapsed < 1 { diff --git a/core/vm/eips.go b/core/vm/eips.go index 33af8fd4fd9d..0fbf27519a62 100644 --- a/core/vm/eips.go +++ b/core/vm/eips.go @@ -43,6 +43,7 @@ var activators = map[int]func(*JumpTable){ 7939: enable7939, 8024: enable8024, 7843: enable7843, + 8037: enable8037, } // EnableEIP enables the given EIP on the config. @@ -377,7 +378,7 @@ func opExtCodeCopyEIP4762(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, er code := evm.StateDB.GetCode(addr) paddedCodeCopy, copyOffset, nonPaddedCopyLength := getDataAndAdjustedBounds(code, uint64CodeOffset, length.Uint64()) consumed, wanted := evm.AccessEvents.CodeChunksRangeGas(addr, copyOffset, nonPaddedCopyLength, uint64(len(code)), false, scope.Contract.Gas.RegularGas) - scope.Contract.UseGas(GasCosts{RegularGas: consumed}, evm.Config.Tracer, tracing.GasChangeUnspecified) + scope.Contract.chargeRegular(consumed, evm.Config.Tracer, tracing.GasChangeUnspecified) if consumed < wanted { return nil, ErrOutOfGas } @@ -403,7 +404,7 @@ func opPush1EIP4762(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { // advanced past this boundary. contractAddr := scope.Contract.Address() consumed, wanted := evm.AccessEvents.CodeChunksRangeGas(contractAddr, *pc+1, uint64(1), uint64(len(scope.Contract.Code)), false, scope.Contract.Gas.RegularGas) - scope.Contract.UseGas(GasCosts{RegularGas: wanted}, evm.Config.Tracer, tracing.GasChangeUnspecified) + scope.Contract.chargeRegular(wanted, evm.Config.Tracer, tracing.GasChangeUnspecified) if consumed < wanted { return nil, ErrOutOfGas } @@ -430,7 +431,7 @@ func makePushEIP4762(size uint64, pushByteSize int) executionFunc { if !scope.Contract.IsDeployment && !scope.Contract.IsSystemCall { contractAddr := scope.Contract.Address() consumed, wanted := evm.AccessEvents.CodeChunksRangeGas(contractAddr, uint64(start), uint64(pushByteSize), uint64(len(scope.Contract.Code)), false, scope.Contract.Gas.RegularGas) - scope.Contract.UseGas(GasCosts{RegularGas: consumed}, evm.Config.Tracer, tracing.GasChangeUnspecified) + scope.Contract.chargeRegular(consumed, evm.Config.Tracer, tracing.GasChangeUnspecified) if consumed < wanted { return nil, ErrOutOfGas } @@ -590,3 +591,14 @@ func enable7843(jt *JumpTable) { maxStack: maxStack(0, 1), } } + +// enable8037 enables the multidimensional-metering as specified in EIP-8037. +func enable8037(jt *JumpTable) { + jt[CREATE].constantGas = params.CreateGasAmsterdam + jt[CREATE].dynamicGas = gasCreateEip8037 + jt[CREATE2].constantGas = params.CreateGasAmsterdam + jt[CREATE2].dynamicGas = gasCreate2Eip8037 + jt[CALL].dynamicGas = gasCallEIP8037 + jt[SELFDESTRUCT].dynamicGas = gasSelfdestruct8037 + jt[SSTORE].dynamicGas = gasSStore8037 +} diff --git a/core/vm/evm.go b/core/vm/evm.go index 832306b9a0cd..19a7c1368e7e 100644 --- a/core/vm/evm.go +++ b/core/vm/evm.go @@ -67,6 +67,8 @@ type BlockContext struct { BlobBaseFee *big.Int // Provides information for BLOBBASEFEE (0 if vm runs with NoBaseFee flag and 0 blob gas price) Random *common.Hash // Provides information for PREVRANDAO SlotNum uint64 // Provides information for SLOTNUM + + CostPerStateByte uint64 // CostPerByte for new state after EIP-8037 } // TxContext provides the EVM with information about a transaction. @@ -245,23 +247,25 @@ func isSystemCall(caller common.Address) bool { // parameters. It also handles any necessary value transfer required and takse // the necessary steps to create accounts and reverses the state in case of an // execution error or failed value transfer. -func (evm *EVM) Call(caller common.Address, addr common.Address, input []byte, gas GasBudget, value *uint256.Int) (ret []byte, leftOverGas GasBudget, err error) { +func (evm *EVM) Call(caller common.Address, addr common.Address, input []byte, gas GasBudget, value *uint256.Int) (ret []byte, result GasBudget, err error) { // Capture the tracer start/end events in debug mode if evm.Config.Tracer != nil { - evm.captureBegin(evm.depth, CALL, caller, addr, input, gas.RegularGas, value.ToBig()) - defer func(startGas uint64) { - evm.captureEnd(evm.depth, startGas, leftOverGas.RegularGas, ret, err) - }(gas.RegularGas) + evm.captureBegin(evm.depth, CALL, caller, addr, input, gas, value.ToBig()) + defer func(startGas GasBudget) { + evm.captureEnd(evm.depth, startGas, result, ret, err) + }(gas) } + initialStateGas := gas.StateGas + // Fail if we're trying to execute above the call depth limit if evm.depth > int(params.CallCreateDepth) { - return nil, gas, ErrDepth + return nil, gas.Preserved(), ErrDepth } syscall := isSystemCall(caller) // Fail if we're trying to transfer more than the available balance. if !syscall && !value.IsZero() && !evm.Context.CanTransfer(evm.StateDB, caller, value) { - return nil, gas, ErrInsufficientBalance + return nil, gas.Preserved(), ErrInsufficientBalance } snapshot := evm.StateDB.Snapshot() p, isPrecompile := evm.precompile(addr) @@ -275,16 +279,15 @@ func (evm *EVM) Call(caller common.Address, addr common.Address, input []byte, g // hash leaf to the access list, then account creation will proceed unimpaired. // Thus, only pay for the creation of the code hash leaf here. wgas := evm.AccessEvents.CodeHashGas(addr, true, gas.RegularGas, false) - if _, ok := gas.Charge(GasCosts{RegularGas: wgas}); !ok { + if _, ok := gas.ChargeRegular(wgas); !ok { evm.StateDB.RevertToSnapshot(snapshot) - gas.Exhaust() - return nil, gas, ErrOutOfGas + return nil, gas.ExitHalt(initialStateGas), ErrOutOfGas } } if !isPrecompile && evm.chainRules.IsEIP158 && value.IsZero() { // Calling a non-existing account, don't do anything. - return nil, gas, nil + return nil, gas.Preserved(), nil } evm.StateDB.CreateAccount(addr) } @@ -296,7 +299,11 @@ func (evm *EVM) Call(caller common.Address, addr common.Address, input []byte, g } if isPrecompile { - ret, gas, err = RunPrecompiledContract(evm.StateDB, p, addr, input, gas, evm.Config.Tracer, evm.chainRules) + var stateDB StateDB + if evm.chainRules.IsAmsterdam { + stateDB = evm.StateDB + } + ret, gas, err = RunPrecompiledContract(stateDB, p, addr, input, gas, evm.Config.Tracer, evm.chainRules) } else { // Initialise a new contract and set the code that is to be used by the EVM. code := evm.resolveCode(addr) @@ -312,21 +319,17 @@ func (evm *EVM) Call(caller common.Address, addr common.Address, input []byte, g } } // When an error was returned by the EVM or when setting the creation code - // above we revert to the snapshot and consume any gas remaining. Additionally, - // when we're in homestead this also counts for code storage gas errors. + // above we revert to the snapshot. gasFromExec below handles the + // regular-gas burn on halt. if err != nil { evm.StateDB.RevertToSnapshot(snapshot) if err != ErrExecutionReverted { if evm.Config.Tracer.HasGasHook() { evm.Config.Tracer.EmitGasChange(gas.AsTracing(), tracing.Gas{}, tracing.GasChangeCallFailedExecution) } - gas.Exhaust() } - // TODO: consider clearing up unused snapshots: - //} else { - // evm.StateDB.DiscardSnapshot(snapshot) } - return ret, gas, err + return ret, gas.ExitFromErr(err, initialStateGas), err } // CallCode executes the contract associated with the addr with the given input @@ -336,30 +339,33 @@ func (evm *EVM) Call(caller common.Address, addr common.Address, input []byte, g // // CallCode differs from Call in the sense that it executes the given address' // code with the caller as context. -func (evm *EVM) CallCode(caller common.Address, addr common.Address, input []byte, gas GasBudget, value *uint256.Int) (ret []byte, leftOverGas GasBudget, err error) { +func (evm *EVM) CallCode(caller common.Address, addr common.Address, input []byte, gas GasBudget, value *uint256.Int) (ret []byte, result GasBudget, err error) { // Invoke tracer hooks that signal entering/exiting a call frame if evm.Config.Tracer != nil { - evm.captureBegin(evm.depth, CALLCODE, caller, addr, input, gas.RegularGas, value.ToBig()) - defer func(startGas uint64) { - evm.captureEnd(evm.depth, startGas, leftOverGas.RegularGas, ret, err) - }(gas.RegularGas) + evm.captureBegin(evm.depth, CALLCODE, caller, addr, input, gas, value.ToBig()) + defer func(startGas GasBudget) { + evm.captureEnd(evm.depth, startGas, result, ret, err) + }(gas) } + initialStateGas := gas.StateGas + // Fail if we're trying to execute above the call depth limit if evm.depth > int(params.CallCreateDepth) { - return nil, gas, ErrDepth + return nil, gas.Preserved(), ErrDepth } // Fail if we're trying to transfer more than the available balance - // Note although it's noop to transfer X ether to caller itself. But - // if caller doesn't have enough balance, it would be an error to allow - // over-charging itself. So the check here is necessary. if !evm.Context.CanTransfer(evm.StateDB, caller, value) { - return nil, gas, ErrInsufficientBalance + return nil, gas.Preserved(), ErrInsufficientBalance } var snapshot = evm.StateDB.Snapshot() // It is allowed to call precompiles, even via delegatecall if p, isPrecompile := evm.precompile(addr); isPrecompile { - ret, gas, err = RunPrecompiledContract(evm.StateDB, p, addr, input, gas, evm.Config.Tracer, evm.chainRules) + var stateDB StateDB + if evm.chainRules.IsAmsterdam { + stateDB = evm.StateDB + } + ret, gas, err = RunPrecompiledContract(stateDB, p, addr, input, gas, evm.Config.Tracer, evm.chainRules) } else { // Initialise a new contract and set the code that is to be used by the EVM. // The contract is a scoped environment for this execution context only. @@ -374,10 +380,9 @@ func (evm *EVM) CallCode(caller common.Address, addr common.Address, input []byt if evm.Config.Tracer.HasGasHook() { evm.Config.Tracer.EmitGasChange(gas.AsTracing(), tracing.Gas{}, tracing.GasChangeCallFailedExecution) } - gas.Exhaust() } } - return ret, gas, err + return ret, gas.ExitFromErr(err, initialStateGas), err } // DelegateCall executes the contract associated with the addr with the given input @@ -385,28 +390,31 @@ func (evm *EVM) CallCode(caller common.Address, addr common.Address, input []byt // // DelegateCall differs from CallCode in the sense that it executes the given address' // code with the caller as context and the caller is set to the caller of the caller. -func (evm *EVM) DelegateCall(originCaller common.Address, caller common.Address, addr common.Address, input []byte, gas GasBudget, value *uint256.Int) (ret []byte, leftOverGas GasBudget, err error) { +func (evm *EVM) DelegateCall(originCaller common.Address, caller common.Address, addr common.Address, input []byte, gas GasBudget, value *uint256.Int) (ret []byte, result GasBudget, err error) { // Invoke tracer hooks that signal entering/exiting a call frame if evm.Config.Tracer != nil { // DELEGATECALL inherits value from parent call - evm.captureBegin(evm.depth, DELEGATECALL, caller, addr, input, gas.RegularGas, value.ToBig()) - defer func(startGas uint64) { - evm.captureEnd(evm.depth, startGas, leftOverGas.RegularGas, ret, err) - }(gas.RegularGas) + evm.captureBegin(evm.depth, DELEGATECALL, caller, addr, input, gas, value.ToBig()) + defer func(startGas GasBudget) { + evm.captureEnd(evm.depth, startGas, result, ret, err) + }(gas) } + initialStateGas := gas.StateGas + // Fail if we're trying to execute above the call depth limit if evm.depth > int(params.CallCreateDepth) { - return nil, gas, ErrDepth + return nil, gas.Preserved(), ErrDepth } var snapshot = evm.StateDB.Snapshot() // It is allowed to call precompiles, even via delegatecall if p, isPrecompile := evm.precompile(addr); isPrecompile { - ret, gas, err = RunPrecompiledContract(evm.StateDB, p, addr, input, gas, evm.Config.Tracer, evm.chainRules) + var stateDB StateDB + if evm.chainRules.IsAmsterdam { + stateDB = evm.StateDB + } + ret, gas, err = RunPrecompiledContract(stateDB, p, addr, input, gas, evm.Config.Tracer, evm.chainRules) } else { - // Initialise a new contract and make initialise the delegate values - // - // Note: The value refers to the original value from the parent call. contract := NewContract(originCaller, caller, value, gas, evm.jumpDests) contract.SetCallCode(evm.resolveCodeHash(addr), evm.resolveCode(addr)) ret, err = evm.Run(contract, input, false) @@ -418,27 +426,28 @@ func (evm *EVM) DelegateCall(originCaller common.Address, caller common.Address, if evm.Config.Tracer.HasGasHook() { evm.Config.Tracer.EmitGasChange(gas.AsTracing(), tracing.Gas{}, tracing.GasChangeCallFailedExecution) } - gas.Exhaust() } } - return ret, gas, err + return ret, gas.ExitFromErr(err, initialStateGas), err } // StaticCall executes the contract associated with the addr with the given input // as parameters while disallowing any modifications to the state during the call. // Opcodes that attempt to perform such modifications will result in exceptions // instead of performing the modifications. -func (evm *EVM) StaticCall(caller common.Address, addr common.Address, input []byte, gas GasBudget) (ret []byte, leftOverGas GasBudget, err error) { +func (evm *EVM) StaticCall(caller common.Address, addr common.Address, input []byte, gas GasBudget) (ret []byte, result GasBudget, err error) { // Invoke tracer hooks that signal entering/exiting a call frame if evm.Config.Tracer != nil { - evm.captureBegin(evm.depth, STATICCALL, caller, addr, input, gas.RegularGas, nil) - defer func(startGas uint64) { - evm.captureEnd(evm.depth, startGas, leftOverGas.RegularGas, ret, err) - }(gas.RegularGas) + evm.captureBegin(evm.depth, STATICCALL, caller, addr, input, gas, nil) + defer func(startGas GasBudget) { + evm.captureEnd(evm.depth, startGas, result, ret, err) + }(gas) } + initialStateGas := gas.StateGas + // Fail if we're trying to execute above the call depth limit if evm.depth > int(params.CallCreateDepth) { - return nil, gas, ErrDepth + return nil, gas.Preserved(), ErrDepth } // We take a snapshot here. This is a bit counter-intuitive, and could probably be skipped. // However, even a staticcall is considered a 'touch'. On mainnet, static calls were introduced @@ -454,16 +463,14 @@ func (evm *EVM) StaticCall(caller common.Address, addr common.Address, input []b evm.StateDB.AddBalance(addr, new(uint256.Int), tracing.BalanceChangeTouchAccount) if p, isPrecompile := evm.precompile(addr); isPrecompile { - ret, gas, err = RunPrecompiledContract(evm.StateDB, p, addr, input, gas, evm.Config.Tracer, evm.chainRules) + var stateDB StateDB + if evm.chainRules.IsAmsterdam { + stateDB = evm.StateDB + } + ret, gas, err = RunPrecompiledContract(stateDB, p, addr, input, gas, evm.Config.Tracer, evm.chainRules) } else { - // Initialise a new contract and set the code that is to be used by the EVM. - // The contract is a scoped environment for this execution context only. contract := NewContract(caller, addr, new(uint256.Int), gas, evm.jumpDests) contract.SetCallCode(evm.resolveCodeHash(addr), evm.resolveCode(addr)) - - // When an error was returned by the EVM or when setting the creation code - // above we revert to the snapshot and consume any gas remaining. Additionally - // when we're in Homestead this also counts for code storage gas errors. ret, err = evm.Run(contract, input, true) gas = contract.Gas } @@ -473,41 +480,47 @@ func (evm *EVM) StaticCall(caller common.Address, addr common.Address, input []b if evm.Config.Tracer.HasGasHook() { evm.Config.Tracer.EmitGasChange(gas.AsTracing(), tracing.Gas{}, tracing.GasChangeCallFailedExecution) } - gas.Exhaust() } } - return ret, gas, err + return ret, gas.ExitFromErr(err, initialStateGas), err } // create creates a new contract using code as deployment code. -func (evm *EVM) create(caller common.Address, code []byte, gas GasBudget, value *uint256.Int, address common.Address, typ OpCode) (ret []byte, createAddress common.Address, leftOverGas GasBudget, err error) { - if evm.Config.Tracer != nil { - evm.captureBegin(evm.depth, typ, caller, address, code, gas.RegularGas, value.ToBig()) - defer func(startGas uint64) { - evm.captureEnd(evm.depth, startGas, leftOverGas.RegularGas, ret, err) - }(gas.RegularGas) - } +func (evm *EVM) create(caller common.Address, code []byte, gas GasBudget, value *uint256.Int, address common.Address, typ OpCode) (ret []byte, createAddress common.Address, result GasBudget, err error) { // Depth check execution. Fail if we're trying to execute above the // limit. + var nonce uint64 if evm.depth > int(params.CallCreateDepth) { - return nil, common.Address{}, gas, ErrDepth + err = ErrDepth + } else if !evm.Context.CanTransfer(evm.StateDB, caller, value) { + err = ErrInsufficientBalance + } else { + nonce = evm.StateDB.GetNonce(caller) + if nonce+1 < nonce { + err = ErrNonceUintOverflow + } } - if !evm.Context.CanTransfer(evm.StateDB, caller, value) { - return nil, common.Address{}, gas, ErrInsufficientBalance + initialStateGas := gas.StateGas + + if err == nil { + evm.StateDB.SetNonce(caller, nonce+1, tracing.NonceChangeContractCreator) } - nonce := evm.StateDB.GetNonce(caller) - if nonce+1 < nonce { - return nil, common.Address{}, gas, ErrNonceUintOverflow + if evm.Config.Tracer != nil { + evm.captureBegin(evm.depth, typ, caller, address, code, gas, value.ToBig()) + defer func(startGas GasBudget) { + evm.captureEnd(evm.depth, startGas, result, ret, err) + }(gas) + } + if err != nil { + return nil, common.Address{}, gas.Preserved(), err } - evm.StateDB.SetNonce(caller, nonce+1, tracing.NonceChangeContractCreator) // Charge the contract creation init gas in verkle mode if evm.chainRules.IsEIP4762 { statelessGas := evm.AccessEvents.ContractCreatePreCheckGas(address, gas.RegularGas) prior, ok := gas.Charge(GasCosts{RegularGas: statelessGas}) if !ok { - gas.Exhaust() - return nil, common.Address{}, gas, ErrOutOfGas + return nil, common.Address{}, gas.ExitHalt(initialStateGas), ErrOutOfGas } if evm.Config.Tracer.HasGasHook() { evm.Config.Tracer.EmitGasChange(prior.AsTracing(), gas.AsTracing(), tracing.GasChangeWitnessContractCollisionCheck) @@ -528,11 +541,13 @@ func (evm *EVM) create(caller common.Address, code []byte, gas GasBudget, value if evm.StateDB.GetNonce(address) != 0 || (contractHash != (common.Hash{}) && contractHash != types.EmptyCodeHash) || // non-empty code isEIP7610RejectedAccount(evm.ChainConfig().ChainID, address, evm.chainRules.IsEIP158) { + halt := gas.ExitHalt(initialStateGas) if evm.Config.Tracer.HasGasHook() { - evm.Config.Tracer.EmitGasChange(gas.AsTracing(), tracing.Gas{}, tracing.GasChangeCallFailedExecution) + evm.Config.Tracer.EmitGasChange(gas.AsTracing(), halt.AsTracing(), tracing.GasChangeCallFailedExecution) } - gas.Exhaust() - return nil, common.Address{}, gas, ErrContractAddressCollision + // EIP-8037 collision rule: the state reservoir is fully preserved on + // address collision while regular gas is burnt. + return nil, common.Address{}, halt, ErrContractAddressCollision } // Create a new account on the state only if the object was not present. // It might be possible the contract code is deployed to a pre-existent @@ -554,8 +569,7 @@ func (evm *EVM) create(caller common.Address, code []byte, gas GasBudget, value if evm.chainRules.IsEIP4762 { consumed, wanted := evm.AccessEvents.ContractCreateInitGas(address, gas.RegularGas) if consumed < wanted { - gas.Exhaust() - return nil, common.Address{}, gas, ErrOutOfGas + return nil, common.Address{}, gas.ExitHalt(initialStateGas), ErrOutOfGas } prior, _ := gas.Charge(GasCosts{RegularGas: consumed}) if evm.Config.Tracer.HasGasHook() { @@ -574,13 +588,21 @@ func (evm *EVM) create(caller common.Address, code []byte, gas GasBudget, value contract.IsDeployment = true ret, err = evm.initNewContract(contract, address) + + // Special case: ErrCodeStoreOutOfGas pre-Homestead does NOT roll back + // state and gas is preserved (i.e., treated as success). if err != nil && (evm.chainRules.IsHomestead || err != ErrCodeStoreOutOfGas) { evm.StateDB.RevertToSnapshot(snapshot) if err != ErrExecutionReverted { - contract.UseGas(GasCosts{RegularGas: contract.Gas.RegularGas}, evm.Config.Tracer, tracing.GasChangeCallFailedExecution) + if evm.Config.Tracer != nil && evm.Config.Tracer.OnGasChange != nil { + evm.Config.Tracer.OnGasChange(contract.Gas.RegularGas, 0, tracing.GasChangeCallFailedExecution) + } } + return ret, address, contract.Gas.ExitFromErr(err, initialStateGas), err } - return ret, address, contract.Gas, err + // Either success, or pre-Homestead ErrCodeStoreOutOfGas (gas preserved). + // Both packaged as a success-form GasBudget. + return ret, address, contract.Gas.ExitSuccess(), err } // initNewContract runs a new contract's creation code, performs checks on the @@ -591,27 +613,43 @@ func (evm *EVM) initNewContract(contract *Contract, address common.Address) ([]b return ret, err } - // Check whether the max code size has been exceeded, assign err if the case. - if err := CheckMaxCodeSize(&evm.chainRules, uint64(len(ret))); err != nil { - return ret, err - } - + // Check prefix before gas calculation. // Reject code starting with 0xEF if EIP-3541 is enabled. if len(ret) >= 1 && ret[0] == 0xEF && evm.chainRules.IsLondon { return ret, ErrInvalidCode } - - if !evm.chainRules.IsEIP4762 { - createDataGas := uint64(len(ret)) * params.CreateDataGas - if !contract.UseGas(GasCosts{RegularGas: createDataGas}, evm.Config.Tracer, tracing.GasChangeCallCodeStorage) { + if evm.chainRules.IsEIP4762 { + consumed, wanted := evm.AccessEvents.CodeChunksRangeGas(address, 0, uint64(len(ret)), uint64(len(ret)), true, contract.Gas.RegularGas) + contract.chargeRegular(consumed, evm.Config.Tracer, tracing.GasChangeWitnessCodeChunk) + if len(ret) > 0 && (consumed < wanted) { + return ret, ErrCodeStoreOutOfGas + } + if err := CheckMaxCodeSize(&evm.chainRules, uint64(len(ret))); err != nil { + return ret, err + } + } else if evm.chainRules.IsAmsterdam { + // Check max code size BEFORE charging gas so over-max code + // does not consume state gas (which would inflate tx_state). + if err := CheckMaxCodeSize(&evm.chainRules, uint64(len(ret))); err != nil { + return ret, err + } + // Charge regular gas (hash cost) before state gas (code-deposit cost). + regularCost := toWordSize(uint64(len(ret))) * params.Keccak256WordGas + if !contract.chargeRegular(regularCost, evm.Config.Tracer, tracing.GasChangeCallCodeStorage) { + return ret, ErrCodeStoreOutOfGas + } + stateCost := uint64(len(ret)) * evm.Context.CostPerStateByte + if !contract.chargeState(stateCost, evm.Config.Tracer, tracing.GasChangeCallCodeStorage) { return ret, ErrCodeStoreOutOfGas } } else { - consumed, wanted := evm.AccessEvents.CodeChunksRangeGas(address, 0, uint64(len(ret)), uint64(len(ret)), true, contract.Gas.RegularGas) - contract.UseGas(GasCosts{RegularGas: consumed}, evm.Config.Tracer, tracing.GasChangeWitnessCodeChunk) - if len(ret) > 0 && (consumed < wanted) { + createDataCost := uint64(len(ret)) * params.CreateDataGas + if !contract.chargeRegular(createDataCost, evm.Config.Tracer, tracing.GasChangeCallCodeStorage) { return ret, ErrCodeStoreOutOfGas } + if err := CheckMaxCodeSize(&evm.chainRules, uint64(len(ret))); err != nil { + return ret, err + } } if len(ret) > 0 { @@ -621,7 +659,7 @@ func (evm *EVM) initNewContract(contract *Contract, address common.Address) ([]b } // Create creates a new contract using code as deployment code. -func (evm *EVM) Create(caller common.Address, code []byte, gas GasBudget, value *uint256.Int) (ret []byte, contractAddr common.Address, leftOverGas GasBudget, err error) { +func (evm *EVM) Create(caller common.Address, code []byte, gas GasBudget, value *uint256.Int) (ret []byte, contractAddr common.Address, result GasBudget, err error) { contractAddr = crypto.CreateAddress(caller, evm.StateDB.GetNonce(caller)) return evm.create(caller, code, gas, value, contractAddr, CREATE) } @@ -630,7 +668,7 @@ func (evm *EVM) Create(caller common.Address, code []byte, gas GasBudget, value // // The different between Create2 with Create is Create2 uses keccak256(0xff ++ msg.sender ++ salt ++ keccak256(init_code))[12:] // instead of the usual sender-and-nonce-hash as the address where the contract is initialized at. -func (evm *EVM) Create2(caller common.Address, code []byte, gas GasBudget, endowment *uint256.Int, salt *uint256.Int) (ret []byte, contractAddr common.Address, leftOverGas GasBudget, err error) { +func (evm *EVM) Create2(caller common.Address, code []byte, gas GasBudget, endowment *uint256.Int, salt *uint256.Int) (ret []byte, contractAddr common.Address, result GasBudget, err error) { inithash := crypto.Keccak256Hash(code) contractAddr = crypto.CreateAddress2(caller, salt.Bytes32(), inithash[:]) return evm.create(caller, code, gas, endowment, contractAddr, CREATE2) @@ -668,22 +706,20 @@ func (evm *EVM) resolveCodeHash(addr common.Address) common.Hash { // ChainConfig returns the environment's chain configuration func (evm *EVM) ChainConfig() *params.ChainConfig { return evm.chainConfig } -func (evm *EVM) captureBegin(depth int, typ OpCode, from common.Address, to common.Address, input []byte, startGas uint64, value *big.Int) { +func (evm *EVM) captureBegin(depth int, typ OpCode, from common.Address, to common.Address, input []byte, startGas GasBudget, value *big.Int) { tracer := evm.Config.Tracer if tracer.OnEnter != nil { - tracer.OnEnter(depth, byte(typ), from, to, input, startGas, value) + tracer.OnEnter(depth, byte(typ), from, to, input, startGas.RegularGas, value) } if tracer.HasGasHook() { - initial := NewGasBudget(startGas) - tracer.EmitGasChange(tracing.Gas{}, initial.AsTracing(), tracing.GasChangeCallInitialBalance) + tracer.EmitGasChange(tracing.Gas{}, startGas.AsTracing(), tracing.GasChangeCallInitialBalance) } } -func (evm *EVM) captureEnd(depth int, startGas uint64, leftOverGas uint64, ret []byte, err error) { +func (evm *EVM) captureEnd(depth int, startGas GasBudget, leftOverGas GasBudget, ret []byte, err error) { tracer := evm.Config.Tracer - if leftOverGas != 0 && tracer.HasGasHook() { - leftover := NewGasBudget(leftOverGas) - tracer.EmitGasChange(leftover.AsTracing(), tracing.Gas{}, tracing.GasChangeCallLeftOverReturned) + if !leftOverGas.IsZero() && tracer.HasGasHook() { + tracer.EmitGasChange(leftOverGas.AsTracing(), tracing.Gas{}, tracing.GasChangeCallLeftOverReturned) } var reverted bool if err != nil { @@ -693,7 +729,7 @@ func (evm *EVM) captureEnd(depth int, startGas uint64, leftOverGas uint64, ret [ reverted = false } if tracer.OnExit != nil { - tracer.OnExit(depth, ret, startGas-leftOverGas, VMErrorFromErr(err), reverted) + tracer.OnExit(depth, ret, startGas.RegularGas-leftOverGas.RegularGas, VMErrorFromErr(err), reverted) } } diff --git a/core/vm/gas_table.go b/core/vm/gas_table.go index 046311f9cc1f..dca4358fb2fa 100644 --- a/core/vm/gas_table.go +++ b/core/vm/gas_table.go @@ -291,10 +291,19 @@ var ( gasMLoad = pureMemoryGascost gasMStore8 = pureMemoryGascost gasMStore = pureMemoryGascost - gasCreate = pureMemoryGascost ) +func gasCreate(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { + if evm.readOnly { + return GasCosts{}, ErrWriteProtection + } + return pureMemoryGascost(evm, contract, stack, mem, memorySize) +} + func gasCreate2(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { + if evm.readOnly { + return GasCosts{}, ErrWriteProtection + } gas, err := memoryGasCost(mem, memorySize) if err != nil { return GasCosts{}, err @@ -313,6 +322,9 @@ func gasCreate2(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memoryS } func gasCreateEip3860(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { + if evm.readOnly { + return GasCosts{}, ErrWriteProtection + } gas, err := memoryGasCost(mem, memorySize) if err != nil { return GasCosts{}, err @@ -331,7 +343,11 @@ func gasCreateEip3860(evm *EVM, contract *Contract, stack *Stack, mem *Memory, m } return GasCosts{RegularGas: gas}, nil } + func gasCreate2Eip3860(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { + if evm.readOnly { + return GasCosts{}, ErrWriteProtection + } gas, err := memoryGasCost(mem, memorySize) if err != nil { return GasCosts{}, err @@ -352,7 +368,7 @@ func gasCreate2Eip3860(evm *EVM, contract *Contract, stack *Stack, mem *Memory, } func gasExpFrontier(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { - expByteLen := uint64((stack.back(1).BitLen() + 7) / 8) + expByteLen := uint64(((*stack.back(1)).BitLen() + 7) / 8) var ( gas = expByteLen * params.ExpByteFrontier // no overflow check required. Max is 256 * ExpByte gas @@ -365,7 +381,7 @@ func gasExpFrontier(evm *EVM, contract *Contract, stack *Stack, mem *Memory, mem } func gasExpEIP158(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { - expByteLen := uint64((stack.back(1).BitLen() + 7) / 8) + expByteLen := uint64(((*stack.back(1)).BitLen() + 7) / 8) var ( gas = expByteLen * params.ExpByteEIP158 // no overflow check required. Max is 256 * ExpByte gas @@ -384,17 +400,17 @@ var ( gasStaticCall = makeCallVariantGasCost(gasStaticCallIntrinsic) ) -func makeCallVariantGasCost(intrinsicFunc gasFunc) gasFunc { +func makeCallVariantGasCost(intrinsicFunc intrinsicGasFunc) gasFunc { return func(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { intrinsic, err := intrinsicFunc(evm, contract, stack, mem, memorySize) if err != nil { return GasCosts{}, err } - evm.callGasTemp, err = callGas(evm.chainRules.IsEIP150, contract.Gas.RegularGas, intrinsic.RegularGas, stack.back(0)) + evm.callGasTemp, err = callGas(evm.chainRules.IsEIP150, contract.Gas.RegularGas, intrinsic, stack.back(0)) if err != nil { return GasCosts{}, err } - gas, overflow := math.SafeAdd(intrinsic.RegularGas, evm.callGasTemp) + gas, overflow := math.SafeAdd(intrinsic, evm.callGasTemp) if overflow { return GasCosts{}, ErrGasUintOverflow } @@ -402,19 +418,19 @@ func makeCallVariantGasCost(intrinsicFunc gasFunc) gasFunc { } } -func gasCallIntrinsic(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { +func gasCallIntrinsic(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (uint64, error) { var ( gas uint64 transfersValue = !stack.back(2).IsZero() address = common.Address(stack.back(1).Bytes20()) ) if evm.readOnly && transfersValue { - return GasCosts{}, ErrWriteProtection + return 0, ErrWriteProtection } // Stateless check memoryGas, err := memoryGasCost(mem, memorySize) if err != nil { - return GasCosts{}, err + return 0, err } var transferGas uint64 if transfersValue && !evm.chainRules.IsEIP4762 { @@ -422,12 +438,12 @@ func gasCallIntrinsic(evm *EVM, contract *Contract, stack *Stack, mem *Memory, m } var overflow bool if gas, overflow = math.SafeAdd(memoryGas, transferGas); overflow { - return GasCosts{}, ErrGasUintOverflow + return 0, ErrGasUintOverflow } // Terminate the gas measurement if the leftover gas is not sufficient, // it can effectively prevent accessing the states in the following steps. if contract.Gas.RegularGas < gas { - return GasCosts{}, ErrOutOfGas + return 0, ErrOutOfGas } // Stateful check var stateGas uint64 @@ -439,56 +455,87 @@ func gasCallIntrinsic(evm *EVM, contract *Contract, stack *Stack, mem *Memory, m stateGas += params.CallNewAccountGas } if gas, overflow = math.SafeAdd(gas, stateGas); overflow { - return GasCosts{}, ErrGasUintOverflow + return 0, ErrGasUintOverflow } - return GasCosts{RegularGas: gas}, nil + return gas, nil } -func gasCallCodeIntrinsic(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { +// gasCallIntrinsic8037 is the intrinsic gas calculator for CALL in Amsterdam. +// It computes memory expansion + value transfer gas but excludes new account +// creation, which is handled as state gas by the wrapper. +func gasCallIntrinsic8037(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (uint64, error) { + var ( + gas uint64 + transfersValue = !stack.back(2).IsZero() + ) + if evm.readOnly && transfersValue { + return 0, ErrWriteProtection + } memoryGas, err := memoryGasCost(mem, memorySize) if err != nil { - return GasCosts{}, err + return 0, err + } + var transferGas uint64 + if transfersValue && !evm.chainRules.IsEIP4762 { + transferGas = params.CallValueTransferGas + } + var overflow bool + if gas, overflow = math.SafeAdd(memoryGas, transferGas); overflow { + return 0, ErrGasUintOverflow + } + return gas, nil +} + +func gasCallCodeIntrinsic(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (uint64, error) { + memoryGas, err := memoryGasCost(mem, memorySize) + if err != nil { + return 0, err } var ( - gas uint64 - overflow bool + gas uint64 + overflow bool + transfersValue = !stack.back(2).IsZero() ) - if stack.back(2).Sign() != 0 && !evm.chainRules.IsEIP4762 { - gas += params.CallValueTransferGas + if transfersValue { + if !evm.chainRules.IsEIP4762 { + gas += params.CallValueTransferGas + } } if gas, overflow = math.SafeAdd(gas, memoryGas); overflow { - return GasCosts{}, ErrGasUintOverflow + return 0, ErrGasUintOverflow } - return GasCosts{RegularGas: gas}, nil + return gas, nil } -func gasDelegateCallIntrinsic(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { +func gasDelegateCallIntrinsic(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (uint64, error) { gas, err := memoryGasCost(mem, memorySize) if err != nil { - return GasCosts{}, err + return 0, err } - return GasCosts{RegularGas: gas}, nil + return gas, nil } -func gasStaticCallIntrinsic(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { +func gasStaticCallIntrinsic(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (uint64, error) { gas, err := memoryGasCost(mem, memorySize) if err != nil { - return GasCosts{}, err + return 0, err } - return GasCosts{RegularGas: gas}, nil + return gas, nil } func gasSelfdestruct(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { if evm.readOnly { return GasCosts{}, ErrWriteProtection } - var gas uint64 // EIP150 homestead gas reprice fork: if evm.chainRules.IsEIP150 { gas = params.SelfdestructGasEIP150 - var address = common.Address(stack.back(0).Bytes20()) + if gas > contract.Gas.RegularGas { + return GasCosts{RegularGas: gas}, nil + } + var address = common.Address(stack.back(0).Bytes20()) if evm.chainRules.IsEIP158 { // if empty and transfers value if evm.StateDB.Empty(address) && evm.StateDB.GetBalance(contract.Address()).Sign() != 0 { @@ -504,3 +551,169 @@ func gasSelfdestruct(evm *EVM, contract *Contract, stack *Stack, mem *Memory, me } return GasCosts{RegularGas: gas}, nil } + +func gasCreateEip8037(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { + if evm.readOnly { + return GasCosts{}, ErrWriteProtection + } + gas, err := memoryGasCost(mem, memorySize) + if err != nil { + return GasCosts{}, err + } + size, overflow := stack.back(2).Uint64WithOverflow() + if overflow { + return GasCosts{}, ErrGasUintOverflow + } + if err := CheckMaxInitCodeSize(&evm.chainRules, size); err != nil { + return GasCosts{}, err + } + // Since size <= MaxInitCodeSizeAmsterdam, these multiplications cannot overflow + words := (size + 31) / 32 + wordGas := params.InitCodeWordGas * words + stateGas := params.AccountCreationSize * evm.Context.CostPerStateByte + return GasCosts{RegularGas: gas + wordGas, StateGas: stateGas}, nil +} + +func gasCreate2Eip8037(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { + if evm.readOnly { + return GasCosts{}, ErrWriteProtection + } + gas, err := memoryGasCost(mem, memorySize) + if err != nil { + return GasCosts{}, err + } + size, overflow := stack.back(2).Uint64WithOverflow() + if overflow { + return GasCosts{}, ErrGasUintOverflow + } + if err := CheckMaxInitCodeSize(&evm.chainRules, size); err != nil { + return GasCosts{}, err + } + // Since size <= MaxInitCodeSizeAmsterdam, these multiplications cannot overflow + words := (size + 31) / 32 + // CREATE2 charges both InitCodeWordGas (EIP-3860) and Keccak256WordGas (for address hashing). + wordGas := (params.InitCodeWordGas + params.Keccak256WordGas) * words + stateGas := params.AccountCreationSize * evm.Context.CostPerStateByte + return GasCosts{RegularGas: gas + wordGas, StateGas: stateGas}, nil +} + +// gasCall8037 is the stateful gas calculator for CALL in Amsterdam (EIP-8037). +// It only returns the state-dependent gas (account creation as state gas). +// Memory gas, transfer gas, and callGas are handled by gasCallStateless and +// makeCallVariantGasCall. +func gasCall8037(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { + var ( + gas GasCosts + transfersValue = !stack.back(2).IsZero() + address = common.Address(stack.back(1).Bytes20()) + ) + if evm.chainRules.IsEIP158 { + if transfersValue && evm.StateDB.Empty(address) { + gas.StateGas += params.AccountCreationSize * evm.Context.CostPerStateByte + } + } else if !evm.StateDB.Exist(address) { + gas.StateGas += params.AccountCreationSize * evm.Context.CostPerStateByte + } + return gas, nil +} + +func gasSelfdestruct8037(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { + if evm.readOnly { + return GasCosts{}, ErrWriteProtection + } + var ( + gas GasCosts + address = common.Address(stack.peek().Bytes20()) + ) + if !evm.StateDB.AddressInAccessList(address) { + // If the caller cannot afford the cost, this change will be rolled back + evm.StateDB.AddAddressToAccessList(address) + gas.RegularGas = params.ColdAccountAccessCostEIP2929 + } + // Check we have enough regular gas before we add the address to the BAL + if contract.Gas.RegularGas < gas.RegularGas { + return gas, nil + } + // if empty and transfers value + if evm.StateDB.Empty(address) && evm.StateDB.GetBalance(contract.Address()).Sign() != 0 { + gas.StateGas += params.AccountCreationSize * evm.Context.CostPerStateByte + } + return gas, nil +} + +func gasSStore8037(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { + if evm.readOnly { + return GasCosts{}, ErrWriteProtection + } + // If we fail the minimum gas availability invariant, fail (0) + if contract.Gas.RegularGas <= params.SstoreSentryGasEIP2200 { + return GasCosts{}, errors.New("not enough gas for reentrancy sentry") + } + // Gas sentry honoured, do the actual gas calculation based on the stored value + var ( + y, x = stack.back(1), stack.peek() + slot = common.Hash(x.Bytes32()) + current, original = evm.StateDB.GetStateAndCommittedState(contract.Address(), slot) + cost GasCosts + ) + // Check slot presence in the access list + if _, slotPresent := evm.StateDB.SlotInAccessList(contract.Address(), slot); !slotPresent { + cost = GasCosts{RegularGas: params.ColdSloadCostEIP2929} + // If the caller cannot afford the cost, this change will be rolled back + evm.StateDB.AddSlotToAccessList(contract.Address(), slot) + } + value := common.Hash(y.Bytes32()) + + if current == value { // noop (1) + // EIP 2200 original clause: + // return params.SloadGasEIP2200, nil + return GasCosts{RegularGas: cost.RegularGas + params.WarmStorageReadCostEIP2929}, nil // SLOAD_GAS + } + if original == current { + if original == (common.Hash{}) { // create slot (2.1.1) + // EIP-8037: Return both regular and state gas. System calls do not charge state gas. + var stateGas uint64 + if !contract.IsSystemCall { + stateGas = params.StorageCreationSize * evm.Context.CostPerStateByte + } + return GasCosts{ + RegularGas: cost.RegularGas + params.SstoreResetGasEIP2200 - params.ColdSloadCostEIP2929, + StateGas: stateGas, + }, nil + } + if value == (common.Hash{}) { // delete slot (2.1.2b) + evm.StateDB.AddRefund(params.SstoreClearsScheduleRefundEIP3529) + } + // EIP-2200 original clause: + // return params.SstoreResetGasEIP2200, nil // write existing slot (2.1.2) + return GasCosts{RegularGas: cost.RegularGas + params.SstoreResetGasEIP2200 - params.ColdSloadCostEIP2929}, nil // write existing slot (2.1.2) + } + if original != (common.Hash{}) { + if current == (common.Hash{}) { // recreate slot (2.2.1.1) + evm.StateDB.SubRefund(params.SstoreClearsScheduleRefundEIP3529) + } else if value == (common.Hash{}) { // delete slot (2.2.1.2) + evm.StateDB.AddRefund(params.SstoreClearsScheduleRefundEIP3529) + } + } + if original == value { + if original == (common.Hash{}) { // reset to original inexistent slot (2.2.2.1) + // EIP-8037 point (2): refund state gas directly to the reservoir + // at the SSTORE restoration point (0→x→0 in same tx); not to the + // refund counter, which is capped at gas_used/5. + contract.Gas.RefundState(params.StorageCreationSize * evm.Context.CostPerStateByte) + + // Regular portion of the refund still goes through the refund counter. + evm.StateDB.AddRefund(params.SstoreResetGasEIP2200 - params.ColdSloadCostEIP2929 - params.WarmStorageReadCostEIP2929) + } else { // reset to original existing slot (2.2.2.2) + // EIP 2200 Original clause: + // evm.StateDB.AddRefund(params.SstoreResetGasEIP2200 - params.SloadGasEIP2200) + // - SSTORE_RESET_GAS redefined as (5000 - COLD_SLOAD_COST) + // - SLOAD_GAS redefined as WARM_STORAGE_READ_COST + // Final: (5000 - COLD_SLOAD_COST) - WARM_STORAGE_READ_COST + evm.StateDB.AddRefund((params.SstoreResetGasEIP2200 - params.ColdSloadCostEIP2929) - params.WarmStorageReadCostEIP2929) + } + } + // EIP-2200 original clause: + //return params.SloadGasEIP2200, nil // dirty update (2.2) + return GasCosts{RegularGas: cost.RegularGas + params.WarmStorageReadCostEIP2929}, nil // dirty update (2.2) +} diff --git a/core/vm/gas_table_test.go b/core/vm/gas_table_test.go index 16ce651a7d32..803ef3110180 100644 --- a/core/vm/gas_table_test.go +++ b/core/vm/gas_table_test.go @@ -97,12 +97,12 @@ func TestEIP2200(t *testing.T) { Transfer: func(StateDB, common.Address, common.Address, *uint256.Int, *params.Rules) {}, } evm := NewEVM(vmctx, statedb, params.AllEthashProtocolChanges, Config{ExtraEips: []int{2200}}) - initialGas := NewGasBudget(tt.gaspool) - _, leftOver, err := evm.Call(common.Address{}, address, nil, initialGas.Copy(), new(uint256.Int)) + initialGas := NewGasBudget(tt.gaspool, 0) + _, result, err := evm.Call(common.Address{}, address, nil, initialGas.Copy(), new(uint256.Int)) if !errors.Is(err, tt.failure) { t.Errorf("test %d: failure mismatch: have %v, want %v", i, err, tt.failure) } - if used := leftOver.Used(initialGas); used != tt.used { + if used := result.Used(initialGas); used != tt.used { t.Errorf("test %d: gas used mismatch: have %v, want %v", i, used, tt.used) } if refund := evm.StateDB.GetRefund(); refund != tt.refund { @@ -157,12 +157,12 @@ func TestCreateGas(t *testing.T) { } evm := NewEVM(vmctx, statedb, chainConfig, config) - initialGas := NewGasBudget(uint64(testGas)) - ret, leftOver, err := evm.Call(common.Address{}, address, nil, initialGas.Copy(), new(uint256.Int)) + initialGas := NewGasBudget(uint64(testGas), 0) + ret, result, err := evm.Call(common.Address{}, address, nil, initialGas.Copy(), new(uint256.Int)) if err != nil { return false } - gasUsed = leftOver.Used(initialGas) + gasUsed = result.Used(initialGas) if len(ret) != 32 { t.Fatalf("test %d: expected 32 bytes returned, have %d", i, len(ret)) } diff --git a/core/vm/gascosts.go b/core/vm/gascosts.go index ed938ae41f9a..0865a0982d70 100644 --- a/core/vm/gascosts.go +++ b/core/vm/gascosts.go @@ -22,9 +22,8 @@ import ( "github.com/ethereum/go-ethereum/core/tracing" ) -// GasCosts denotes a vector of gas costs in the -// multidimensional metering paradigm. It represents the cost -// charged by an individual operation. +// GasCosts denotes a vector of gas costs in the multidimensional metering +// paradigm. It represents the cost charged by an individual operation. type GasCosts struct { RegularGas uint64 StateGas uint64 @@ -40,67 +39,263 @@ func (g GasCosts) String() string { return fmt.Sprintf("<%v,%v>", g.RegularGas, g.StateGas) } -// GasBudget denotes a vector of remaining gas allowances available -// for EVM execution in the multidimensional metering paradigm. -// Unlike GasCosts which represents the price of an operation, -// GasBudget tracks how much gas is left to spend. +// GasBudget is the unified gas-state structure used throughout the EVM. +// It carries two pairs of fields: +// +// - RegularGas / StateGas: the running balance during execution, or the +// leftover balance the caller must absorb after a sub-call. +// - UsedRegularGas / UsedStateGas: per-frame accumulators tracking gross +// consumption. UsedStateGas is signed so it can be decremented by inline +// state-gas refunds (e.g., SSTORE 0->A->0). +// +// The same struct serves three roles: +// +// - During execution: Charge / ChargeRegular / ChargeState / RefundState +// and RefundRegular mutate the running balance and the usage accumulators +// in lockstep. +// +// - At frame exit: Preserved / ExitSuccess / ExitRevert / ExitHalt / +// ExitFromErr produce a new GasBudget in "leftover" form that packages +// the result for the caller. +// +// - At absorption: the caller's Absorb method merges the child's leftover +// budget into its own running budget. type GasBudget struct { - RegularGas uint64 // The leftover gas for execution and state gas usage - StateGas uint64 // The state gas reservoir + RegularGas uint64 // remaining regular-gas balance (or leftover for caller to absorb) + StateGas uint64 // remaining state-gas reservoir (or leftover for caller to absorb) + UsedRegularGas uint64 // gross regular gas consumed in this frame + UsedStateGas int64 // signed net state-gas consumed in this frame } -// NewGasBudget creates a GasBudget with the given initial regular gas allowance. -func NewGasBudget(gas uint64) GasBudget { - return GasBudget{RegularGas: gas} +// NewGasBudget initializes a fresh GasBudget for execution / forwarding, +// with both usage accumulators set to zero. +func NewGasBudget(regular, state uint64) GasBudget { + return GasBudget{RegularGas: regular, StateGas: state} } -// Used returns the amount of regular gas consumed so far. +// Used returns the total scalar gas consumed relative to an initial budget +// (= (initial.regular + initial.state) − (current.regular + current.state)). +// This is the payment scalar (EIP-8037's tx_gas_used_before_refund). +// +// TODO(rjl493456442) the total used gas can be calculated via g.UsedRegularGas +// and g.UsedStateGas. func (g GasBudget) Used(initial GasBudget) uint64 { - return initial.RegularGas - g.RegularGas -} - -// Exhaust sets all remaining gas to zero, preserving the initial amount -// for usage tracking. -func (g *GasBudget) Exhaust() { - g.RegularGas = 0 - g.StateGas = 0 + return (initial.RegularGas + initial.StateGas) - (g.RegularGas + g.StateGas) } -func (g *GasBudget) Copy() GasBudget { - return GasBudget{RegularGas: g.RegularGas, StateGas: g.StateGas} +// Copy returns a deep copy of the budget. +func (g GasBudget) Copy() GasBudget { + return g } -// String returns a visual representation of the gas budget vector. +// String returns a visual representation of the budget. func (g GasBudget) String() string { - return fmt.Sprintf("<%v,%v>", g.RegularGas, g.StateGas) + return fmt.Sprintf("<%v,%v,used=<%v,%v>>", g.RegularGas, g.StateGas, g.UsedRegularGas, g.UsedStateGas) } -// CanAfford reports whether the budget has sufficient gas to cover the cost. +// CanAfford reports whether the running balance can cover the given cost. +// State-gas charges that exceed the reservoir spill into regular gas. func (g GasBudget) CanAfford(cost GasCosts) bool { - return g.RegularGas >= cost.RegularGas + if g.RegularGas < cost.RegularGas { + return false + } + if cost.StateGas > g.StateGas { + spillover := cost.StateGas - g.StateGas + if spillover > g.RegularGas-cost.RegularGas { + return false + } + } + return true } -// Charge deducts the given gas cost from the budget. It returns the -// pre-charge budget and false if the budget does not have sufficient -// gas to cover the cost. +// Charge deducts a combined regular+state cost from the running balance and +// updates the usage accumulators. State-gas in excess of the reservoir spills +// into regular_gas. func (g *GasBudget) Charge(cost GasCosts) (GasBudget, bool) { prior := *g - if g.RegularGas < cost.RegularGas { + if !g.CanAfford(cost) { return prior, false } + // Charge regular gas g.RegularGas -= cost.RegularGas - return prior, true -} + g.UsedRegularGas += cost.RegularGas -// Refund adds the given gas budget back. It returns the pre-refund budget -// and whether the budget was actually changed. -func (g *GasBudget) Refund(other GasBudget) (GasBudget, bool) { - prior := *g - g.RegularGas += other.RegularGas - return prior, g.RegularGas != prior.RegularGas + // Charge state gas + if cost.StateGas > g.StateGas { + spillover := cost.StateGas - g.StateGas + g.StateGas = 0 + g.RegularGas -= spillover + } else { + g.StateGas -= cost.StateGas + } + g.UsedStateGas += int64(cost.StateGas) + return prior, true } // AsTracing converts the GasBudget into the tracing-facing Gas vector. func (g GasBudget) AsTracing() tracing.Gas { return tracing.Gas{Regular: g.RegularGas, State: g.StateGas} } + +// ChargeRegular is a convenience that deducts a regular-only cost. +func (g *GasBudget) ChargeRegular(r uint64) (GasBudget, bool) { + return g.Charge(GasCosts{RegularGas: r}) +} + +// ChargeState is a convenience that deducts a state-only cost (spills to +// regular when the reservoir is exhausted). Returns false on OOG. +func (g *GasBudget) ChargeState(s uint64) (GasBudget, bool) { + return g.Charge(GasCosts{StateGas: s}) +} + +// IsZero returns an indicator if the gas budget has been exhausted. +func (g *GasBudget) IsZero() bool { + return g.RegularGas == 0 && g.StateGas == 0 +} + +// RefundState applies an inline state-gas refund (e.g., SSTORE 0->A->0). +// The reservoir is credited and the signed usage counter is decremented +// in lockstep, preserving the per-frame invariant: +// +// StateGas + UsedStateGas == initialStateGas + spillover_so_far +// +// which the revert path relies on for the correct gross refund. +func (g *GasBudget) RefundState(s uint64) { + g.StateGas += s + g.UsedStateGas -= int64(s) +} + +// RefundRegular applies an inline regular-gas refund. +func (g *GasBudget) RefundRegular(s uint64) { + g.RegularGas += s + g.UsedRegularGas -= s +} + +// Forward drains `regular` regular gas and the entire state reservoir from +// the parent's running budget and returns the initial GasBudget for a child +// frame. The parent's UsedRegularGas is bumped by the forwarded amount so +// that the absorb-on-return path correctly reclaims the unused portion. +// +// Used by frame boundaries where the regular forward has NOT been pre- +// deducted: tx-level dispatch (state_transition) and CREATE / CREATE2. The +// CALL family pre-deducts the forward via the dynamic gas table for tracer- +// reporting reasons and therefore constructs its child budget directly. +// +// Caller must ensure `regular` does not exceed the running balance and +// apply any EIP-150 1/64 retention before calling Forward. +func (g *GasBudget) Forward(regular uint64) GasBudget { + g.RegularGas -= regular + g.UsedRegularGas += regular + + child := GasBudget{ + RegularGas: regular, + StateGas: g.StateGas, + } + g.StateGas = 0 + return child +} + +// ForwardAll forwards the parent's full remaining budget (both regular and +// state) to a child frame. Equivalent to Forward(g.RegularGas) — used at +// the tx boundary where there is no 1/64 retention. +func (g *GasBudget) ForwardAll() GasBudget { + return g.Forward(g.RegularGas) +} + +// ============================================================================ +// Exit-form constructors. These take a post-execution running budget and +// produce a new GasBudget in "leftover form" — the value the caller should +// absorb to update its own state. +// ============================================================================ + +// Preserved produces a leftover form with the running balance preserved and +// usage zeroed. Use this for pre-execution validation failures (depth, +// balance, EIP-158 zero-value-to-nonexistent) where no execution actually +// occurred. +func (g GasBudget) Preserved() GasBudget { + return GasBudget{ + RegularGas: g.RegularGas, + StateGas: g.StateGas, + // UsedRegularGas / UsedStateGas stay at zero. + } +} + +// ExitSuccess produces the leftover form for a successful frame. Inline +// state-gas refunds have already been folded into StateGas / UsedStateGas +// during execution; the running budget IS the exit budget on success. +func (g GasBudget) ExitSuccess() GasBudget { + return g +} + +// ExitRevert produces the leftover form for a REVERT exit. Per EIP-8037, +// the gross state-gas charged in the failing subtree is refunded to the +// caller's reservoir via the signed-counter formula +// +// leftover.StateGas = StateGas + UsedStateGas +// +// and UsedStateGas is zeroed because the state effect is routed through +// StateGas. UsedRegularGas is unchanged. +func (g GasBudget) ExitRevert() GasBudget { + reservoir := int64(g.StateGas) + g.UsedStateGas + if reservoir < 0 { + // Defensive: invariant guarantees non-negativity. Clamping prevents + // a hypothetical bug from sign-extending to a huge uint64. + reservoir = 0 + } + return GasBudget{ + RegularGas: g.RegularGas, + StateGas: uint64(reservoir), + UsedRegularGas: g.UsedRegularGas, + UsedStateGas: 0, + } +} + +// ExitHalt produces the leftover form for an exceptional halt. Remaining +// regular gas is accounted as burned; the reservoir resets to initialStateGas +// (the value the caller held at the call site). UsedStateGas is zeroed +// because state-gas effects do not persist on halt. +func (g GasBudget) ExitHalt(initialStateGas uint64) GasBudget { + return GasBudget{ + RegularGas: 0, + StateGas: initialStateGas, + UsedRegularGas: g.UsedRegularGas + g.RegularGas, + UsedStateGas: 0, + } +} + +// ExitFromErr dispatches on err to the appropriate exit-form constructor +// for the post-evm.Run path: +// +// - err == nil → ExitSuccess +// - err == ErrExecutionReverted → ExitRevert +// - any other err → ExitHalt +// +// Soft validation failures (occurring BEFORE evm.Run) should call Preserved +// directly instead of going through this dispatcher. +func (g GasBudget) ExitFromErr(err error, initialStateGas uint64) GasBudget { + switch { + case err == nil: + return g.ExitSuccess() + case err == ErrExecutionReverted: + return g.ExitRevert() + default: + return g.ExitHalt(initialStateGas) + } +} + +// Absorb merges a sub-call's leftover GasBudget into this (caller's) running +// budget. The caller's UsedRegularGas is reclaimed by the unused forwarded +// regular gas (which was pre-charged in full at call entry); the state +// reservoir is overwritten with the child's leftover; and the child's signed +// net state-gas usage is added to the caller's accumulator. +// +// Invariant maintained by all callers: at the moment of this call, the +// caller's UsedRegularGas already accounts for the FULL forwarded regular +// gas (as if the child had consumed all of it). On halt, child.RegularGas +// is 0 so the reclaim is a no-op. +func (g *GasBudget) Absorb(child GasBudget) { + g.UsedRegularGas -= child.RegularGas + g.RegularGas += child.RegularGas + g.StateGas = child.StateGas + g.UsedStateGas += child.UsedStateGas +} diff --git a/core/vm/instructions.go b/core/vm/instructions.go index 4b05092cc799..0f5274857c87 100644 --- a/core/vm/instructions.go +++ b/core/vm/instructions.go @@ -647,25 +647,21 @@ func opSwap16(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { } func opCreate(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { - if evm.readOnly { - return nil, ErrWriteProtection - } var ( value = scope.Stack.pop() offset, size = scope.Stack.pop(), scope.Stack.pop() input = scope.Memory.GetCopy(offset.Uint64(), size.Uint64()) - gas = scope.Contract.Gas.RegularGas + forward = scope.Contract.Gas.RegularGas ) if evm.chainRules.IsEIP150 { - gas -= gas / 64 + forward -= forward / 64 } // reuse size int for stackvalue stackvalue := size - scope.Contract.UseGas(GasCosts{RegularGas: gas}, evm.Config.Tracer, tracing.GasChangeCallContractCreation) - - res, addr, returnGas, suberr := evm.Create(scope.Contract.Address(), input, NewGasBudget(gas), &value) + child := scope.Contract.forwardGas(forward, evm.Config.Tracer, tracing.GasChangeCallContractCreation) + res, addr, result, suberr := evm.Create(scope.Contract.Address(), input, child, &value) // Push item on the stack based on the returned error. If the ruleset is // homestead we must check for CodeStoreOutOfGasError (homestead only // rule) and treat as an error, if the ruleset is frontier we must @@ -679,8 +675,10 @@ func opCreate(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { } scope.Stack.push(&stackvalue) - scope.Contract.RefundGas(returnGas, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) - + scope.Contract.RefundGas(result, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + if evm.chainRules.IsAmsterdam && suberr != nil { + scope.Contract.Gas.RefundState(params.AccountCreationSize * evm.Context.CostPerStateByte) + } if suberr == ErrExecutionReverted { evm.returnData = res // set REVERT data to return data buffer return res, nil @@ -690,24 +688,21 @@ func opCreate(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { } func opCreate2(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { - if evm.readOnly { - return nil, ErrWriteProtection - } var ( endowment = scope.Stack.pop() offset, size = scope.Stack.pop(), scope.Stack.pop() salt = scope.Stack.pop() input = scope.Memory.GetCopy(offset.Uint64(), size.Uint64()) - gas = scope.Contract.Gas.RegularGas + forward = scope.Contract.Gas.RegularGas ) // Apply EIP150 - gas -= gas / 64 - scope.Contract.UseGas(GasCosts{RegularGas: gas}, evm.Config.Tracer, tracing.GasChangeCallContractCreation2) + forward -= forward / 64 + // reuse size int for stackvalue stackvalue := size - res, addr, returnGas, suberr := evm.Create2(scope.Contract.Address(), input, NewGasBudget(gas), - &endowment, &salt) + child := scope.Contract.forwardGas(forward, evm.Config.Tracer, tracing.GasChangeCallContractCreation2) + res, addr, result, suberr := evm.Create2(scope.Contract.Address(), input, child, &endowment, &salt) // Push item on the stack based on the returned error. if suberr != nil { stackvalue.Clear() @@ -715,7 +710,10 @@ func opCreate2(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { stackvalue.SetBytes(addr.Bytes()) } scope.Stack.push(&stackvalue) - scope.Contract.RefundGas(returnGas, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + scope.Contract.RefundGas(result, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + if evm.chainRules.IsAmsterdam && suberr != nil { + scope.Contract.Gas.RefundState(params.AccountCreationSize * evm.Context.CostPerStateByte) + } if suberr == ErrExecutionReverted { evm.returnData = res // set REVERT data to return data buffer @@ -743,7 +741,12 @@ func opCall(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { if !value.IsZero() { gas += params.CallStipend } - ret, returnGas, err := evm.Call(scope.Contract.Address(), toAddr, args, NewGasBudget(gas), &value) + + // Regular gas for the forward was already pre-deducted by the dynamic + // gas table (see makeCallVariantGasCallEIP*); only the state reservoir + // needs to be handed off to the child here. + childBudget := NewGasBudget(gas, scope.Contract.Gas.StateGas) + ret, result, err := evm.Call(scope.Contract.Address(), toAddr, args, childBudget, &value) if err != nil { temp.Clear() @@ -755,7 +758,7 @@ func opCall(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { scope.Memory.Set(retOffset.Uint64(), retSize.Uint64(), ret) } - scope.Contract.RefundGas(returnGas, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + scope.Contract.RefundGas(result, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) evm.returnData = ret return ret, nil @@ -777,7 +780,8 @@ func opCallCode(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { gas += params.CallStipend } - ret, returnGas, err := evm.CallCode(scope.Contract.Address(), toAddr, args, NewGasBudget(gas), &value) + childBudget := NewGasBudget(gas, scope.Contract.Gas.StateGas) + ret, result, err := evm.CallCode(scope.Contract.Address(), toAddr, args, childBudget, &value) if err != nil { temp.Clear() } else { @@ -788,7 +792,7 @@ func opCallCode(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { scope.Memory.Set(retOffset.Uint64(), retSize.Uint64(), ret) } - scope.Contract.RefundGas(returnGas, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + scope.Contract.RefundGas(result, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) evm.returnData = ret return ret, nil @@ -806,7 +810,8 @@ func opDelegateCall(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { // Get arguments from the memory. args := scope.Memory.GetPtr(inOffset.Uint64(), inSize.Uint64()) - ret, returnGas, err := evm.DelegateCall(scope.Contract.Caller(), scope.Contract.Address(), toAddr, args, NewGasBudget(gas), scope.Contract.value) + childBudget := NewGasBudget(gas, scope.Contract.Gas.StateGas) + ret, result, err := evm.DelegateCall(scope.Contract.Caller(), scope.Contract.Address(), toAddr, args, childBudget, scope.Contract.value) if err != nil { temp.Clear() } else { @@ -816,8 +821,7 @@ func opDelegateCall(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { if err == nil || err == ErrExecutionReverted { scope.Memory.Set(retOffset.Uint64(), retSize.Uint64(), ret) } - - scope.Contract.RefundGas(returnGas, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + scope.Contract.RefundGas(result, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) evm.returnData = ret return ret, nil @@ -835,7 +839,8 @@ func opStaticCall(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { // Get arguments from the memory. args := scope.Memory.GetPtr(inOffset.Uint64(), inSize.Uint64()) - ret, returnGas, err := evm.StaticCall(scope.Contract.Address(), toAddr, args, NewGasBudget(gas)) + childBudget := NewGasBudget(gas, scope.Contract.Gas.StateGas) + ret, result, err := evm.StaticCall(scope.Contract.Address(), toAddr, args, childBudget) if err != nil { temp.Clear() } else { @@ -846,7 +851,7 @@ func opStaticCall(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { scope.Memory.Set(retOffset.Uint64(), retSize.Uint64(), ret) } - scope.Contract.RefundGas(returnGas, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + scope.Contract.RefundGas(result, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) evm.returnData = ret return ret, nil diff --git a/core/vm/interface.go b/core/vm/interface.go index a9938c2a2873..8c67a553c48b 100644 --- a/core/vm/interface.go +++ b/core/vm/interface.go @@ -71,6 +71,14 @@ type StateDB interface { // during the current transaction. IsNewContract(addr common.Address) bool + // SameTxSelfDestructs returns addresses that were created and then + // self-destructed in the current transaction. + SameTxSelfDestructs() []common.Address + + // NewStorageSlotCount returns the number of storage slots written to a + // non-zero value in the current transaction on the given address. + NewStorageSlotCount(addr common.Address) int + // Empty returns whether the given account is empty. Empty // is defined according to EIP161 (balance = nonce = code = 0). Empty(common.Address) bool diff --git a/core/vm/interpreter.go b/core/vm/interpreter.go index 399432724772..ca33670163a9 100644 --- a/core/vm/interpreter.go +++ b/core/vm/interpreter.go @@ -174,7 +174,7 @@ func (evm *EVM) Run(contract *Contract, input []byte, readOnly bool) (ret []byte // associated costs. contractAddr := contract.Address() consumed, wanted := evm.TxContext.AccessEvents.CodeChunksRangeGas(contractAddr, pc, 1, uint64(len(contract.Code)), false, contract.Gas.RegularGas) - contract.UseGas(GasCosts{RegularGas: consumed}, evm.Config.Tracer, tracing.GasChangeWitnessCodeChunk) + contract.chargeRegular(consumed, evm.Config.Tracer, tracing.GasChangeWitnessCodeChunk) if consumed < wanted { return nil, ErrOutOfGas } @@ -192,10 +192,8 @@ func (evm *EVM) Run(contract *Contract, input []byte, readOnly bool) (ret []byte return nil, &ErrStackOverflow{stackLen: sLen, limit: operation.maxStack} } // for tracing: this gas consumption event is emitted below in the debug section. - if contract.Gas.RegularGas < cost { + if !contract.chargeRegular(cost, nil, tracing.GasChangeIgnored) { return nil, ErrOutOfGas - } else { - contract.Gas.RegularGas -= cost } // All ops with a dynamic memory usage also has a dynamic gas cost. @@ -224,11 +222,13 @@ func (evm *EVM) Run(contract *Contract, input []byte, readOnly bool) (ret []byte if err != nil { return nil, fmt.Errorf("%w: %v", ErrOutOfGas, err) } - // for tracing: this gas consumption event is emitted below in the debug section. - if contract.Gas.RegularGas < dynamicCost.RegularGas { + // EIP-8037: charge regular gas before state gas. The state charge + // is a no-op when dynamicCost.StateGas == 0 (e.g., pre-Amsterdam). + if !contract.chargeRegular(dynamicCost.RegularGas, nil, tracing.GasChangeIgnored) { + return nil, ErrOutOfGas + } + if !contract.chargeState(dynamicCost.StateGas, nil, tracing.GasChangeIgnored) { return nil, ErrOutOfGas - } else { - contract.Gas.RegularGas -= dynamicCost.RegularGas } } diff --git a/core/vm/interpreter_test.go b/core/vm/interpreter_test.go index 868cb12d04e3..42530b83b72f 100644 --- a/core/vm/interpreter_test.go +++ b/core/vm/interpreter_test.go @@ -55,7 +55,7 @@ func TestLoopInterrupt(t *testing.T) { timeout := make(chan bool) go func(evm *EVM) { - _, _, err := evm.Call(common.Address{}, address, nil, NewGasBudget(math.MaxUint64), new(uint256.Int)) + _, _, err := evm.Call(common.Address{}, address, nil, NewGasBudget(math.MaxUint64, 0), new(uint256.Int)) errChannel <- err }(evm) @@ -85,7 +85,7 @@ func BenchmarkInterpreter(b *testing.B) { value = uint256.NewInt(0) stack = newStackForTesting() mem = NewMemory() - contract = NewContract(common.Address{}, common.Address{}, value, NewGasBudget(startGas), nil) + contract = NewContract(common.Address{}, common.Address{}, value, NewGasBudget(startGas, 0), nil) ) stack.push(uint256.NewInt(123)) stack.push(uint256.NewInt(123)) diff --git a/core/vm/jump_table.go b/core/vm/jump_table.go index 82fc43ec1327..9ea8349e3ab3 100644 --- a/core/vm/jump_table.go +++ b/core/vm/jump_table.go @@ -23,8 +23,9 @@ import ( ) type ( - executionFunc func(pc *uint64, evm *EVM, callContext *ScopeContext) ([]byte, error) - gasFunc func(*EVM, *Contract, *Stack, *Memory, uint64) (GasCosts, error) // last parameter is the requested memory size as a uint64 + executionFunc func(pc *uint64, evm *EVM, callContext *ScopeContext) ([]byte, error) + gasFunc func(*EVM, *Contract, *Stack, *Memory, uint64) (GasCosts, error) // last parameter is the requested memory size as a uint64 + intrinsicGasFunc func(*EVM, *Contract, *Stack, *Memory, uint64) (uint64, error) // last parameter is the requested memory size as a uint64 // memorySizeFunc returns the required size, and whether the operation overflowed a uint64 memorySizeFunc func(*Stack) (size uint64, overflow bool) ) @@ -97,6 +98,7 @@ func newAmsterdamInstructionSet() JumpTable { instructionSet := newOsakaInstructionSet() enable7843(&instructionSet) // EIP-7843 (SLOTNUM opcode) enable8024(&instructionSet) // EIP-8024 (Backward compatible SWAPN, DUPN, EXCHANGE) + enable8037(&instructionSet) // EIP-8037 (State creation gas cost increase) return validate(instructionSet) } diff --git a/core/vm/operations_acl.go b/core/vm/operations_acl.go index 86ac262a9349..6ddb54c94aee 100644 --- a/core/vm/operations_acl.go +++ b/core/vm/operations_acl.go @@ -168,7 +168,7 @@ func makeCallVariantGasCallEIP2929(oldCalculator gasFunc, addressPosition int) g evm.StateDB.AddAddressToAccessList(addr) // Charge the remaining difference here already, to correctly calculate available // gas for call - if !contract.UseGas(GasCosts{RegularGas: coldCost}, evm.Config.Tracer, tracing.GasChangeCallStorageColdAccess) { + if !contract.chargeRegular(coldCost, evm.Config.Tracer, tracing.GasChangeCallStorageColdAccess) { return GasCosts{}, ErrOutOfGas } } @@ -245,6 +245,10 @@ func makeSelfdestructGasFn(refundsEnabled bool) gasFunc { return GasCosts{}, ErrOutOfGas } } + if gas > contract.Gas.RegularGas { + return GasCosts{RegularGas: gas}, nil + } + // if empty and transfers value if evm.StateDB.Empty(address) && evm.StateDB.GetBalance(contract.Address()).Sign() != 0 { gas += params.CreateBySelfdestructGas @@ -262,6 +266,8 @@ var ( gasDelegateCallEIP7702 = makeCallVariantGasCallEIP7702(gasDelegateCallIntrinsic) gasStaticCallEIP7702 = makeCallVariantGasCallEIP7702(gasStaticCallIntrinsic) gasCallCodeEIP7702 = makeCallVariantGasCallEIP7702(gasCallCodeIntrinsic) + + innerGasCallEIP8037 = makeCallVariantGasCallEIP8037(gasCallIntrinsic8037, gasCall8037) ) func gasCallEIP7702(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { @@ -276,7 +282,15 @@ func gasCallEIP7702(evm *EVM, contract *Contract, stack *Stack, mem *Memory, mem return innerGasCallEIP7702(evm, contract, stack, mem, memorySize) } -func makeCallVariantGasCallEIP7702(intrinsicFunc gasFunc) gasFunc { +func gasCallEIP8037(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { + transfersValue := !stack.back(2).IsZero() + if evm.readOnly && transfersValue { + return GasCosts{}, ErrWriteProtection + } + return innerGasCallEIP8037(evm, contract, stack, mem, memorySize) +} + +func makeCallVariantGasCallEIP7702(intrinsicFunc intrinsicGasFunc) gasFunc { return func(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { var ( eip2929Cost uint64 @@ -295,7 +309,7 @@ func makeCallVariantGasCallEIP7702(intrinsicFunc gasFunc) gasFunc { // Charge the remaining difference here already, to correctly calculate // available gas for call - if !contract.UseGas(GasCosts{RegularGas: eip2929Cost}, evm.Config.Tracer, tracing.GasChangeCallStorageColdAccess) { + if !contract.chargeRegular(eip2929Cost, evm.Config.Tracer, tracing.GasChangeCallStorageColdAccess) { return GasCosts{}, ErrOutOfGas } } @@ -312,7 +326,7 @@ func makeCallVariantGasCallEIP7702(intrinsicFunc gasFunc) gasFunc { // Terminate the gas measurement if the leftover gas is not sufficient, // it can effectively prevent accessing the states in the following steps. // It's an essential safeguard before any stateful check. - if contract.Gas.RegularGas < intrinsicCost.RegularGas { + if contract.Gas.RegularGas < intrinsicCost { return GasCosts{}, ErrOutOfGas } @@ -324,13 +338,13 @@ func makeCallVariantGasCallEIP7702(intrinsicFunc gasFunc) gasFunc { evm.StateDB.AddAddressToAccessList(target) eip7702Cost = params.ColdAccountAccessCostEIP2929 } - if !contract.UseGas(GasCosts{RegularGas: eip7702Cost}, evm.Config.Tracer, tracing.GasChangeCallStorageColdAccess) { + if !contract.chargeRegular(eip7702Cost, evm.Config.Tracer, tracing.GasChangeCallStorageColdAccess) { return GasCosts{}, ErrOutOfGas } } // Calculate the gas budget for the nested call. The costs defined by // EIP-2929 and EIP-7702 have already been applied. - evm.callGasTemp, err = callGas(evm.chainRules.IsEIP150, contract.Gas.RegularGas, intrinsicCost.RegularGas, stack.back(0)) + evm.callGasTemp, err = callGas(evm.chainRules.IsEIP150, contract.Gas.RegularGas, intrinsicCost, stack.back(0)) if err != nil { return GasCosts{}, err } @@ -339,6 +353,10 @@ func makeCallVariantGasCallEIP7702(intrinsicFunc gasFunc) gasFunc { // part of the dynamic gas. This will ensure it is correctly reported to // tracers. contract.Gas.RegularGas += eip2929Cost + eip7702Cost + // Undo the RegularGasUsed increments from the direct UseGas charges, + // since this gas will be re-charged via the returned cost. + contract.Gas.UsedRegularGas -= eip2929Cost + contract.Gas.UsedRegularGas -= eip7702Cost // Aggregate the gas costs from all components, including EIP-2929, EIP-7702, // the CALL opcode itself, and the cost incurred by nested calls. @@ -349,7 +367,94 @@ func makeCallVariantGasCallEIP7702(intrinsicFunc gasFunc) gasFunc { if totalCost, overflow = math.SafeAdd(eip2929Cost, eip7702Cost); overflow { return GasCosts{}, ErrGasUintOverflow } - if totalCost, overflow = math.SafeAdd(totalCost, intrinsicCost.RegularGas); overflow { + if totalCost, overflow = math.SafeAdd(totalCost, intrinsicCost); overflow { + return GasCosts{}, ErrGasUintOverflow + } + if totalCost, overflow = math.SafeAdd(totalCost, evm.callGasTemp); overflow { + return GasCosts{}, ErrGasUintOverflow + } + return GasCosts{RegularGas: totalCost}, nil + } +} + +// makeCallVariantGasCallEIP8037 creates a call gas function for Amsterdam (EIP-8037). +// It extends the EIP-7702 pattern with state gas handling and GasUsed tracking. +// intrinsicFunc computes the regular gas (memory + transfer, no new account creation). +// stateGasFunc computes the state gas (new account creation as state gas). +func makeCallVariantGasCallEIP8037(intrinsicFunc intrinsicGasFunc, stateGasFunc gasFunc) gasFunc { + return func(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { + var ( + eip2929Cost uint64 + eip7702Cost uint64 + addr = common.Address(stack.back(1).Bytes20()) + ) + // EIP-2929 cold access check. + if !evm.StateDB.AddressInAccessList(addr) { + evm.StateDB.AddAddressToAccessList(addr) + eip2929Cost = params.ColdAccountAccessCostEIP2929 - params.WarmStorageReadCostEIP2929 + if !contract.chargeRegular(eip2929Cost, evm.Config.Tracer, tracing.GasChangeCallStorageColdAccess) { + return GasCosts{}, ErrOutOfGas + } + } + + // Compute intrinsic cost (memory + transfer, no new account creation). + intrinsicCost, err := intrinsicFunc(evm, contract, stack, mem, memorySize) + if err != nil { + return GasCosts{}, err + } + + // Charge intrinsic cost directly (regular gas). This must happen + // BEFORE state gas to prevent reservoir inflation, and also serves + // as the OOG guard before stateful operations. + if !contract.chargeRegular(intrinsicCost, evm.Config.Tracer, tracing.GasChangeCallOpCode) { + return GasCosts{}, ErrOutOfGas + } + + // EIP-7702 delegation check. + if target, ok := types.ParseDelegation(evm.StateDB.GetCode(addr)); ok { + if evm.StateDB.AddressInAccessList(target) { + eip7702Cost = params.WarmStorageReadCostEIP2929 + } else { + evm.StateDB.AddAddressToAccessList(target) + eip7702Cost = params.ColdAccountAccessCostEIP2929 + } + if !contract.chargeRegular(eip7702Cost, evm.Config.Tracer, tracing.GasChangeCallStorageColdAccess) { + return GasCosts{}, ErrOutOfGas + } + } + + // Compute and charge state gas (new account creation) AFTER regular gas. + stateGas, err := stateGasFunc(evm, contract, stack, mem, memorySize) + if err != nil { + return GasCosts{}, err + } + if stateGas.StateGas > 0 { + // Charge updates contract.Gas.UsedStateGas in lockstep. + if _, ok := contract.Gas.Charge(GasCosts{StateGas: stateGas.StateGas}); !ok { + return GasCosts{}, ErrOutOfGas + } + } + + // Calculate the gas budget for the nested call (63/64 rule). + evm.callGasTemp, err = callGas(evm.chainRules.IsEIP150, contract.Gas.RegularGas, 0, stack.back(0)) + if err != nil { + return GasCosts{}, err + } + + // Temporarily undo direct regular charges for tracer reporting. + // The interpreter will charge the returned totalCost. + contract.Gas.RegularGas += eip2929Cost + eip7702Cost + intrinsicCost + contract.Gas.UsedRegularGas -= eip2929Cost + eip7702Cost + intrinsicCost + + // Aggregate total cost. + var ( + overflow bool + totalCost uint64 + ) + if totalCost, overflow = math.SafeAdd(eip2929Cost, eip7702Cost); overflow { + return GasCosts{}, ErrGasUintOverflow + } + if totalCost, overflow = math.SafeAdd(totalCost, intrinsicCost); overflow { return GasCosts{}, ErrGasUintOverflow } if totalCost, overflow = math.SafeAdd(totalCost, evm.callGasTemp); overflow { diff --git a/core/vm/runtime/runtime.go b/core/vm/runtime/runtime.go index 4fafdf3a5049..34dec1bd4a7d 100644 --- a/core/vm/runtime/runtime.go +++ b/core/vm/runtime/runtime.go @@ -144,15 +144,15 @@ func Execute(code, input []byte, cfg *Config) ([]byte, *state.StateDB, error) { // set the receiver's (the executing contract) code for execution. cfg.State.SetCode(address, code, tracing.CodeChangeUnspecified) // Call the code with the given configuration. - ret, leftOverGas, err := vmenv.Call( + ret, result, err := vmenv.Call( cfg.Origin, common.BytesToAddress([]byte("contract")), input, - vm.NewGasBudget(cfg.GasLimit), + vm.NewGasBudget(cfg.GasLimit, 0), uint256.MustFromBig(cfg.Value), ) if cfg.EVMConfig.Tracer != nil && cfg.EVMConfig.Tracer.OnTxEnd != nil { - cfg.EVMConfig.Tracer.OnTxEnd(&types.Receipt{GasUsed: cfg.GasLimit - leftOverGas.RegularGas}, err) + cfg.EVMConfig.Tracer.OnTxEnd(&types.Receipt{GasUsed: cfg.GasLimit - result.RegularGas}, err) } return ret, cfg.State, err } @@ -179,16 +179,16 @@ func Create(input []byte, cfg *Config) ([]byte, common.Address, uint64, error) { // - reset transient storage(eip 1153) cfg.State.Prepare(rules, cfg.Origin, cfg.Coinbase, nil, vm.ActivePrecompiles(rules), nil) // Call the code with the given configuration. - code, address, leftOverGas, err := vmenv.Create( + code, address, result, err := vmenv.Create( cfg.Origin, input, - vm.NewGasBudget(cfg.GasLimit), + vm.NewGasBudget(cfg.GasLimit, 0), uint256.MustFromBig(cfg.Value), ) if cfg.EVMConfig.Tracer != nil && cfg.EVMConfig.Tracer.OnTxEnd != nil { - cfg.EVMConfig.Tracer.OnTxEnd(&types.Receipt{GasUsed: cfg.GasLimit - leftOverGas.RegularGas}, err) + cfg.EVMConfig.Tracer.OnTxEnd(&types.Receipt{GasUsed: cfg.GasLimit - result.RegularGas}, err) } - return code, address, leftOverGas.RegularGas, err + return code, address, result.RegularGas, err } // Call executes the code given by the contract's address. It will return the @@ -213,15 +213,15 @@ func Call(address common.Address, input []byte, cfg *Config) ([]byte, uint64, er statedb.Prepare(rules, cfg.Origin, cfg.Coinbase, &address, vm.ActivePrecompiles(rules), nil) // Call the code with the given configuration. - ret, leftOverGas, err := vmenv.Call( + ret, result, err := vmenv.Call( cfg.Origin, address, input, - vm.NewGasBudget(cfg.GasLimit), + vm.NewGasBudget(cfg.GasLimit, 0), uint256.MustFromBig(cfg.Value), ) if cfg.EVMConfig.Tracer != nil && cfg.EVMConfig.Tracer.OnTxEnd != nil { - cfg.EVMConfig.Tracer.OnTxEnd(&types.Receipt{GasUsed: cfg.GasLimit - leftOverGas.RegularGas}, err) + cfg.EVMConfig.Tracer.OnTxEnd(&types.Receipt{GasUsed: cfg.GasLimit - result.RegularGas}, err) } - return ret, leftOverGas.RegularGas, err + return ret, result.RegularGas, err } diff --git a/eth/tracers/js/tracer_test.go b/eth/tracers/js/tracer_test.go index 6570d735755c..2fefa46492d6 100644 --- a/eth/tracers/js/tracer_test.go +++ b/eth/tracers/js/tracer_test.go @@ -55,7 +55,7 @@ func runTrace(tracer *tracers.Tracer, vmctx *vmContext, chaincfg *params.ChainCo gasLimit uint64 = 31000 startGas uint64 = 10000 value = uint256.NewInt(0) - contract = vm.NewContract(common.Address{}, common.Address{}, value, vm.NewGasBudget(startGas), nil) + contract = vm.NewContract(common.Address{}, common.Address{}, value, vm.NewGasBudget(startGas, 0), nil) ) evm.SetTxContext(vmctx.txCtx) contract.Code = []byte{byte(vm.PUSH1), 0x1, byte(vm.PUSH1), 0x1, 0x0} diff --git a/eth/tracers/logger/logger_test.go b/eth/tracers/logger/logger_test.go index decdf588e133..73868d22e031 100644 --- a/eth/tracers/logger/logger_test.go +++ b/eth/tracers/logger/logger_test.go @@ -47,7 +47,7 @@ func TestStoreCapture(t *testing.T) { var ( logger = NewStructLogger(nil) evm = vm.NewEVM(vm.BlockContext{}, &dummyStatedb{}, params.TestChainConfig, vm.Config{Tracer: logger.Hooks()}) - contract = vm.NewContract(common.Address{}, common.Address{}, new(uint256.Int), vm.NewGasBudget(100000), nil) + contract = vm.NewContract(common.Address{}, common.Address{}, new(uint256.Int), vm.NewGasBudget(100000, 0), nil) ) contract.Code = []byte{byte(vm.PUSH1), 0x1, byte(vm.PUSH1), 0x0, byte(vm.SSTORE)} var index common.Hash diff --git a/params/protocol_params.go b/params/protocol_params.go index 3e36b8354751..b7dcd0b81bab 100644 --- a/params/protocol_params.go +++ b/params/protocol_params.go @@ -88,6 +88,7 @@ const ( LogTopicGas uint64 = 375 // Multiplied by the * of the LOG*, per LOG transaction. e.g. LOG0 incurs 0 * c_txLogTopicGas, LOG4 incurs 4 * c_txLogTopicGas. CreateGas uint64 = 32000 // Once per CREATE operation & contract-creation transaction. Create2Gas uint64 = 32000 // Once per CREATE2 operation + CreateGasAmsterdam uint64 = 9000 // Regular gas portion of CREATE in Amsterdam (EIP-8037); state gas is charged separately. CreateNGasEip4762 uint64 = 1000 // Once per CREATEn operations post-verkle SelfdestructRefundGas uint64 = 24000 // Refunded following a selfdestruct operation. MemoryGas uint64 = 3 // Times the address of the (highest referenced byte in memory + 1). NOTE: referencing happens on read, write and in instructions such as RETURN and CALL. @@ -100,6 +101,7 @@ const ( TxAccessListAddressGas uint64 = 2400 // Per address specified in EIP 2930 access list TxAccessListStorageKeyGas uint64 = 1900 // Per storage key specified in EIP 2930 access list TxAuthTupleGas uint64 = 12500 // Per auth tuple code specified in EIP-7702 + TxAuthTupleRegularGas uint64 = 7500 // Per auth tuple regular gas specified in EIP-8037 // These have been changed during the course of the chain CallGasFrontier uint64 = 40 // Once per CALL operation & message call transaction. @@ -196,6 +198,11 @@ const ( // the bound has a small safety margin for system-contract accesses that // don't consume block gas. BALItemCost uint64 = 2000 + + AccountCreationSize = 112 + StorageCreationSize = 32 + AuthorizationCreationSize = 23 + CostPerStateByte = 1174 ) // Bls12381G1MultiExpDiscountTable is the gas discount table for BLS12-381 G1 multi exponentiation operation diff --git a/tests/state_test.go b/tests/state_test.go index cf1d4bce4c90..f6ae90c21f48 100644 --- a/tests/state_test.go +++ b/tests/state_test.go @@ -325,10 +325,10 @@ func runBenchmark(b *testing.B, t *StateTest) { b.StartTimer() start := time.Now() - initialGas := vm.NewGasBudget(msg.GasLimit) + initialGas := vm.NewGasBudget(msg.GasLimit, 0) // Execute the message. - _, leftOverGas, err := evm.Call(sender.Address(), *msg.To, msg.Data, initialGas.Copy(), msg.Value) + _, result, err := evm.Call(sender.Address(), *msg.To, msg.Data, initialGas.Copy(), msg.Value) if err != nil { b.Error(err) return @@ -337,7 +337,7 @@ func runBenchmark(b *testing.B, t *StateTest) { b.StopTimer() elapsed += uint64(time.Since(start)) refund += state.StateDB.GetRefund() - gasUsed += leftOverGas.Used(initialGas) + gasUsed += result.Used(initialGas) state.StateDB.RevertToSnapshot(snapshot) } diff --git a/tests/transaction_test_util.go b/tests/transaction_test_util.go index 572c109f1ea4..88745dabaa59 100644 --- a/tests/transaction_test_util.go +++ b/tests/transaction_test_util.go @@ -80,8 +80,8 @@ func (tt *TransactionTest) Run() error { if err != nil { return } - // Intrinsic gas - cost, err := core.IntrinsicGas(tx.Data(), tx.AccessList(), tx.SetCodeAuthorizations(), tx.To() == nil, rules.IsHomestead, rules.IsIstanbul, rules.IsShanghai, rules.IsAmsterdam) + // Intrinsic cost + cost, err := core.IntrinsicGas(tx.Data(), tx.AccessList(), tx.SetCodeAuthorizations(), tx.To() == nil, rules, 0) if err != nil { return } From 5963fc8408aba77d4b4fe1aaf5faa38ac1c1effe Mon Sep 17 00:00:00 2001 From: Gary Rong Date: Mon, 18 May 2026 11:19:25 +0800 Subject: [PATCH 02/17] core: improve EIP-8037 implementations --- cmd/evm/internal/t8ntool/transaction.go | 3 +- core/bench_test.go | 3 +- core/chain_makers.go | 2 +- core/evm.go | 11 +- core/gaspool.go | 21 +- core/state/statedb.go | 28 --- core/state/statedb_hooked.go | 8 - core/state_processor.go | 30 ++- core/state_transition.go | 285 ++++++++++++++---------- core/tracing/hooks.go | 4 + core/txpool/validation.go | 12 +- core/vm/contract.go | 14 +- core/vm/contracts.go | 2 +- core/vm/evm.go | 69 +++--- core/vm/gas_table.go | 41 ++-- core/vm/gascosts.go | 6 +- core/vm/instructions.go | 33 ++- core/vm/interface.go | 8 - core/vm/jump_table.go | 3 + core/vm/operations_acl.go | 23 +- params/protocol_params.go | 7 +- tests/transaction_test_util.go | 2 +- 22 files changed, 330 insertions(+), 285 deletions(-) diff --git a/cmd/evm/internal/t8ntool/transaction.go b/cmd/evm/internal/t8ntool/transaction.go index 26ae11e0fb81..9eb1bdbf5f0d 100644 --- a/cmd/evm/internal/t8ntool/transaction.go +++ b/cmd/evm/internal/t8ntool/transaction.go @@ -133,8 +133,7 @@ func Transaction(ctx *cli.Context) error { } // Check intrinsic gas rules := chainConfig.Rules(common.Big0, true, 0) - gasCostPerStateByte := core.CostPerStateByte(&types.Header{}, chainConfig) - cost, err := core.IntrinsicGas(tx.Data(), tx.AccessList(), tx.SetCodeAuthorizations(), tx.To() == nil, rules, gasCostPerStateByte) + cost, err := core.IntrinsicGas(tx.Data(), tx.AccessList(), tx.SetCodeAuthorizations(), tx.To() == nil, rules, params.CostPerStateByte) if err != nil { r.Error = err results = append(results, r) diff --git a/core/bench_test.go b/core/bench_test.go index d49062af06c5..fe66aeae0d37 100644 --- a/core/bench_test.go +++ b/core/bench_test.go @@ -89,8 +89,7 @@ func genValueTx(nbytes int) func(int, *BlockGen) { data := make([]byte, nbytes) return func(i int, gen *BlockGen) { toaddr := common.Address{} - gasCostPerStateByte := CostPerStateByte(gen.header, gen.cm.config) - cost, _ := IntrinsicGas(data, nil, nil, false, params.Rules{}, gasCostPerStateByte) + cost, _ := IntrinsicGas(data, nil, nil, false, params.Rules{}, params.CostPerStateByte) signer := gen.Signer() gasPrice := big.NewInt(0) if gen.header.BaseFee != nil { diff --git a/core/chain_makers.go b/core/chain_makers.go index cb78739882b8..2e856b5161d3 100644 --- a/core/chain_makers.go +++ b/core/chain_makers.go @@ -168,7 +168,7 @@ func (b *BlockGen) AddTxWithChain(bc *BlockChain, tx *types.Transaction) { // been set, the block's coinbase is set to the zero address. // The evm interpreter can be customized with the provided vm config. func (b *BlockGen) AddTxWithVMConfig(tx *types.Transaction, config vm.Config) { - b.addTx(&BlockChain{chainConfig: b.cm.config}, config, tx) + b.addTx(nil, config, tx) } // GetBalance returns the balance of the given address at the generated block. diff --git a/core/evm.go b/core/evm.go index 6e3938da5b67..fdea63e46910 100644 --- a/core/evm.go +++ b/core/evm.go @@ -80,19 +80,10 @@ func NewEVMBlockContext(header *types.Header, chain ChainContext, author *common GasLimit: header.GasLimit, Random: random, SlotNum: slotNum, - CostPerStateByte: CostPerStateByte(header, chain.Config()), + CostPerStateByte: params.CostPerStateByte, } } -// CostPerStateByte computes the cost per one byte of state creation -// after EIP-8037. -func CostPerStateByte(header *types.Header, config *params.ChainConfig) uint64 { - if !config.IsAmsterdam(header.Number, header.Time) { - return 0 - } - return params.CostPerStateByte -} - // NewEVMTxContext creates a new transaction context for a single transaction. func NewEVMTxContext(msg *Message) vm.TxContext { ctx := vm.TxContext{ diff --git a/core/gaspool.go b/core/gaspool.go index 82323aafc42e..2fb6416795dc 100644 --- a/core/gaspool.go +++ b/core/gaspool.go @@ -42,9 +42,9 @@ func NewGasPool(amount uint64) *GasPool { } } -// SubGas deducts the given amount from the pool if enough gas is +// CheckGasLegacy deducts the given amount from the pool if enough gas is // available and returns an error otherwise. -func (gp *GasPool) SubGas(amount uint64) error { +func (gp *GasPool) CheckGasLegacy(amount uint64) error { if gp.remaining < amount { return ErrGasLimitReached } @@ -65,31 +65,24 @@ func (gp *GasPool) CheckGasAmsterdam(regularReservation, stateReservation uint64 return nil } -// ReturnGas adds the refunded gas back to the pool and updates +// ChargeGasLegacy adds the refunded gas back to the pool and updates // the cumulative gas usage accordingly. -func (gp *GasPool) ReturnGas(returned uint64, gasUsed uint64) error { +func (gp *GasPool) ChargeGasLegacy(returned uint64, gasUsed uint64) error { if gp.remaining > math.MaxUint64-returned { return fmt.Errorf("%w: remaining: %d, returned: %d", ErrGasLimitOverflow, gp.remaining, returned) } - // The returned gas calculation differs across forks. - // - // - Pre-Amsterdam: - // returned = purchased - remaining (refund included) - // - // - Post-Amsterdam: - // returned = purchased - gasUsed (refund excluded) + // returned = purchased - remaining (refund included) gp.remaining += returned // gasUsed = max(txGasUsed - gasRefund, calldataFloorGasCost) - // regardless of Amsterdam is activated or not. gp.cumulativeUsed += gasUsed return nil } // ChargeGasAmsterdam calculates the new remaining gas in the pool after the // execution of a message. Previously we subtracted and re-added gas to the -// gaspool. After Amsterdam we only check if we can include the transaction and charge the -// gaspool at the end. +// gaspool. After Amsterdam we only check if we can include the transaction +// and charge the gaspool at the end. func (gp *GasPool) ChargeGasAmsterdam(txRegular, txState, receiptGasUsed uint64) error { gp.cumulativeRegular += txRegular gp.cumulativeState += txState diff --git a/core/state/statedb.go b/core/state/statedb.go index 5e25cce5c8ec..1c49d460206d 100644 --- a/core/state/statedb.go +++ b/core/state/statedb.go @@ -683,34 +683,6 @@ func (s *StateDB) IsNewContract(addr common.Address) bool { return obj.newContract } -// SameTxSelfDestructs returns the addresses that were both created and -// self-destructed in the current transaction (EIP-6780). -func (s *StateDB) SameTxSelfDestructs() []common.Address { - var out []common.Address - for addr, obj := range s.stateObjects { - if obj.newContract && obj.selfDestructed { - out = append(out, addr) - } - } - return out -} - -// NewStorageSlotCount returns the number of storage slots that were written -// to a non-zero value in the current transaction on the given account. -func (s *StateDB) NewStorageSlotCount(addr common.Address) int { - obj, ok := s.stateObjects[addr] - if !ok { - return 0 - } - var count int - for _, v := range obj.dirtyStorage { - if v != (common.Hash{}) { - count++ - } - } - return count -} - // Copy creates a deep, independent copy of the state. // Snapshots of the copied state cannot be applied to the copy. func (s *StateDB) Copy() *StateDB { diff --git a/core/state/statedb_hooked.go b/core/state/statedb_hooked.go index 8b4475158398..98d01343a45e 100644 --- a/core/state/statedb_hooked.go +++ b/core/state/statedb_hooked.go @@ -59,14 +59,6 @@ func (s *hookedStateDB) IsNewContract(addr common.Address) bool { return s.inner.IsNewContract(addr) } -func (s *hookedStateDB) SameTxSelfDestructs() []common.Address { - return s.inner.SameTxSelfDestructs() -} - -func (s *hookedStateDB) NewStorageSlotCount(addr common.Address) int { - return s.inner.NewStorageSlotCount(addr) -} - func (s *hookedStateDB) GetBalance(addr common.Address) *uint256.Int { return s.inner.GetBalance(addr) } diff --git a/core/state_processor.go b/core/state_processor.go index 9af1682ce8e8..d0ce45078ee8 100644 --- a/core/state_processor.go +++ b/core/state_processor.go @@ -277,9 +277,15 @@ func ProcessBeaconBlockRoot(beaconRoot common.Hash, evm *vm.EVM, blockAccessList defer tracer.OnSystemCallEnd() } } + // SYSTEM_MAX_SSTORES_PER_CALL = 16 is the upper bound on the number of + // new storage slots a single system call is expected to write. + // + // This value matches MAX_WITHDRAWAL_REQUESTS_PER_BLOCK (EIP-7002), the + // largest per-block bound across the existing system contracts. + stateBudget := params.SystemMaxSStoresPerCall * evm.Context.CostPerStateByte * params.StorageCreationSize msg := &Message{ From: params.SystemAddress, - GasLimit: 30_000_000, + GasLimit: 30_000_000 + stateBudget, GasPrice: uint256.NewInt(0), GasFeeCap: uint256.NewInt(0), GasTipCap: uint256.NewInt(0), @@ -290,7 +296,7 @@ func ProcessBeaconBlockRoot(beaconRoot common.Hash, evm *vm.EVM, blockAccessList evm.StateDB.Prepare(evm.GetRules(), common.Address{}, common.Address{}, nil, nil, nil) evm.StateDB.SetTxContext(common.Hash{}, 0, 0) evm.StateDB.AddAddressToAccessList(params.BeaconRootsAddress) - _, _, _ = evm.Call(msg.From, *msg.To, msg.Data, vm.NewGasBudget(30_000_000, 0), common.U2560) + _, _, _ = evm.Call(msg.From, *msg.To, msg.Data, vm.NewGasBudget(30_000_000, stateBudget), common.U2560) if evm.StateDB.AccessEvents() != nil { evm.StateDB.AccessEvents().Merge(evm.AccessEvents) } @@ -306,9 +312,15 @@ func ProcessParentBlockHash(prevHash common.Hash, evm *vm.EVM, blockAccessList * defer tracer.OnSystemCallEnd() } } + // SYSTEM_MAX_SSTORES_PER_CALL = 16 is the upper bound on the number of + // new storage slots a single system call is expected to write. + // + // This value matches MAX_WITHDRAWAL_REQUESTS_PER_BLOCK (EIP-7002), the + // largest per-block bound across the existing system contracts. + stateBudget := params.SystemMaxSStoresPerCall * evm.Context.CostPerStateByte * params.StorageCreationSize msg := &Message{ From: params.SystemAddress, - GasLimit: 30_000_000, + GasLimit: 30_000_000 + stateBudget, GasPrice: uint256.NewInt(0), GasFeeCap: uint256.NewInt(0), GasTipCap: uint256.NewInt(0), @@ -319,7 +331,7 @@ func ProcessParentBlockHash(prevHash common.Hash, evm *vm.EVM, blockAccessList * evm.StateDB.Prepare(evm.GetRules(), common.Address{}, common.Address{}, nil, nil, nil) evm.StateDB.SetTxContext(common.Hash{}, 0, 0) evm.StateDB.AddAddressToAccessList(params.HistoryStorageAddress) - _, _, err := evm.Call(msg.From, *msg.To, msg.Data, vm.NewGasBudget(30_000_000, 0), common.U2560) + _, _, err := evm.Call(msg.From, *msg.To, msg.Data, vm.NewGasBudget(30_000_000, stateBudget), common.U2560) if err != nil { panic(err) } @@ -348,9 +360,15 @@ func processRequestsSystemCall(requests *[][]byte, rules params.Rules, evm *vm.E defer tracer.OnSystemCallEnd() } } + // SYSTEM_MAX_SSTORES_PER_CALL = 16 is the upper bound on the number of + // new storage slots a single system call is expected to write. + // + // This value matches MAX_WITHDRAWAL_REQUESTS_PER_BLOCK (EIP-7002), the + // largest per-block bound across the existing system contracts. + stateBudget := params.SystemMaxSStoresPerCall * evm.Context.CostPerStateByte * params.StorageCreationSize msg := &Message{ From: params.SystemAddress, - GasLimit: 30_000_000, + GasLimit: 30_000_000 + stateBudget, GasPrice: uint256.NewInt(0), GasFeeCap: uint256.NewInt(0), GasTipCap: uint256.NewInt(0), @@ -360,7 +378,7 @@ func processRequestsSystemCall(requests *[][]byte, rules params.Rules, evm *vm.E evm.StateDB.Prepare(rules, common.Address{}, common.Address{}, nil, nil, nil) evm.StateDB.SetTxContext(common.Hash{}, 0, blockAccessIndex) evm.StateDB.AddAddressToAccessList(addr) - ret, _, err := evm.Call(msg.From, *msg.To, msg.Data, vm.NewGasBudget(30_000_000, 0), common.U2560) + ret, _, err := evm.Call(msg.From, *msg.To, msg.Data, vm.NewGasBudget(30_000_000, stateBudget), common.U2560) if evm.StateDB.AccessEvents() != nil { evm.StateDB.AccessEvents().Merge(evm.AccessEvents) } diff --git a/core/state_transition.go b/core/state_transition.go index 7ac66b69f1a0..b026bbcbf97f 100644 --- a/core/state_transition.go +++ b/core/state_transition.go @@ -27,6 +27,7 @@ import ( "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/core/vm" "github.com/ethereum/go-ethereum/crypto/kzg4844" + "github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/params" "github.com/holiman/uint256" ) @@ -375,6 +376,24 @@ func (st *stateTransition) to() common.Address { return *st.msg.To } +// buyGas pre-pays gas from the sender's balance and initializes the +// transaction's gas budget. It is invoked at the tail of preCheck. +// +// The balance requirement is the worst-case ETH the tx may need to lock +// up: `msg.GasLimit × max(msg.GasPrice, msg.GasFeeCap) + msg.Value`, +// plus `blobGas × msg.BlobGasFeeCap` under Cancun. Insufficient balance +// returns ErrInsufficientFunds. After the check, the sender is actually +// debited `msg.GasLimit × msg.GasPrice` (plus `blobGas × blobBaseFee` +// under Cancun), the cap-vs-tip differential is settled at tx end. +// +// The gas budget is seeded into both `initialBudget` (frozen snapshot +// for tx-end accounting) and `gasRemaining` (live running balance): +// +// - Pre-Amsterdam: one-dimensional regular budget equal to +// `msg.GasLimit`; the state-gas reservoir is zero. +// - Amsterdam+ (EIP-8037): two-dimensional budget. Regular gas is +// capped at `MaxTxGas` (EIP-7825, 16_777_216); any excess from +// `msg.GasLimit` above that cap becomes the state-gas reservoir. func (st *stateTransition) buyGas() error { mgval := new(uint256.Int).SetUint64(st.msg.GasLimit) _, overflow := mgval.MulOverflow(mgval, st.msg.GasPrice) @@ -428,7 +447,7 @@ func (st *stateTransition) buyGas() error { return fmt.Errorf("%w: address %v have %v want %v", ErrInsufficientFunds, st.msg.From.Hex(), have, want) } - // After Amsterdam we limit the regular gas to 16k, the data gas to the transaction limit + // After Amsterdam we limit the regular gas to 16M, the data gas to the transaction limit limit := st.msg.GasLimit if st.evm.ChainConfig().IsAmsterdam(st.evm.Context.BlockNumber, st.evm.Context.Time) { limit = min(st.msg.GasLimit, params.MaxTxGas) @@ -437,13 +456,32 @@ func (st *stateTransition) buyGas() error { st.gasRemaining = st.initialBudget.Copy() if st.evm.Config.Tracer.HasGasHook() { - empty := vm.GasBudget{} - st.evm.Config.Tracer.EmitGasChange(empty.AsTracing(), st.gasRemaining.AsTracing(), tracing.GasChangeTxInitialBalance) + st.evm.Config.Tracer.EmitGasChange(tracing.Gas{}, st.gasRemaining.AsTracing(), tracing.GasChangeTxInitialBalance) } + // Deduct the gas cost from the sender's balance st.state.SubBalance(st.msg.From, mgval, tracing.BalanceDecreaseGasBuy) return nil } +// preCheck performs all pre-execution validation that does not require +// the EVM to run, then ends by calling buyGas to lock in the gas budget. +// It returns a consensus error if any of the following fail: +// +// - Sender nonce matches state and is not at 2^64-1 (EIP-2681). +// - EIP-7825 per-tx gas-limit cap on Osaka chains pre-Amsterdam +// (the cap also bounds the regular dimension after Amsterdam, but +// it is enforced there via the two-dimensional budget in buyGas). +// - EIP-3607 sender-is-EOA, allowing accounts whose only code is an +// EIP-7702 delegation designator. +// - EIP-1559 fee-cap, tip-cap and base-fee constraints (London+). +// - Blob-tx structural checks: non-nil `To`, non-empty hash list, +// valid KZG versioned hashes, count below `BlobTxMaxBlobs` (Osaka+). +// - Blob fee-cap not below the current blob base fee (Cancun+). +// - EIP-7702 set-code-tx shape: non-nil `To` and non-empty +// authorization list. +// +// The SkipNonceChecks / SkipTransactionChecks / NoBaseFee flags bypass +// subsets of these checks for simulation paths (eth_call, eth_estimateGas). func (st *stateTransition) preCheck() error { // Only check transactions that are not fake msg := st.msg @@ -461,8 +499,10 @@ func (st *stateTransition) preCheck() error { msg.From.Hex(), stNonce) } } - isOsaka := st.evm.ChainConfig().IsOsaka(st.evm.Context.BlockNumber, st.evm.Context.Time) - isAmsterdam := st.evm.ChainConfig().IsAmsterdam(st.evm.Context.BlockNumber, st.evm.Context.Time) + var ( + isOsaka = st.evm.ChainConfig().IsOsaka(st.evm.Context.BlockNumber, st.evm.Context.Time) + isAmsterdam = st.evm.ChainConfig().IsAmsterdam(st.evm.Context.BlockNumber, st.evm.Context.Time) + ) if !msg.SkipTransactionChecks { // Verify tx gas limit does not exceed EIP-7825 cap. if !isAmsterdam && isOsaka && msg.GasLimit > params.MaxTxGas { @@ -539,28 +579,72 @@ func (st *stateTransition) preCheck() error { return st.buyGas() } -// execute will transition the state by applying the current message and -// returning the evm execution result with following fields. +// reserveBlockGasBudget checks if the remaining gas budget in the block pool is +// sufficient for including this transaction. +func (st *stateTransition) reserveBlockGasBudget(rules params.Rules, gasLimit uint64, intrinsicCost vm.GasCosts) error { + var err error + if rules.IsAmsterdam { + // EIP-8037 per-tx 2D block-inclusion check. For each dimension, + // the worst-case contribution is tx.gas minus the other + // dimension's intrinsic (capped at MaxTxGas for the regular + // dimension). + regularReservation := gasLimit + if regularReservation > intrinsicCost.StateGas { + regularReservation -= intrinsicCost.StateGas + } else { + regularReservation = 0 + } + regularReservation = min(regularReservation, params.MaxTxGas) + + stateReservation := gasLimit + if stateReservation > intrinsicCost.RegularGas { + stateReservation -= intrinsicCost.RegularGas + } else { + stateReservation = 0 + } + err = st.gp.CheckGasAmsterdam(regularReservation, stateReservation) + } else { + err = st.gp.CheckGasLegacy(gasLimit) + } + return err +} + +// execute transitions the state by applying the current message and +// returns the EVM execution result with the following fields: // -// - used gas: total gas used (including gas being refunded) -// - returndata: the returned data from evm -// - concrete execution error: various EVM errors which abort the execution, e.g. -// ErrOutOfGas, ErrExecutionReverted +// - used gas: total gas used, including gas refunded +// - peak used gas: maximum gas used before applying refunds +// - returndata: data returned by the EVM +// - execution error: EVM-level errors that abort execution, such as +// ErrOutOfGas or ErrExecutionReverted // -// However if any consensus issue encountered, return the error directly with -// nil evm execution result. +// If a consensus error is encountered, it is returned directly with a +// nil EVM execution result. func (st *stateTransition) execute() (*ExecutionResult, error) { - // First check this message satisfies all consensus rules before - // applying the message. The rules include these clauses + // The state-transition pipeline below runs in stages. Each stage may + // abort with a consensus error before the EVM is invoked: // - // 1. the nonce of the message caller is correct - // 2. caller has enough balance to cover transaction fee(gaslimit * gasprice) - // 3. the amount of gas required is available in the block - // 4. the purchased gas is enough to cover intrinsic usage - // 5. there is no overflow when calculating intrinsic gas - // 6. caller has enough balance to cover asset transfer for **topmost** call - - // Check clauses 1-3, buy gas if everything is correct + // 1. preCheck: nonce, fee-cap, blob and EIP-7702 structural + // checks; ends by calling buyGas to debit the + // sender and seed the two-dimensional gas budget + // (EIP-8037). + // 2. Intrinsic: charges the intrinsic regular + state cost from + // the running budget with overflow detection. + // 3. Block pool: per-dimension inclusion reservation against the + // block gas pool (two-dimensional after Amsterdam, + // EIP-8037). + // 4. Floor pre: EIP-7623 calldata floor must fit in the gas allowance. + // 5. Top-call: run the top-most call, ensuring sender can cover + // the value transfer of the top call frame; init-code + // size respects the cap. + // + // After the EVM has run, the result path applies EIP-8037 state-gas + // refunds, the EIP-3529 regular-refund cap, and the EIP-7623 scalar + // floor (`tx_gas_used = max(tx_gas_used_after_refund, floor)`), + // returns leftover gas to the sender, settles the block pool and + // pays the coinbase tip. + + // Stage 1: validate the message and pre-pay gas. if err := st.preCheck(); err != nil { return nil, err } @@ -572,7 +656,9 @@ func (st *stateTransition) execute() (*ExecutionResult, error) { floorDataGas uint64 ) - // Check clauses 4-5, subtract intrinsic gas if everything is correct + // Stage 2: charge intrinsic gas (with overflow detection inside + // IntrinsicGas). Under Amsterdam the cost is two-dimensional and + // Charge debits both regular and state in one step. cost, err := IntrinsicGas(msg.Data, msg.AccessList, msg.SetCodeAuthorizations, contractCreation, rules, st.evm.Context.CostPerStateByte) if err != nil { return nil, err @@ -585,63 +671,34 @@ func (st *stateTransition) execute() (*ExecutionResult, error) { st.evm.Config.Tracer.EmitGasChange(prior.AsTracing(), st.gasRemaining.AsTracing(), tracing.GasChangeTxIntrinsicGas) } - // Check if we have enough gas in the block - if rules.IsAmsterdam { - // EIP-8037 per-tx 2D block-inclusion check. For each dimension, - // the worst-case contribution is tx.gas minus the other - // dimension's intrinsic (capped at MaxTxGas for the regular - // dimension). - regularReservation := msg.GasLimit - if regularReservation > cost.StateGas { - regularReservation -= cost.StateGas - } else { - regularReservation = 0 - } - regularReservation = min(regularReservation, params.MaxTxGas) - - stateReservation := msg.GasLimit - if stateReservation > cost.RegularGas { - stateReservation -= cost.RegularGas - } else { - stateReservation = 0 - } - if err := st.gp.CheckGasAmsterdam(regularReservation, stateReservation); err != nil { - return nil, err - } - } else { - if err := st.gp.SubGas(msg.GasLimit); err != nil { - return nil, err - } + // Stage 3: reserve this tx's share of the block gas pool. Under + // Amsterdam this is a two-dimensional per-tx inclusion check; before + // Amsterdam it is a single scalar subtraction. + if err := st.reserveBlockGasBudget(rules, msg.GasLimit, cost); err != nil { + return nil, err } - // Compute the floor data cost (EIP-7623), needed for both Prague and Amsterdam validation. + // Stage 4: validate the EIP-7623 calldata floor against the gas limit. + // The floor inflates the total gas usage at tx end, so the gas limit + // must be sufficient to cover that. if rules.IsPrague { floorDataGas, err = FloorDataGas(rules, msg.Data, msg.AccessList) if err != nil { return nil, err } + // Make sure the transaction has sufficient gas allowance to + // pay the floor cost. if msg.GasLimit < floorDataGas { return nil, fmt.Errorf("%w: have %d, want %d", ErrIntrinsicGas, msg.GasLimit, floorDataGas) } - } - - if rules.IsAmsterdam { - if cost.Sum() > msg.GasLimit { - return nil, fmt.Errorf("%w: have %d, want %d", ErrIntrinsicGas, msg.GasLimit, cost.Sum()) - } - // RegularGas must by < 16M - maxRegularGas := max(cost.RegularGas, floorDataGas) - if maxRegularGas > params.MaxTxGas { - return nil, fmt.Errorf("%w: max regular gas %d exceeds limit %d", ErrIntrinsicGas, maxRegularGas, params.MaxTxGas) + // In Amsterdam, the transaction gas limit is allowed to exceed + // params.MaxTxGas, but the calldata floor cost is capped by it. + if rules.IsAmsterdam { + if max(cost.RegularGas, floorDataGas) > params.MaxTxGas { + return nil, fmt.Errorf("%w: regular intrisic cost %v, floor: %v", ErrFloorDataGas, cost.RegularGas, floorDataGas) + } } } - before, ok := st.gasRemaining.Charge(cost) - if !ok { - return nil, fmt.Errorf("%w: have %d, want %d", ErrIntrinsicGas, st.gasRemaining.RegularGas, cost.RegularGas) - } - if st.evm.Config.Tracer.HasGasHook() { - st.evm.Config.Tracer.EmitGasChange(before.AsTracing(), st.gasRemaining.AsTracing(), tracing.GasChangeTxIntrinsicGas) - } if rules.IsEIP4762 { st.evm.AccessEvents.AddTxOrigin(msg.From) @@ -651,7 +708,8 @@ func (st *stateTransition) execute() (*ExecutionResult, error) { } } - // Check clause 6 + // Stage 5: top-call affordability, the sender must still be able + // to cover the value transfer of the top frame after gas pre-pay. value := msg.Value if value == nil { value = new(uint256.Int) @@ -689,7 +747,7 @@ func (st *stateTransition) execute() (*ExecutionResult, error) { if msg.SetCodeAuthorizations != nil { for _, auth := range msg.SetCodeAuthorizations { // Note errors are ignored, we simply skip invalid authorizations here. - _ = st.applyAuthorization(rules, &auth) + st.applyAuthorization(rules, &auth) } } @@ -706,58 +764,44 @@ func (st *stateTransition) execute() (*ExecutionResult, error) { ret, result, vmerr = st.evm.Call(msg.From, st.to(), msg.Data, st.gasRemaining.ForwardAll(), value) st.gasRemaining.Absorb(result) } + // If this was a failed contract creation, refund the account creation costs. if rules.IsAmsterdam { - if vmerr != nil { - // If this was a contract creation, refund the account creation costs. - if contractCreation { - refund := params.AccountCreationSize * st.evm.Context.CostPerStateByte - st.gasRemaining.RefundState(refund) - } - } else { - // Compute refunds for selfdestructed slots - cpsb := st.evm.Context.CostPerStateByte - var sdRefund uint64 - for _, addr := range st.state.SameTxSelfDestructs() { - r := params.AccountCreationSize * cpsb - r += uint64(st.state.NewStorageSlotCount(addr)) * params.StorageCreationSize * cpsb - r += uint64(st.state.GetCodeSize(addr)) * cpsb - sdRefund += r - } - if sdRefund > 0 { - st.gasRemaining.RefundState(sdRefund) - } + if vmerr != nil && contractCreation { + refund := params.AccountCreationSize * st.evm.Context.CostPerStateByte + st.gasRemaining.RefundState(refund) } } // Record the gas used excluding gas refunds. This value represents the actual // gas allowance required to complete execution. peakGasUsed := st.gasUsed() + peakRegular := st.gasRemaining.UsedRegularGas // Compute refund counter, capped to a refund quotient. st.gasRemaining.RefundRegular(st.calcRefund()) if rules.IsPrague { - // After EIP-7623: Data-heavy transactions pay the floor gas. + // We can always guarantee that the initial regular gas allowance + // is sufficient to cover the floor cost. + // + // Pre-Amsterdam, there is a single dimension and gas limit is greater + // than the floor cost. + // + // Since Amsterdam: + // - If GasLimit <= 16M, the state reservoir is initialized to 0, + // and regular_gas_budget >= floor_cost always holds. + // - If GasLimit > 16M, the state reservoir is non-zero, while + // regular_gas_budget == 16M, which is still guaranteed to be + // greater than the floor cost. The extra cost should be deducted + // from the regular even the state reservoir is non-zero. if used := st.gasUsed(); used < floorDataGas { - /* - prior, _ := st.gasRemaining.Charge(vm.GasCosts{RegularGas: floorDataGas - used}) - if st.evm.Config.Tracer.HasGasHook() { - st.evm.Config.Tracer.EmitGasChange(prior.AsTracing(), st.gasRemaining.AsTracing(), tracing.GasChangeTxDataFloor) - } - */ - prev := st.gasRemaining.RegularGas - // When the calldata floor exceeds actual gas used, any - // remaining state gas must also be consumed. - targetRemaining := (st.initialBudget.RegularGas + st.initialBudget.StateGas) - floorDataGas - st.gasRemaining.StateGas = 0 - st.gasRemaining.RegularGas = targetRemaining - if t := st.evm.Config.Tracer; t != nil && t.OnGasChange != nil { - t.OnGasChange(prev, st.gasRemaining.RegularGas, tracing.GasChangeTxDataFloor) + prior, _ := st.gasRemaining.ChargeRegular(floorDataGas - used) + if st.evm.Config.Tracer.HasGasHook() { + st.evm.Config.Tracer.EmitGasChange(prior.AsTracing(), st.gasRemaining.AsTracing(), tracing.GasChangeTxDataFloor) } } - if peakGasUsed < floorDataGas { - peakGasUsed = floorDataGas - } + peakGasUsed = max(peakGasUsed, floorDataGas) + peakRegular = max(peakRegular, floorDataGas) } returned := st.returnGas() @@ -766,19 +810,21 @@ func (st *stateTransition) execute() (*ExecutionResult, error) { // st.gasRemaining.UsedRegularGas / UsedStateGas already include both // the intrinsic charge (from st.gasRemaining.Charge(cost) above) and // the per-frame exec contributions absorbed from evm.Call / evm.Create. - // UsedStateGas may be negative when inline refunds (SSTORE 0→x→0, - // CREATE-failure refund, 7702 auth refund, same-tx-SD refund) exceed - // intrinsic + exec state charges. Clamp at 0. + // + // UsedStateGas should never become negative in the top-most frame, since + // state gas refunds only occur when state creation is reverted within the + // same transaction, while clearing pre-existing state is never refunded. var txState uint64 - if st.gasRemaining.UsedStateGas > 0 { + if st.gasRemaining.UsedStateGas >= 0 { txState = uint64(st.gasRemaining.UsedStateGas) + } else { + log.Error("Negative top-most frame state gas usage", "amount", st.gasRemaining.UsedStateGas) } - txRegular := max(st.gasRemaining.UsedRegularGas, floorDataGas) - if err := st.gp.ChargeGasAmsterdam(txRegular, txState, st.gasUsed()); err != nil { + if err := st.gp.ChargeGasAmsterdam(peakRegular, txState, st.gasUsed()); err != nil { return nil, err } } else { - if err = st.gp.ReturnGas(returned, st.gasUsed()); err != nil { + if err = st.gp.ChargeGasLegacy(returned, st.gasUsed()); err != nil { return nil, err } } @@ -909,18 +955,15 @@ func (st *stateTransition) calcRefund() uint64 { return refund } -// returnGas returns ETH for remaining gas, -// exchanged at the original rate. +// returnGas returns ETH for remaining gas, exchanged at the original rate. func (st *stateTransition) returnGas() uint64 { gas := st.gasRemaining.RegularGas + st.gasRemaining.StateGas remaining := uint256.NewInt(st.gasRemaining.RegularGas) remaining.Mul(remaining, st.msg.GasPrice) st.state.AddBalance(st.msg.From, remaining, tracing.BalanceIncreaseGasReturn) - if st.gasRemaining.RegularGas > 0 && st.evm.Config.Tracer.HasGasHook() { - after := st.gasRemaining - after.RegularGas = 0 - st.evm.Config.Tracer.EmitGasChange(st.gasRemaining.AsTracing(), after.AsTracing(), tracing.GasChangeTxLeftOverReturned) + if !st.gasRemaining.IsZero() && st.evm.Config.Tracer.HasGasHook() { + st.evm.Config.Tracer.EmitGasChange(st.gasRemaining.AsTracing(), tracing.Gas{}, tracing.GasChangeTxLeftOverReturned) } return gas } diff --git a/core/tracing/hooks.go b/core/tracing/hooks.go index 6ea3f7ebbfa9..72cbc8a29816 100644 --- a/core/tracing/hooks.go +++ b/core/tracing/hooks.go @@ -472,6 +472,10 @@ const ( // transaction data. This change will always be a negative change. GasChangeTxDataFloor GasChangeReason = 19 + // GasChangeStateGasRefund represents the amount of pre-charged state gas + // refunded back to the state reservoir. + GasChangeStateGasRefund GasChangeReason = 20 + // GasChangeIgnored is a special value that can be used to indicate that the gas change should be ignored as // it will be "manually" tracked by a direct emit of the gas change event. GasChangeIgnored GasChangeReason = 0xFF diff --git a/core/txpool/validation.go b/core/txpool/validation.go index 439d5c68ffdb..5f8463729bb9 100644 --- a/core/txpool/validation.go +++ b/core/txpool/validation.go @@ -125,8 +125,7 @@ func ValidateTransaction(tx *types.Transaction, head *types.Header, signer types } // Ensure the transaction has more gas than the bare minimum needed to cover // the transaction metadata - gasCostPerStateByte := core.CostPerStateByte(head, opts.Config) - intrGas, err := core.IntrinsicGas(tx.Data(), tx.AccessList(), tx.SetCodeAuthorizations(), tx.To() == nil, rules, gasCostPerStateByte) + intrGas, err := core.IntrinsicGas(tx.Data(), tx.AccessList(), tx.SetCodeAuthorizations(), tx.To() == nil, rules, params.CostPerStateByte) if err != nil { return err } @@ -139,9 +138,18 @@ func ValidateTransaction(tx *types.Transaction, head *types.Header, signer types if err != nil { return err } + // Make sure the transaction has sufficient gas allowance to + // pay the floor cost. if tx.Gas() < floorDataGas { return fmt.Errorf("%w: gas %v, minimum needed %v", core.ErrFloorDataGas, tx.Gas(), floorDataGas) } + // In Amsterdam, the transaction gas limit is allowed to exceed + // params.MaxTxGas, but the calldata floor cost is capped by it. + if rules.IsAmsterdam { + if max(intrGas.RegularGas, floorDataGas) > params.MaxTxGas { + return fmt.Errorf("%w: regular intrisic cost %v, floor: %v", core.ErrFloorDataGas, intrGas.RegularGas, floorDataGas) + } + } } // Ensure the gasprice is high enough to cover the requirement of the calling pool if tx.GasTipCapIntCmp(opts.MinTip) < 0 { diff --git a/core/vm/contract.go b/core/vm/contract.go index 03360ebaffd9..11172af24cae 100644 --- a/core/vm/contract.go +++ b/core/vm/contract.go @@ -152,9 +152,19 @@ func (c *Contract) chargeState(s uint64, logger *tracing.Hooks, reason tracing.G return true } -// RefundGas absorbs a sub-call's leftover GasBudget into this contract's gas +// refundState refunds the pre-charged state gas back to state reservoir. +func (c *Contract) refundState(s uint64, logger *tracing.Hooks, reason tracing.GasChangeReason) { + prior := c.Gas + c.Gas.RefundState(s) + + if s != 0 && logger.HasGasHook() && reason != tracing.GasChangeIgnored { + logger.EmitGasChange(prior.AsTracing(), c.Gas.AsTracing(), reason) + } +} + +// refundGas absorbs a sub-call's leftover GasBudget into this contract's gas // state. Thin wrapper around GasBudget.Absorb with tracer integration. -func (c *Contract) RefundGas(child GasBudget, logger *tracing.Hooks, reason tracing.GasChangeReason) { +func (c *Contract) refundGas(child GasBudget, logger *tracing.Hooks, reason tracing.GasChangeReason) { prior := c.Gas c.Gas.Absorb(child) if logger.HasGasHook() && reason != tracing.GasChangeIgnored { diff --git a/core/vm/contracts.go b/core/vm/contracts.go index 306fa0dbc3ad..6908ffeba190 100644 --- a/core/vm/contracts.go +++ b/core/vm/contracts.go @@ -264,7 +264,7 @@ func ActivePrecompiles(rules params.Rules) []common.Address { // - any error that occurred func RunPrecompiledContract(stateDB StateDB, p PrecompiledContract, address common.Address, input []byte, gas GasBudget, logger *tracing.Hooks, rules params.Rules) (ret []byte, remaining GasBudget, err error) { gasCost := p.RequiredGas(input) - prior, ok := gas.Charge(GasCosts{RegularGas: gasCost}) + prior, ok := gas.ChargeRegular(gasCost) if !ok { return nil, gas, ErrOutOfGas } diff --git a/core/vm/evm.go b/core/vm/evm.go index 19a7c1368e7e..76d6978a7815 100644 --- a/core/vm/evm.go +++ b/core/vm/evm.go @@ -299,11 +299,7 @@ func (evm *EVM) Call(caller common.Address, addr common.Address, input []byte, g } if isPrecompile { - var stateDB StateDB - if evm.chainRules.IsAmsterdam { - stateDB = evm.StateDB - } - ret, gas, err = RunPrecompiledContract(stateDB, p, addr, input, gas, evm.Config.Tracer, evm.chainRules) + ret, gas, err = RunPrecompiledContract(evm.StateDB, p, addr, input, gas, evm.Config.Tracer, evm.chainRules) } else { // Initialise a new contract and set the code that is to be used by the EVM. code := evm.resolveCode(addr) @@ -318,18 +314,20 @@ func (evm *EVM) Call(caller common.Address, addr common.Address, input []byte, g gas = contract.Gas } } - // When an error was returned by the EVM or when setting the creation code - // above we revert to the snapshot. gasFromExec below handles the - // regular-gas burn on halt. + + // Calculate the remaining gas at the end of frame + exitGas := gas.Exit(err, initialStateGas) if err != nil { evm.StateDB.RevertToSnapshot(snapshot) + + // Drain the leftover regular gas if unexceptional halt occurs if err != ErrExecutionReverted { if evm.Config.Tracer.HasGasHook() { - evm.Config.Tracer.EmitGasChange(gas.AsTracing(), tracing.Gas{}, tracing.GasChangeCallFailedExecution) + evm.Config.Tracer.EmitGasChange(gas.AsTracing(), exitGas.AsTracing(), tracing.GasChangeCallFailedExecution) } } } - return ret, gas.ExitFromErr(err, initialStateGas), err + return ret, exitGas, err } // CallCode executes the contract associated with the addr with the given input @@ -361,11 +359,7 @@ func (evm *EVM) CallCode(caller common.Address, addr common.Address, input []byt // It is allowed to call precompiles, even via delegatecall if p, isPrecompile := evm.precompile(addr); isPrecompile { - var stateDB StateDB - if evm.chainRules.IsAmsterdam { - stateDB = evm.StateDB - } - ret, gas, err = RunPrecompiledContract(stateDB, p, addr, input, gas, evm.Config.Tracer, evm.chainRules) + ret, gas, err = RunPrecompiledContract(evm.StateDB, p, addr, input, gas, evm.Config.Tracer, evm.chainRules) } else { // Initialise a new contract and set the code that is to be used by the EVM. // The contract is a scoped environment for this execution context only. @@ -374,15 +368,20 @@ func (evm *EVM) CallCode(caller common.Address, addr common.Address, input []byt ret, err = evm.Run(contract, input, false) gas = contract.Gas } + + // Calculate the remaining gas at the end of frame + exitGas := gas.Exit(err, initialStateGas) if err != nil { evm.StateDB.RevertToSnapshot(snapshot) + + // Drain the leftover regular gas if unexceptional halt occurs if err != ErrExecutionReverted { if evm.Config.Tracer.HasGasHook() { - evm.Config.Tracer.EmitGasChange(gas.AsTracing(), tracing.Gas{}, tracing.GasChangeCallFailedExecution) + evm.Config.Tracer.EmitGasChange(gas.AsTracing(), exitGas.AsTracing(), tracing.GasChangeCallFailedExecution) } } } - return ret, gas.ExitFromErr(err, initialStateGas), err + return ret, exitGas, err } // DelegateCall executes the contract associated with the addr with the given input @@ -409,26 +408,27 @@ func (evm *EVM) DelegateCall(originCaller common.Address, caller common.Address, // It is allowed to call precompiles, even via delegatecall if p, isPrecompile := evm.precompile(addr); isPrecompile { - var stateDB StateDB - if evm.chainRules.IsAmsterdam { - stateDB = evm.StateDB - } - ret, gas, err = RunPrecompiledContract(stateDB, p, addr, input, gas, evm.Config.Tracer, evm.chainRules) + ret, gas, err = RunPrecompiledContract(evm.StateDB, p, addr, input, gas, evm.Config.Tracer, evm.chainRules) } else { contract := NewContract(originCaller, caller, value, gas, evm.jumpDests) contract.SetCallCode(evm.resolveCodeHash(addr), evm.resolveCode(addr)) ret, err = evm.Run(contract, input, false) gas = contract.Gas } + + // Calculate the remaining gas at the end of frame + exitGas := gas.Exit(err, initialStateGas) if err != nil { evm.StateDB.RevertToSnapshot(snapshot) + + // Drain the leftover regular gas if unexceptional halt occurs if err != ErrExecutionReverted { if evm.Config.Tracer.HasGasHook() { - evm.Config.Tracer.EmitGasChange(gas.AsTracing(), tracing.Gas{}, tracing.GasChangeCallFailedExecution) + evm.Config.Tracer.EmitGasChange(gas.AsTracing(), exitGas.AsTracing(), tracing.GasChangeCallFailedExecution) } } } - return ret, gas.ExitFromErr(err, initialStateGas), err + return ret, exitGas, err } // StaticCall executes the contract associated with the addr with the given input @@ -463,26 +463,25 @@ func (evm *EVM) StaticCall(caller common.Address, addr common.Address, input []b evm.StateDB.AddBalance(addr, new(uint256.Int), tracing.BalanceChangeTouchAccount) if p, isPrecompile := evm.precompile(addr); isPrecompile { - var stateDB StateDB - if evm.chainRules.IsAmsterdam { - stateDB = evm.StateDB - } - ret, gas, err = RunPrecompiledContract(stateDB, p, addr, input, gas, evm.Config.Tracer, evm.chainRules) + ret, gas, err = RunPrecompiledContract(evm.StateDB, p, addr, input, gas, evm.Config.Tracer, evm.chainRules) } else { contract := NewContract(caller, addr, new(uint256.Int), gas, evm.jumpDests) contract.SetCallCode(evm.resolveCodeHash(addr), evm.resolveCode(addr)) ret, err = evm.Run(contract, input, true) gas = contract.Gas } + + // Calculate the remaining gas at the end of frame + exitGas := gas.Exit(err, initialStateGas) if err != nil { evm.StateDB.RevertToSnapshot(snapshot) if err != ErrExecutionReverted { if evm.Config.Tracer.HasGasHook() { - evm.Config.Tracer.EmitGasChange(gas.AsTracing(), tracing.Gas{}, tracing.GasChangeCallFailedExecution) + evm.Config.Tracer.EmitGasChange(gas.AsTracing(), exitGas.AsTracing(), tracing.GasChangeCallFailedExecution) } } } - return ret, gas.ExitFromErr(err, initialStateGas), err + return ret, exitGas, err } // create creates a new contract using code as deployment code. @@ -593,12 +592,14 @@ func (evm *EVM) create(caller common.Address, code []byte, gas GasBudget, value // state and gas is preserved (i.e., treated as success). if err != nil && (evm.chainRules.IsHomestead || err != ErrCodeStoreOutOfGas) { evm.StateDB.RevertToSnapshot(snapshot) + + exit := contract.Gas.Exit(err, initialStateGas) if err != ErrExecutionReverted { - if evm.Config.Tracer != nil && evm.Config.Tracer.OnGasChange != nil { - evm.Config.Tracer.OnGasChange(contract.Gas.RegularGas, 0, tracing.GasChangeCallFailedExecution) + if evm.Config.Tracer.HasGasHook() { + evm.Config.Tracer.EmitGasChange(contract.Gas.AsTracing(), exit.AsTracing(), tracing.GasChangeCallFailedExecution) } } - return ret, address, contract.Gas.ExitFromErr(err, initialStateGas), err + return ret, address, exit, err } // Either success, or pre-Homestead ErrCodeStoreOutOfGas (gas preserved). // Both packaged as a success-form GasBudget. diff --git a/core/vm/gas_table.go b/core/vm/gas_table.go index dca4358fb2fa..7ce9e7ca8d8e 100644 --- a/core/vm/gas_table.go +++ b/core/vm/gas_table.go @@ -368,7 +368,7 @@ func gasCreate2Eip3860(evm *EVM, contract *Contract, stack *Stack, mem *Memory, } func gasExpFrontier(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { - expByteLen := uint64(((*stack.back(1)).BitLen() + 7) / 8) + expByteLen := uint64((stack.back(1).BitLen() + 7) / 8) var ( gas = expByteLen * params.ExpByteFrontier // no overflow check required. Max is 256 * ExpByte gas @@ -381,7 +381,7 @@ func gasExpFrontier(evm *EVM, contract *Contract, stack *Stack, mem *Memory, mem } func gasExpEIP158(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { - expByteLen := uint64(((*stack.back(1)).BitLen() + 7) / 8) + expByteLen := uint64((stack.back(1).BitLen() + 7) / 8) var ( gas = expByteLen * params.ExpByteEIP158 // no overflow check required. Max is 256 * ExpByte gas @@ -460,10 +460,10 @@ func gasCallIntrinsic(evm *EVM, contract *Contract, stack *Stack, mem *Memory, m return gas, nil } -// gasCallIntrinsic8037 is the intrinsic gas calculator for CALL in Amsterdam. +// regularGasCall8037 is the intrinsic gas calculator for CALL in Amsterdam. // It computes memory expansion + value transfer gas but excludes new account // creation, which is handled as state gas by the wrapper. -func gasCallIntrinsic8037(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (uint64, error) { +func regularGasCall8037(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (uint64, error) { var ( gas uint64 transfersValue = !stack.back(2).IsZero() @@ -571,7 +571,10 @@ func gasCreateEip8037(evm *EVM, contract *Contract, stack *Stack, mem *Memory, m words := (size + 31) / 32 wordGas := params.InitCodeWordGas * words stateGas := params.AccountCreationSize * evm.Context.CostPerStateByte - return GasCosts{RegularGas: gas + wordGas, StateGas: stateGas}, nil + return GasCosts{ + RegularGas: gas + wordGas, + StateGas: stateGas, + }, nil } func gasCreate2Eip8037(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { @@ -591,28 +594,35 @@ func gasCreate2Eip8037(evm *EVM, contract *Contract, stack *Stack, mem *Memory, } // Since size <= MaxInitCodeSizeAmsterdam, these multiplications cannot overflow words := (size + 31) / 32 - // CREATE2 charges both InitCodeWordGas (EIP-3860) and Keccak256WordGas (for address hashing). + + // CREATE2 charges both InitCodeWordGas (EIP-3860) and Keccak256WordGas + // (for address hashing). wordGas := (params.InitCodeWordGas + params.Keccak256WordGas) * words stateGas := params.AccountCreationSize * evm.Context.CostPerStateByte - return GasCosts{RegularGas: gas + wordGas, StateGas: stateGas}, nil + return GasCosts{ + RegularGas: gas + wordGas, + StateGas: stateGas, + }, nil } -// gasCall8037 is the stateful gas calculator for CALL in Amsterdam (EIP-8037). +// stateGasCall8037 is the stateful gas calculator for CALL in Amsterdam (EIP-8037). // It only returns the state-dependent gas (account creation as state gas). // Memory gas, transfer gas, and callGas are handled by gasCallStateless and // makeCallVariantGasCall. -func gasCall8037(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { +func stateGasCall8037(evm *EVM, contract *Contract, stack *Stack) (uint64, error) { var ( - gas GasCosts + gas uint64 transfersValue = !stack.back(2).IsZero() address = common.Address(stack.back(1).Bytes20()) ) + // TODO(rjl, marius), can EIP8037 implicitly means the EIP158 is also activated? + // It's technically possible to skip the EIP158 but very unlikely in practice. if evm.chainRules.IsEIP158 { if transfersValue && evm.StateDB.Empty(address) { - gas.StateGas += params.AccountCreationSize * evm.Context.CostPerStateByte + gas += params.AccountCreationSize * evm.Context.CostPerStateByte } } else if !evm.StateDB.Exist(address) { - gas.StateGas += params.AccountCreationSize * evm.Context.CostPerStateByte + gas += params.AccountCreationSize * evm.Context.CostPerStateByte } return gas, nil } @@ -671,14 +681,9 @@ func gasSStore8037(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memo } if original == current { if original == (common.Hash{}) { // create slot (2.1.1) - // EIP-8037: Return both regular and state gas. System calls do not charge state gas. - var stateGas uint64 - if !contract.IsSystemCall { - stateGas = params.StorageCreationSize * evm.Context.CostPerStateByte - } return GasCosts{ RegularGas: cost.RegularGas + params.SstoreResetGasEIP2200 - params.ColdSloadCostEIP2929, - StateGas: stateGas, + StateGas: params.StorageCreationSize * evm.Context.CostPerStateByte, }, nil } if value == (common.Hash{}) { // delete slot (2.1.2b) diff --git a/core/vm/gascosts.go b/core/vm/gascosts.go index 0865a0982d70..83d148b6ce4e 100644 --- a/core/vm/gascosts.go +++ b/core/vm/gascosts.go @@ -55,7 +55,7 @@ func (g GasCosts) String() string { // in lockstep. // // - At frame exit: Preserved / ExitSuccess / ExitRevert / ExitHalt / -// ExitFromErr produce a new GasBudget in "leftover" form that packages +// Exit produce a new GasBudget in "leftover" form that packages // the result for the caller. // // - At absorption: the caller's Absorb method merges the child's leftover @@ -263,7 +263,7 @@ func (g GasBudget) ExitHalt(initialStateGas uint64) GasBudget { } } -// ExitFromErr dispatches on err to the appropriate exit-form constructor +// Exit dispatches on err to the appropriate exit-form constructor // for the post-evm.Run path: // // - err == nil → ExitSuccess @@ -272,7 +272,7 @@ func (g GasBudget) ExitHalt(initialStateGas uint64) GasBudget { // // Soft validation failures (occurring BEFORE evm.Run) should call Preserved // directly instead of going through this dispatcher. -func (g GasBudget) ExitFromErr(err error, initialStateGas uint64) GasBudget { +func (g GasBudget) Exit(err error, initialStateGas uint64) GasBudget { switch { case err == nil: return g.ExitSuccess() diff --git a/core/vm/instructions.go b/core/vm/instructions.go index 0f5274857c87..d85fea5ee636 100644 --- a/core/vm/instructions.go +++ b/core/vm/instructions.go @@ -675,7 +675,7 @@ func opCreate(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { } scope.Stack.push(&stackvalue) - scope.Contract.RefundGas(result, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + scope.Contract.refundGas(result, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) if evm.chainRules.IsAmsterdam && suberr != nil { scope.Contract.Gas.RefundState(params.AccountCreationSize * evm.Context.CostPerStateByte) } @@ -710,11 +710,13 @@ func opCreate2(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { stackvalue.SetBytes(addr.Bytes()) } scope.Stack.push(&stackvalue) - scope.Contract.RefundGas(result, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + scope.Contract.refundGas(result, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + + // If the creation frame reverts or halts exceptionally, the charged state-gas + // is refilled back to the state reservoir in Amsterdam. if evm.chainRules.IsAmsterdam && suberr != nil { - scope.Contract.Gas.RefundState(params.AccountCreationSize * evm.Context.CostPerStateByte) + scope.Contract.refundState(params.AccountCreationSize*evm.Context.CostPerStateByte, evm.Config.Tracer, tracing.GasChangeStateGasRefund) } - if suberr == ErrExecutionReverted { evm.returnData = res // set REVERT data to return data buffer return res, nil @@ -754,11 +756,24 @@ func opCall(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { temp.SetOne() } stack.push(&temp) + if err == nil || err == ErrExecutionReverted { scope.Memory.Set(retOffset.Uint64(), retSize.Uint64(), ret) } - - scope.Contract.RefundGas(result, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + scope.Contract.refundGas(result, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + + // If the call frame reverts or halts exceptionally, the charged state-gas + // is refilled back to the state reservoir in Amsterdam. + // + // The state-gas should only be refunded if the state creation doesn't + // happens, such as ErrDepth, ErrInsufficientBalance. + // + // TODO(rjl) it's so ugly, please rework it. + if evm.chainRules.IsAmsterdam && err != nil { + if (err == ErrDepth || err == ErrInsufficientBalance) && !value.IsZero() && evm.StateDB.Empty(toAddr) { + scope.Contract.refundState(params.AccountCreationSize*evm.Context.CostPerStateByte, evm.Config.Tracer, tracing.GasChangeStateGasRefund) + } + } evm.returnData = ret return ret, nil @@ -792,7 +807,7 @@ func opCallCode(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { scope.Memory.Set(retOffset.Uint64(), retSize.Uint64(), ret) } - scope.Contract.RefundGas(result, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + scope.Contract.refundGas(result, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) evm.returnData = ret return ret, nil @@ -821,7 +836,7 @@ func opDelegateCall(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { if err == nil || err == ErrExecutionReverted { scope.Memory.Set(retOffset.Uint64(), retSize.Uint64(), ret) } - scope.Contract.RefundGas(result, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + scope.Contract.refundGas(result, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) evm.returnData = ret return ret, nil @@ -851,7 +866,7 @@ func opStaticCall(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { scope.Memory.Set(retOffset.Uint64(), retSize.Uint64(), ret) } - scope.Contract.RefundGas(result, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + scope.Contract.refundGas(result, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) evm.returnData = ret return ret, nil diff --git a/core/vm/interface.go b/core/vm/interface.go index 8c67a553c48b..a9938c2a2873 100644 --- a/core/vm/interface.go +++ b/core/vm/interface.go @@ -71,14 +71,6 @@ type StateDB interface { // during the current transaction. IsNewContract(addr common.Address) bool - // SameTxSelfDestructs returns addresses that were created and then - // self-destructed in the current transaction. - SameTxSelfDestructs() []common.Address - - // NewStorageSlotCount returns the number of storage slots written to a - // non-zero value in the current transaction on the given address. - NewStorageSlotCount(addr common.Address) int - // Empty returns whether the given account is empty. Empty // is defined according to EIP161 (balance = nonce = code = 0). Empty(common.Address) bool diff --git a/core/vm/jump_table.go b/core/vm/jump_table.go index 9ea8349e3ab3..5cc5e34cedb2 100644 --- a/core/vm/jump_table.go +++ b/core/vm/jump_table.go @@ -28,6 +28,9 @@ type ( intrinsicGasFunc func(*EVM, *Contract, *Stack, *Memory, uint64) (uint64, error) // last parameter is the requested memory size as a uint64 // memorySizeFunc returns the required size, and whether the operation overflowed a uint64 memorySizeFunc func(*Stack) (size uint64, overflow bool) + + regularGasFunc func(*EVM, *Contract, *Stack, *Memory, uint64) (uint64, error) + stateGasFunc func(*EVM, *Contract, *Stack) (uint64, error) ) type operation struct { diff --git a/core/vm/operations_acl.go b/core/vm/operations_acl.go index 6ddb54c94aee..67056ecb65d2 100644 --- a/core/vm/operations_acl.go +++ b/core/vm/operations_acl.go @@ -267,7 +267,7 @@ var ( gasStaticCallEIP7702 = makeCallVariantGasCallEIP7702(gasStaticCallIntrinsic) gasCallCodeEIP7702 = makeCallVariantGasCallEIP7702(gasCallCodeIntrinsic) - innerGasCallEIP8037 = makeCallVariantGasCallEIP8037(gasCallIntrinsic8037, gasCall8037) + innerGasCallEIP8037 = makeCallVariantGasCallEIP8037(regularGasCall8037, stateGasCall8037) ) func gasCallEIP7702(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { @@ -381,7 +381,7 @@ func makeCallVariantGasCallEIP7702(intrinsicFunc intrinsicGasFunc) gasFunc { // It extends the EIP-7702 pattern with state gas handling and GasUsed tracking. // intrinsicFunc computes the regular gas (memory + transfer, no new account creation). // stateGasFunc computes the state gas (new account creation as state gas). -func makeCallVariantGasCallEIP8037(intrinsicFunc intrinsicGasFunc, stateGasFunc gasFunc) gasFunc { +func makeCallVariantGasCallEIP8037(regularFunc regularGasFunc, stateGasFunc stateGasFunc) gasFunc { return func(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { var ( eip2929Cost uint64 @@ -397,8 +397,8 @@ func makeCallVariantGasCallEIP8037(intrinsicFunc intrinsicGasFunc, stateGasFunc } } - // Compute intrinsic cost (memory + transfer, no new account creation). - intrinsicCost, err := intrinsicFunc(evm, contract, stack, mem, memorySize) + // Compute regular cost (memory + transfer, no new account creation). + regularCost, err := regularFunc(evm, contract, stack, mem, memorySize) if err != nil { return GasCosts{}, err } @@ -406,7 +406,7 @@ func makeCallVariantGasCallEIP8037(intrinsicFunc intrinsicGasFunc, stateGasFunc // Charge intrinsic cost directly (regular gas). This must happen // BEFORE state gas to prevent reservoir inflation, and also serves // as the OOG guard before stateful operations. - if !contract.chargeRegular(intrinsicCost, evm.Config.Tracer, tracing.GasChangeCallOpCode) { + if !contract.chargeRegular(regularCost, evm.Config.Tracer, tracing.GasChangeCallOpCode) { return GasCosts{}, ErrOutOfGas } @@ -424,13 +424,12 @@ func makeCallVariantGasCallEIP8037(intrinsicFunc intrinsicGasFunc, stateGasFunc } // Compute and charge state gas (new account creation) AFTER regular gas. - stateGas, err := stateGasFunc(evm, contract, stack, mem, memorySize) + stateGas, err := stateGasFunc(evm, contract, stack) if err != nil { return GasCosts{}, err } - if stateGas.StateGas > 0 { - // Charge updates contract.Gas.UsedStateGas in lockstep. - if _, ok := contract.Gas.Charge(GasCosts{StateGas: stateGas.StateGas}); !ok { + if stateGas > 0 { + if _, ok := contract.Gas.ChargeState(stateGas); !ok { return GasCosts{}, ErrOutOfGas } } @@ -443,8 +442,8 @@ func makeCallVariantGasCallEIP8037(intrinsicFunc intrinsicGasFunc, stateGasFunc // Temporarily undo direct regular charges for tracer reporting. // The interpreter will charge the returned totalCost. - contract.Gas.RegularGas += eip2929Cost + eip7702Cost + intrinsicCost - contract.Gas.UsedRegularGas -= eip2929Cost + eip7702Cost + intrinsicCost + contract.Gas.RegularGas += eip2929Cost + eip7702Cost + regularCost + contract.Gas.UsedRegularGas -= eip2929Cost + eip7702Cost + regularCost // Aggregate total cost. var ( @@ -454,7 +453,7 @@ func makeCallVariantGasCallEIP8037(intrinsicFunc intrinsicGasFunc, stateGasFunc if totalCost, overflow = math.SafeAdd(eip2929Cost, eip7702Cost); overflow { return GasCosts{}, ErrGasUintOverflow } - if totalCost, overflow = math.SafeAdd(totalCost, intrinsicCost); overflow { + if totalCost, overflow = math.SafeAdd(totalCost, regularCost); overflow { return GasCosts{}, ErrGasUintOverflow } if totalCost, overflow = math.SafeAdd(totalCost, evm.callGasTemp); overflow { diff --git a/params/protocol_params.go b/params/protocol_params.go index b7dcd0b81bab..69e10fa5d9db 100644 --- a/params/protocol_params.go +++ b/params/protocol_params.go @@ -199,10 +199,11 @@ const ( // don't consume block gas. BALItemCost uint64 = 2000 - AccountCreationSize = 112 - StorageCreationSize = 32 + AccountCreationSize = 120 + StorageCreationSize = 64 AuthorizationCreationSize = 23 - CostPerStateByte = 1174 + CostPerStateByte = 1530 + SystemMaxSStoresPerCall = 16 ) // Bls12381G1MultiExpDiscountTable is the gas discount table for BLS12-381 G1 multi exponentiation operation diff --git a/tests/transaction_test_util.go b/tests/transaction_test_util.go index 88745dabaa59..91f7d6c3ec68 100644 --- a/tests/transaction_test_util.go +++ b/tests/transaction_test_util.go @@ -81,7 +81,7 @@ func (tt *TransactionTest) Run() error { return } // Intrinsic cost - cost, err := core.IntrinsicGas(tx.Data(), tx.AccessList(), tx.SetCodeAuthorizations(), tx.To() == nil, rules, 0) + cost, err := core.IntrinsicGas(tx.Data(), tx.AccessList(), tx.SetCodeAuthorizations(), tx.To() == nil, rules, params.CostPerStateByte) if err != nil { return } From 39fb6df9515b9a8cb3d507dc90a81c94919f7dd0 Mon Sep 17 00:00:00 2001 From: Gary Rong Date: Wed, 20 May 2026 15:18:03 +0800 Subject: [PATCH 03/17] core: absorb marius's fix from the baldev-7 branch --- core/blockchain.go | 6 +++++ core/state_transition.go | 57 +++++++++++++++++++++++++--------------- 2 files changed, 42 insertions(+), 21 deletions(-) diff --git a/core/blockchain.go b/core/blockchain.go index 7b5a910b7a1c..eb4ea7ebc1fe 100644 --- a/core/blockchain.go +++ b/core/blockchain.go @@ -2301,6 +2301,12 @@ func (bc *BlockChain) ProcessBlock(ctx context.Context, parentRoot common.Hash, stats.Validation = vtime - (statedb.AccountHashes + statedb.AccountUpdates + statedb.StorageUpdates) // The time spent on block validation stats.CrossValidation = xvtime // The time spent on stateless cross validation + // Attach the computed block access list so it gets persisted alongside the + // block. The validator has already verified the hash matches the header. + if res.Bal != nil && block.AccessList() == nil { + block = block.WithAccessListUnsafe(res.Bal.ToEncodingObj()) + } + // Write the block to the chain and get the status. var status WriteStatus if config.WriteState { diff --git a/core/state_transition.go b/core/state_transition.go index b026bbcbf97f..1314fdcf4bda 100644 --- a/core/state_transition.go +++ b/core/state_transition.go @@ -781,21 +781,28 @@ func (st *stateTransition) execute() (*ExecutionResult, error) { st.gasRemaining.RefundRegular(st.calcRefund()) if rules.IsPrague { - // We can always guarantee that the initial regular gas allowance - // is sufficient to cover the floor cost. - // - // Pre-Amsterdam, there is a single dimension and gas limit is greater - // than the floor cost. - // - // Since Amsterdam: - // - If GasLimit <= 16M, the state reservoir is initialized to 0, - // and regular_gas_budget >= floor_cost always holds. - // - If GasLimit > 16M, the state reservoir is non-zero, while - // regular_gas_budget == 16M, which is still guaranteed to be - // greater than the floor cost. The extra cost should be deducted - // from the regular even the state reservoir is non-zero. + // EIP-7623 floor: tx_gas_used_after_refund = max(used, calldata_floor). + // Drain the leftover gas budget — regular first, then state — to bring + // gasUsed up to the floor. State must be drained too because a failed + // contract-creation top-level refund (line ~770) can move otherwise-spent + // gas back into the state reservoir, leaving RegularGas too small to + // satisfy the floor on its own. if used := st.gasUsed(); used < floorDataGas { - prior, _ := st.gasRemaining.ChargeRegular(floorDataGas - used) + prior := st.gasRemaining + need := floorDataGas - used + if take := min(need, st.gasRemaining.RegularGas); take > 0 { + st.gasRemaining.RegularGas -= take + st.gasRemaining.UsedRegularGas += take + need -= take + } + if take := min(need, st.gasRemaining.StateGas); take > 0 { + st.gasRemaining.StateGas -= take + st.gasRemaining.UsedStateGas += int64(take) + need -= take + } + if need > 0 { + return nil, fmt.Errorf("insufficient gas for floor cost, remaining: %d", need) + } if st.evm.Config.Tracer.HasGasHook() { st.evm.Config.Tracer.EmitGasChange(prior.AsTracing(), st.gasRemaining.AsTracing(), tracing.GasChangeTxDataFloor) } @@ -901,7 +908,6 @@ func (st *stateTransition) applyAuthorization(rules params.Rules, auth *types.Se if err != nil { return err } - // If the account already exists in state, refund the new account cost // charged in the intrinsic calculation. if st.state.Exist(authority) { @@ -912,24 +918,33 @@ func (st *stateTransition) applyAuthorization(rules params.Rules, auth *types.Se st.state.AddRefund(params.CallNewAccountGas - params.TxAuthTupleGas) } } - prevDelegation, isDelegated := types.ParseDelegation(st.state.GetCode(authority)) + if rules.IsAmsterdam { + // EIP-8037: also refund the auth-base state gas when no new delegation + // indicator bytes are written. Two cases: + // - the authority already has a delegation (overwrite in place); or + // - the auth is no-op (auth.Address == 0). + // In both cases the 23 delegation bytes are reused, so the auth-base + // portion of the intrinsic state gas is refilled. + if isDelegated || auth.Address == (common.Address{}) { + st.gasRemaining.RefundState(params.AuthorizationCreationSize * st.evm.Context.CostPerStateByte) + } + } // Update nonce and account code. st.state.SetNonce(authority, auth.Nonce+1, tracing.NonceChangeAuthorization) + + // Delegation to zero address means clear. if auth.Address == (common.Address{}) { - // Delegation to zero address means clear. if isDelegated { st.state.SetCode(authority, nil, tracing.CodeChangeAuthorizationClear) } return nil } - - // install delegation to auth.Address if the delegation changed + // Install delegation to auth.Address if the delegation changed if !isDelegated || auth.Address != prevDelegation { st.state.SetCode(authority, types.AddressToDelegation(auth.Address), tracing.CodeChangeAuthorization) } - return nil } @@ -958,7 +973,7 @@ func (st *stateTransition) calcRefund() uint64 { // returnGas returns ETH for remaining gas, exchanged at the original rate. func (st *stateTransition) returnGas() uint64 { gas := st.gasRemaining.RegularGas + st.gasRemaining.StateGas - remaining := uint256.NewInt(st.gasRemaining.RegularGas) + remaining := uint256.NewInt(gas) remaining.Mul(remaining, st.msg.GasPrice) st.state.AddBalance(st.msg.From, remaining, tracing.BalanceIncreaseGasReturn) From ce259970916ac7aeff9ddb5900e0c4627a67d0dd Mon Sep 17 00:00:00 2001 From: Gary Rong Date: Wed, 20 May 2026 16:27:14 +0800 Subject: [PATCH 04/17] core/state: improve authorization state gas refund --- core/state/state_object.go | 24 +++++++++++ core/state/statedb.go | 12 ++++++ core/state/statedb_hooked.go | 4 ++ core/state_transition.go | 83 ++++++++++++++++++++++++++++++------ core/vm/interface.go | 5 +++ 5 files changed, 114 insertions(+), 14 deletions(-) diff --git a/core/state/state_object.go b/core/state/state_object.go index ce456e7668f6..2165fad57ad1 100644 --- a/core/state/state_object.go +++ b/core/state/state_object.go @@ -572,6 +572,30 @@ func (s *stateObject) Code() []byte { return code } +// GetCommittedCode returns the contract code committed at the start of the +// current execution, ignoring any intra-execution SetCode modifications. +// Used by EIP-7702 authorization application to make refund decisions +// relative to the originally-committed delegation, matching the spirit of +// GetCommittedState for storage slots. +func (s *stateObject) GetCommittedCode() []byte { + // The account did not exist at the start of the current execution. + if s.origin == nil { + return nil + } + hash := common.BytesToHash(s.origin.CodeHash) + if hash == types.EmptyCodeHash { + return nil + } + // If the code has not been touched in this execution, the live cache + // already holds the committed code. + if !s.dirtyCode { + return s.Code() + } + // Code was modified within the current execution. Reach for the on-disk + // blob keyed by the origin hash. + return s.db.reader.Code(s.address, hash) +} + // CodeSize returns the size of the contract code associated with this object, // or zero if none. This method is an almost mirror of Code, but uses a cache // inside the database to avoid loading codes seen recently. diff --git a/core/state/statedb.go b/core/state/statedb.go index 1c49d460206d..15c175fd3026 100644 --- a/core/state/statedb.go +++ b/core/state/statedb.go @@ -397,6 +397,18 @@ func (s *StateDB) GetCodeHash(addr common.Address) common.Hash { return common.Hash{} } +// GetCommittedCode returns the contract code committed at the start of the +// current execution, ignoring any in-progress SetCode mutations. Returns +// nil when the account had no code (or did not exist) prior to this +// execution. +func (s *StateDB) GetCommittedCode(addr common.Address) []byte { + stateObject := s.getStateObject(addr) + if stateObject != nil { + return stateObject.GetCommittedCode() + } + return nil +} + // GetState retrieves the value associated with the specific key. func (s *StateDB) GetState(addr common.Address, hash common.Hash) common.Hash { stateObject := s.getStateObject(addr) diff --git a/core/state/statedb_hooked.go b/core/state/statedb_hooked.go index 98d01343a45e..184629ea414c 100644 --- a/core/state/statedb_hooked.go +++ b/core/state/statedb_hooked.go @@ -75,6 +75,10 @@ func (s *hookedStateDB) GetCode(addr common.Address) []byte { return s.inner.GetCode(addr) } +func (s *hookedStateDB) GetCommittedCode(addr common.Address) []byte { + return s.inner.GetCommittedCode(addr) +} + func (s *hookedStateDB) GetCodeSize(addr common.Address) int { return s.inner.GetCodeSize(addr) } diff --git a/core/state_transition.go b/core/state_transition.go index 1314fdcf4bda..b8ca6714fa35 100644 --- a/core/state_transition.go +++ b/core/state_transition.go @@ -744,12 +744,7 @@ func (st *stateTransition) execute() (*ExecutionResult, error) { st.state.SetNonce(msg.From, st.state.GetNonce(msg.From)+1, tracing.NonceChangeEoACall) // Apply EIP-7702 authorizations. - if msg.SetCodeAuthorizations != nil { - for _, auth := range msg.SetCodeAuthorizations { - // Note errors are ignored, we simply skip invalid authorizations here. - st.applyAuthorization(rules, &auth) - } - } + st.applyAuthorizations(rules, msg.SetCodeAuthorizations) // Perform convenience warming of sender's delegation target. Although the // sender is already warmed in Prepare(..), it's possible a delegation to @@ -902,8 +897,48 @@ func (st *stateTransition) validateAuthorization(auth *types.SetCodeAuthorizatio return authority, nil } +// applyAuthorizations applies every EIP-7702 code delegation in the tx and, +// under EIP-8037, reconciles the per-authority creation budget so that the +// total AuthorizationCreationSize state gas charged matches the net change +// in on-disk delegation bytes. +// +// Invalid authorizations are silently skipped (their auth-base intrinsic +// state gas remains charged, matching the pre-existing behavior). +func (st *stateTransition) applyAuthorizations(rules params.Rules, auths []types.SetCodeAuthorization) { + if len(auths) == 0 { + return + } + // Under EIP-8037 each authority can be billed at most one + // AuthorizationCreationSize. applyAuthorization records authorities it + // has billed; we reconcile after the loop by refunding any creation that + // was billed but whose final delegation state in this tx ended up empty + // (e.g., 0→a→0). + var billed map[common.Address]struct{} + if rules.IsAmsterdam { + billed = make(map[common.Address]struct{}) + } + for _, auth := range auths { + // Errors are ignored — invalid authorizations are simply skipped. + st.applyAuthorization(rules, &auth, billed) + } + // End-of-loop reconciliation: a billed creation whose authority is no + // longer delegated wrote zero net bytes to disk, so the auth-base + // intrinsic state gas should not be retained. + for authority := range billed { + if _, isDelegated := types.ParseDelegation(st.state.GetCode(authority)); !isDelegated { + st.gasRemaining.RefundState(params.AuthorizationCreationSize * st.evm.Context.CostPerStateByte) + } + } +} + // applyAuthorization applies an EIP-7702 code delegation to the state. -func (st *stateTransition) applyAuthorization(rules params.Rules, auth *types.SetCodeAuthorization) error { +// +// authBilledCreations, when non-nil, tracks the set of authorities for which +// this tx has been billed one AuthorizationCreationSize charge (the per- +// authority "first creation" budget). The caller is expected to do an +// end-of-loop pass over this set and refund any entry whose final delegation +// state ended up empty. +func (st *stateTransition) applyAuthorization(rules params.Rules, auth *types.SetCodeAuthorization, authBilledCreations map[common.Address]struct{}) error { authority, err := st.validateAuthorization(auth) if err != nil { return err @@ -920,14 +955,34 @@ func (st *stateTransition) applyAuthorization(rules params.Rules, auth *types.Se } prevDelegation, isDelegated := types.ParseDelegation(st.state.GetCode(authority)) if rules.IsAmsterdam { - // EIP-8037: also refund the auth-base state gas when no new delegation - // indicator bytes are written. Two cases: - // - the authority already has a delegation (overwrite in place); or - // - the auth is no-op (auth.Address == 0). - // In both cases the 23 delegation bytes are reused, so the auth-base - // portion of the intrinsic state gas is refilled. - if isDelegated || auth.Address == (common.Address{}) { + // EIP-8037: refund the auth-base state gas unless this auth is the + // first one in this tx to write delegation bytes to an authority + // whose committed code was empty. Refund when ANY of: + // + // - the authority was already delegated at the start of the tx + // (the 23 bytes are already accounted for in committed state, + // and any auth against it just re-writes them); + // + // - the auth is no-op / clearing (auth.Address == 0) — no bytes + // are written in this step at all; + // + // - we have already billed a creation for this authority in + // this tx (per-authority creation budget is 1). + // + // Modeling it this way mirrors the SSTORE "reset to original" + // pattern (EIP-2200 / EIP-3529) and avoids both the undercount in + // a→0→b (committed had delegation, second auth missed the refund) + // and the overcount in 0→a→0→c (each later auth was previously + // billed as a fresh creation). The remaining 0→a→0 case — a + // creation is billed and then undone within the same auth list — + // is handled by the caller's end-of-loop adjustment over + // authBilledCreations. + _, committedDelegated := types.ParseDelegation(st.state.GetCommittedCode(authority)) + _, alreadyBilled := authBilledCreations[authority] + if committedDelegated || alreadyBilled || auth.Address == (common.Address{}) { st.gasRemaining.RefundState(params.AuthorizationCreationSize * st.evm.Context.CostPerStateByte) + } else { + authBilledCreations[authority] = struct{}{} } } diff --git a/core/vm/interface.go b/core/vm/interface.go index a9938c2a2873..dc897f0c69e3 100644 --- a/core/vm/interface.go +++ b/core/vm/interface.go @@ -42,6 +42,11 @@ type StateDB interface { GetCodeHash(common.Address) common.Hash GetCode(common.Address) []byte + // GetCommittedCode returns the contract code at the start of the current + // execution, ignoring any in-progress SetCode mutations. Returns nil when + // the account had no code prior to this execution. + GetCommittedCode(common.Address) []byte + // SetCode sets the new code for the address, and returns the previous code, if any. SetCode(common.Address, []byte, tracing.CodeChangeReason) []byte GetCodeSize(common.Address) int From bf69a15de68bfac5f7e2a8faab24fca7016ac7ce Mon Sep 17 00:00:00 2001 From: Gary Rong Date: Thu, 21 May 2026 11:33:12 +0800 Subject: [PATCH 05/17] core/vm: change exit halt --- core/vm/evm.go | 28 +++++++++------------------- core/vm/gascosts.go | 23 ++++++++++++++++------- 2 files changed, 25 insertions(+), 26 deletions(-) diff --git a/core/vm/evm.go b/core/vm/evm.go index 76d6978a7815..a16e9c8cc273 100644 --- a/core/vm/evm.go +++ b/core/vm/evm.go @@ -255,8 +255,6 @@ func (evm *EVM) Call(caller common.Address, addr common.Address, input []byte, g evm.captureEnd(evm.depth, startGas, result, ret, err) }(gas) } - initialStateGas := gas.StateGas - // Fail if we're trying to execute above the call depth limit if evm.depth > int(params.CallCreateDepth) { return nil, gas.Preserved(), ErrDepth @@ -281,7 +279,7 @@ func (evm *EVM) Call(caller common.Address, addr common.Address, input []byte, g wgas := evm.AccessEvents.CodeHashGas(addr, true, gas.RegularGas, false) if _, ok := gas.ChargeRegular(wgas); !ok { evm.StateDB.RevertToSnapshot(snapshot) - return nil, gas.ExitHalt(initialStateGas), ErrOutOfGas + return nil, gas.ExitHalt(), ErrOutOfGas } } @@ -316,7 +314,7 @@ func (evm *EVM) Call(caller common.Address, addr common.Address, input []byte, g } // Calculate the remaining gas at the end of frame - exitGas := gas.Exit(err, initialStateGas) + exitGas := gas.Exit(err) if err != nil { evm.StateDB.RevertToSnapshot(snapshot) @@ -345,8 +343,6 @@ func (evm *EVM) CallCode(caller common.Address, addr common.Address, input []byt evm.captureEnd(evm.depth, startGas, result, ret, err) }(gas) } - initialStateGas := gas.StateGas - // Fail if we're trying to execute above the call depth limit if evm.depth > int(params.CallCreateDepth) { return nil, gas.Preserved(), ErrDepth @@ -370,7 +366,7 @@ func (evm *EVM) CallCode(caller common.Address, addr common.Address, input []byt } // Calculate the remaining gas at the end of frame - exitGas := gas.Exit(err, initialStateGas) + exitGas := gas.Exit(err) if err != nil { evm.StateDB.RevertToSnapshot(snapshot) @@ -398,8 +394,6 @@ func (evm *EVM) DelegateCall(originCaller common.Address, caller common.Address, evm.captureEnd(evm.depth, startGas, result, ret, err) }(gas) } - initialStateGas := gas.StateGas - // Fail if we're trying to execute above the call depth limit if evm.depth > int(params.CallCreateDepth) { return nil, gas.Preserved(), ErrDepth @@ -417,7 +411,7 @@ func (evm *EVM) DelegateCall(originCaller common.Address, caller common.Address, } // Calculate the remaining gas at the end of frame - exitGas := gas.Exit(err, initialStateGas) + exitGas := gas.Exit(err) if err != nil { evm.StateDB.RevertToSnapshot(snapshot) @@ -443,8 +437,6 @@ func (evm *EVM) StaticCall(caller common.Address, addr common.Address, input []b evm.captureEnd(evm.depth, startGas, result, ret, err) }(gas) } - initialStateGas := gas.StateGas - // Fail if we're trying to execute above the call depth limit if evm.depth > int(params.CallCreateDepth) { return nil, gas.Preserved(), ErrDepth @@ -472,7 +464,7 @@ func (evm *EVM) StaticCall(caller common.Address, addr common.Address, input []b } // Calculate the remaining gas at the end of frame - exitGas := gas.Exit(err, initialStateGas) + exitGas := gas.Exit(err) if err != nil { evm.StateDB.RevertToSnapshot(snapshot) if err != ErrExecutionReverted { @@ -499,8 +491,6 @@ func (evm *EVM) create(caller common.Address, code []byte, gas GasBudget, value err = ErrNonceUintOverflow } } - initialStateGas := gas.StateGas - if err == nil { evm.StateDB.SetNonce(caller, nonce+1, tracing.NonceChangeContractCreator) } @@ -519,7 +509,7 @@ func (evm *EVM) create(caller common.Address, code []byte, gas GasBudget, value statelessGas := evm.AccessEvents.ContractCreatePreCheckGas(address, gas.RegularGas) prior, ok := gas.Charge(GasCosts{RegularGas: statelessGas}) if !ok { - return nil, common.Address{}, gas.ExitHalt(initialStateGas), ErrOutOfGas + return nil, common.Address{}, gas.ExitHalt(), ErrOutOfGas } if evm.Config.Tracer.HasGasHook() { evm.Config.Tracer.EmitGasChange(prior.AsTracing(), gas.AsTracing(), tracing.GasChangeWitnessContractCollisionCheck) @@ -540,7 +530,7 @@ func (evm *EVM) create(caller common.Address, code []byte, gas GasBudget, value if evm.StateDB.GetNonce(address) != 0 || (contractHash != (common.Hash{}) && contractHash != types.EmptyCodeHash) || // non-empty code isEIP7610RejectedAccount(evm.ChainConfig().ChainID, address, evm.chainRules.IsEIP158) { - halt := gas.ExitHalt(initialStateGas) + halt := gas.ExitHalt() if evm.Config.Tracer.HasGasHook() { evm.Config.Tracer.EmitGasChange(gas.AsTracing(), halt.AsTracing(), tracing.GasChangeCallFailedExecution) } @@ -568,7 +558,7 @@ func (evm *EVM) create(caller common.Address, code []byte, gas GasBudget, value if evm.chainRules.IsEIP4762 { consumed, wanted := evm.AccessEvents.ContractCreateInitGas(address, gas.RegularGas) if consumed < wanted { - return nil, common.Address{}, gas.ExitHalt(initialStateGas), ErrOutOfGas + return nil, common.Address{}, gas.ExitHalt(), ErrOutOfGas } prior, _ := gas.Charge(GasCosts{RegularGas: consumed}) if evm.Config.Tracer.HasGasHook() { @@ -593,7 +583,7 @@ func (evm *EVM) create(caller common.Address, code []byte, gas GasBudget, value if err != nil && (evm.chainRules.IsHomestead || err != ErrCodeStoreOutOfGas) { evm.StateDB.RevertToSnapshot(snapshot) - exit := contract.Gas.Exit(err, initialStateGas) + exit := contract.Gas.Exit(err) if err != ErrExecutionReverted { if evm.Config.Tracer.HasGasHook() { evm.Config.Tracer.EmitGasChange(contract.Gas.AsTracing(), exit.AsTracing(), tracing.GasChangeCallFailedExecution) diff --git a/core/vm/gascosts.go b/core/vm/gascosts.go index 83d148b6ce4e..63269a071b91 100644 --- a/core/vm/gascosts.go +++ b/core/vm/gascosts.go @@ -251,13 +251,22 @@ func (g GasBudget) ExitRevert() GasBudget { } // ExitHalt produces the leftover form for an exceptional halt. Remaining -// regular gas is accounted as burned; the reservoir resets to initialStateGas -// (the value the caller held at the call site). UsedStateGas is zeroed -// because state-gas effects do not persist on halt. -func (g GasBudget) ExitHalt(initialStateGas uint64) GasBudget { +// regular gas is burned into UsedRegularGas; the state dimension follows the +// same revert-style formula as ExitRevert because the spec routes both halt +// and revert through incorporate_child_on_error: +// +// parent.state_gas_left = parent + child.state_gas_used + child.state_gas_left +// +// which in our model means returning `StateGas + UsedStateGas` to the parent +// and zeroing the per-frame counter. +func (g GasBudget) ExitHalt() GasBudget { + reservoir := int64(g.StateGas) + g.UsedStateGas + if reservoir < 0 { + reservoir = 0 + } return GasBudget{ RegularGas: 0, - StateGas: initialStateGas, + StateGas: uint64(reservoir), UsedRegularGas: g.UsedRegularGas + g.RegularGas, UsedStateGas: 0, } @@ -272,14 +281,14 @@ func (g GasBudget) ExitHalt(initialStateGas uint64) GasBudget { // // Soft validation failures (occurring BEFORE evm.Run) should call Preserved // directly instead of going through this dispatcher. -func (g GasBudget) Exit(err error, initialStateGas uint64) GasBudget { +func (g GasBudget) Exit(err error) GasBudget { switch { case err == nil: return g.ExitSuccess() case err == ErrExecutionReverted: return g.ExitRevert() default: - return g.ExitHalt(initialStateGas) + return g.ExitHalt() } } From 6e4f5710f926d37e16c61555a5bd21e4f95b49de Mon Sep 17 00:00:00 2001 From: MariusVanDerWijden Date: Thu, 21 May 2026 16:14:49 +0200 Subject: [PATCH 06/17] core/vm: use up gas _after_ the call --- core/vm/gascosts.go | 26 ++++++++++++++------------ core/vm/instructions.go | 29 +++++++++++++++++------------ 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/core/vm/gascosts.go b/core/vm/gascosts.go index 63269a071b91..0ae36ba12661 100644 --- a/core/vm/gascosts.go +++ b/core/vm/gascosts.go @@ -173,8 +173,7 @@ func (g *GasBudget) RefundRegular(s uint64) { // Forward drains `regular` regular gas and the entire state reservoir from // the parent's running budget and returns the initial GasBudget for a child -// frame. The parent's UsedRegularGas is bumped by the forwarded amount so -// that the absorb-on-return path correctly reclaims the unused portion. +// frame. // // Used by frame boundaries where the regular forward has NOT been pre- // deducted: tx-level dispatch (state_transition) and CREATE / CREATE2. The @@ -185,7 +184,6 @@ func (g *GasBudget) RefundRegular(s uint64) { // apply any EIP-150 1/64 retention before calling Forward. func (g *GasBudget) Forward(regular uint64) GasBudget { g.RegularGas -= regular - g.UsedRegularGas += regular child := GasBudget{ RegularGas: regular, @@ -293,18 +291,22 @@ func (g GasBudget) Exit(err error) GasBudget { } // Absorb merges a sub-call's leftover GasBudget into this (caller's) running -// budget. The caller's UsedRegularGas is reclaimed by the unused forwarded -// regular gas (which was pre-charged in full at call entry); the state -// reservoir is overwritten with the child's leftover; and the child's signed -// net state-gas usage is added to the caller's accumulator. +// budget. // -// Invariant maintained by all callers: at the moment of this call, the -// caller's UsedRegularGas already accounts for the FULL forwarded regular -// gas (as if the child had consumed all of it). On halt, child.RegularGas -// is 0 so the reclaim is a no-op. +// - RegularGas: the child's leftover regular gas flows back to the caller. +// - UsedRegularGas: the child's own gross regular-gas consumption is added +// to the caller's accumulator. Combined with the caller having NOT +// pre-bumped UsedRegularGas by the forwarded amount, this matches the +// spec's escrow_subcall_regular_gas + incorporate_child_* pattern: only +// opcode charges count towards regular_gas_used, never state-gas +// spillover or escrowed forwards. +// - StateGas: the reservoir is overwritten by the child's leftover (spec's +// incorporate_child_on_success / on_error formula for state_gas_left). +// - UsedStateGas: the child's signed net state-gas usage is added to the +// caller's accumulator (spec's incorporate child gas). func (g *GasBudget) Absorb(child GasBudget) { - g.UsedRegularGas -= child.RegularGas g.RegularGas += child.RegularGas + g.UsedRegularGas += child.UsedRegularGas g.StateGas = child.StateGas g.UsedStateGas += child.UsedStateGas } diff --git a/core/vm/instructions.go b/core/vm/instructions.go index d85fea5ee636..417c7efab91b 100644 --- a/core/vm/instructions.go +++ b/core/vm/instructions.go @@ -743,6 +743,12 @@ func opCall(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { if !value.IsZero() { gas += params.CallStipend } + // Escrow pattern (spec: escrow_subcall_regular_gas): undo the full + // forwarded gas — including the value-transfer stipend — from + // UsedRegularGas. The stipend is a free loan to the child and must + // not count toward the caller's regular gas usage; the child's own + // opcode charges are re-added via Absorb on return. + scope.Contract.Gas.UsedRegularGas -= gas // Regular gas for the forward was already pre-deducted by the dynamic // gas table (see makeCallVariantGasCallEIP*); only the state reservoir @@ -762,18 +768,10 @@ func opCall(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { } scope.Contract.refundGas(result, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) - // If the call frame reverts or halts exceptionally, the charged state-gas - // is refilled back to the state reservoir in Amsterdam. - // - // The state-gas should only be refunded if the state creation doesn't - // happens, such as ErrDepth, ErrInsufficientBalance. - // - // TODO(rjl) it's so ugly, please rework it. - if evm.chainRules.IsAmsterdam && err != nil { - if (err == ErrDepth || err == ErrInsufficientBalance) && !value.IsZero() && evm.StateDB.Empty(toAddr) { - scope.Contract.refundState(params.AccountCreationSize*evm.Context.CostPerStateByte, evm.Config.Tracer, tracing.GasChangeStateGasRefund) - } - } + // EIP-8037: CALL does not refund the new-account state_gas on + // ErrDepth/ErrInsufficientBalance — the spec only reclaims the forwarded + // reservoir; the state_gas charged for new account creation stays in + // state_gas_used (see Amsterdam spec call() / generic_call()). evm.returnData = ret return ret, nil @@ -794,6 +792,9 @@ func opCallCode(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { if !value.IsZero() { gas += params.CallStipend } + // Escrow pattern: undo the full forwarded gas (including stipend) from + // UsedRegularGas; the child's own opcode charges come back via Absorb. + scope.Contract.Gas.UsedRegularGas -= gas childBudget := NewGasBudget(gas, scope.Contract.Gas.StateGas) ret, result, err := evm.CallCode(scope.Contract.Address(), toAddr, args, childBudget, &value) @@ -825,6 +826,8 @@ func opDelegateCall(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { // Get arguments from the memory. args := scope.Memory.GetPtr(inOffset.Uint64(), inSize.Uint64()) + // Undo the forwarded gas from UsedRegularGas to prevent double-charging. + scope.Contract.Gas.UsedRegularGas -= gas childBudget := NewGasBudget(gas, scope.Contract.Gas.StateGas) ret, result, err := evm.DelegateCall(scope.Contract.Caller(), scope.Contract.Address(), toAddr, args, childBudget, scope.Contract.value) if err != nil { @@ -854,6 +857,8 @@ func opStaticCall(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { // Get arguments from the memory. args := scope.Memory.GetPtr(inOffset.Uint64(), inSize.Uint64()) + // Undo the forwarded gas from UsedRegularGas to prevent double-charging. + scope.Contract.Gas.UsedRegularGas -= gas childBudget := NewGasBudget(gas, scope.Contract.Gas.StateGas) ret, result, err := evm.StaticCall(scope.Contract.Address(), toAddr, args, childBudget) if err != nil { From 05f242939fe72af4dab83a50d829e1adad92ccf6 Mon Sep 17 00:00:00 2001 From: MariusVanDerWijden Date: Thu, 21 May 2026 16:15:49 +0200 Subject: [PATCH 07/17] core/vm: put account in BAL before transfer Might not actually be needed if spec is changed, see https://github.com/ethereum/EIPs/pull/11699 --- core/vm/evm.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/core/vm/evm.go b/core/vm/evm.go index a16e9c8cc273..22f5c7ebe392 100644 --- a/core/vm/evm.go +++ b/core/vm/evm.go @@ -261,6 +261,12 @@ func (evm *EVM) Call(caller common.Address, addr common.Address, input []byte, g } syscall := isSystemCall(caller) + // EIP-7928: per the Amsterdam spec, delegation resolution happens before + // the value-transfer check, so the delegated-to must appear in the BAL + // even when the call later reverts with ErrInsufficientBalance. Touch the + // target's code here (a no-op for non-delegated accounts) to record it. + evm.resolveCode(addr) + // Fail if we're trying to transfer more than the available balance. if !syscall && !value.IsZero() && !evm.Context.CanTransfer(evm.StateDB, caller, value) { return nil, gas.Preserved(), ErrInsufficientBalance @@ -347,6 +353,12 @@ func (evm *EVM) CallCode(caller common.Address, addr common.Address, input []byt if evm.depth > int(params.CallCreateDepth) { return nil, gas.Preserved(), ErrDepth } + + // EIP-7928: per the Amsterdam spec, delegation resolution happens before + // the value-transfer check, so the delegated-to must appear in the BAL + // even when the call later reverts with ErrInsufficientBalance. + evm.resolveCode(addr) + // Fail if we're trying to transfer more than the available balance if !evm.Context.CanTransfer(evm.StateDB, caller, value) { return nil, gas.Preserved(), ErrInsufficientBalance From 88c11627ea050ece8d2440cfc2d4ec52a733396c Mon Sep 17 00:00:00 2001 From: MariusVanDerWijden Date: Thu, 21 May 2026 16:16:13 +0200 Subject: [PATCH 08/17] tests: add BAL specific tests --- build/checksums.txt | 5 +++++ build/ci.go | 18 ++++++++++++++++++ tests/block_test.go | 15 ++++++++++++--- tests/init_test.go | 7 ++++--- 4 files changed, 39 insertions(+), 6 deletions(-) diff --git a/build/checksums.txt b/build/checksums.txt index 454efa93c478..d6a9f2b803b2 100644 --- a/build/checksums.txt +++ b/build/checksums.txt @@ -5,6 +5,11 @@ # https://github.com/ethereum/execution-spec-tests/releases/download/v5.1.0 a3192784375acec7eaec492799d5c5d0c47a2909a3cc40178898e4ecd20cc416 fixtures_develop.tar.gz +# version:spec-tests-bal v7.2.0 +# https://github.com/ethereum/execution-specs/releases +# https://github.com/ethereum/execution-specs/releases/download/tests-bal%40v7.2.0 +fc1d9ae174cdd5db789068839999e6f83666cc79f7dac36e973d7616d9a2e2cf fixtures_bal.tar.gz + # version:golang 1.25.10 # https://go.dev/dl/ 20cf04a92e5af99748e341bc8996fa28090c9ac98765fa115ec5ddf41d7af41d go1.25.10.src.tar.gz diff --git a/build/ci.go b/build/ci.go index 173a3280ce16..8948c2230e0e 100644 --- a/build/ci.go +++ b/build/ci.go @@ -164,6 +164,9 @@ var ( // This is where the tests should be unpacked. executionSpecTestsDir = "tests/spec-tests" + + // This is where the bal-specific release of the tests should be unpacked. + executionSpecTestsBALDir = "tests/spec-tests-bal" ) var GOBIN, _ = filepath.Abs(filepath.Join("build", "bin")) @@ -402,6 +405,7 @@ func doTest(cmdline []string) { // Get test fixtures. if !*short { downloadSpecTestFixtures(csdb, *cachedir) + downloadBALSpecTestFixtures(csdb, *cachedir) } // Configure the toolchain. @@ -467,6 +471,20 @@ func downloadSpecTestFixtures(csdb *download.ChecksumDB, cachedir string) string return filepath.Join(cachedir, base) } +// downloadBALSpecTestFixtures downloads and extracts the bal-specific execution-spec-tests fixtures. +func downloadBALSpecTestFixtures(csdb *download.ChecksumDB, cachedir string) string { + ext := ".tar.gz" + base := "fixtures_bal" + archivePath := filepath.Join(cachedir, base+ext) + if err := csdb.DownloadFileFromKnownURL(archivePath); err != nil { + log.Fatal(err) + } + if err := build.ExtractArchive(archivePath, executionSpecTestsBALDir); err != nil { + log.Fatal(err) + } + return filepath.Join(cachedir, base) +} + // doCheckGenerate ensures that re-generating generated files does not cause // any mutations in the source file tree. func doCheckGenerate() { diff --git a/tests/block_test.go b/tests/block_test.go index 0f087967bb68..31bb4f9d3681 100644 --- a/tests/block_test.go +++ b/tests/block_test.go @@ -82,8 +82,17 @@ func TestBlockchain(t *testing.T) { // TestExecutionSpecBlocktests runs the test fixtures from execution-spec-tests. func TestExecutionSpecBlocktests(t *testing.T) { - if !common.FileExist(executionSpecBlockchainTestDir) { - t.Skipf("directory %s does not exist", executionSpecBlockchainTestDir) + testExecutionSpecBlocktests(t, executionSpecBlockchainTestDir) +} + +// TestExecutionSpecBlocktestsBAL runs the BAL release test fixtures from execution-spec-tests. +func TestExecutionSpecBlocktestsBAL(t *testing.T) { + testExecutionSpecBlocktests(t, executionSpecBALBlockchainTestDir) +} + +func testExecutionSpecBlocktests(t *testing.T, testDir string) { + if !common.FileExist(testDir) { + t.Skipf("directory %s does not exist", testDir) } bt := new(testMatcher) @@ -97,7 +106,7 @@ func TestExecutionSpecBlocktests(t *testing.T) { bt.skipLoad(`dynamicAccountOverwriteEmpty_Paris`) bt.skipLoad(`create2collisionStorageParis`) - bt.walk(t, executionSpecBlockchainTestDir, func(t *testing.T, name string, test *BlockTest) { + bt.walk(t, testDir, func(t *testing.T, name string, test *BlockTest) { execBlockTest(t, bt, test) }) } diff --git a/tests/init_test.go b/tests/init_test.go index b933c9808cda..20c9e8e1c90b 100644 --- a/tests/init_test.go +++ b/tests/init_test.go @@ -41,9 +41,10 @@ var ( transactionTestDir = filepath.Join(baseDir, "TransactionTests") rlpTestDir = filepath.Join(baseDir, "RLPTests") difficultyTestDir = filepath.Join(baseDir, "BasicTests") - executionSpecBlockchainTestDir = filepath.Join(".", "spec-tests", "fixtures", "blockchain_tests") - executionSpecStateTestDir = filepath.Join(".", "spec-tests", "fixtures", "state_tests") - executionSpecTransactionTestDir = filepath.Join(".", "spec-tests", "fixtures", "transaction_tests") + executionSpecBlockchainTestDir = filepath.Join(".", "spec-tests", "fixtures", "blockchain_tests") + executionSpecBALBlockchainTestDir = filepath.Join(".", "spec-tests-bal", "fixtures", "blockchain_tests") + executionSpecStateTestDir = filepath.Join(".", "spec-tests", "fixtures", "state_tests") + executionSpecTransactionTestDir = filepath.Join(".", "spec-tests", "fixtures", "transaction_tests") benchmarksDir = filepath.Join(".", "evm-benchmarks", "benchmarks") ) From 27c8f4a19f8b65fb77af44a2a8dc3c5b23ac73f0 Mon Sep 17 00:00:00 2001 From: MariusVanDerWijden Date: Thu, 21 May 2026 18:43:14 +0200 Subject: [PATCH 09/17] beacon/engine: fix marshalling --- beacon/engine/ed_codec.go | 7 +++---- beacon/engine/types.go | 20 +++++++++++++++----- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/beacon/engine/ed_codec.go b/beacon/engine/ed_codec.go index 02a1fd380539..83603616a715 100644 --- a/beacon/engine/ed_codec.go +++ b/beacon/engine/ed_codec.go @@ -10,7 +10,6 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/core/types/bal" ) var _ = (*executableDataMarshaling)(nil) @@ -36,7 +35,7 @@ func (e ExecutableData) MarshalJSON() ([]byte, error) { BlobGasUsed *hexutil.Uint64 `json:"blobGasUsed"` ExcessBlobGas *hexutil.Uint64 `json:"excessBlobGas"` SlotNumber *hexutil.Uint64 `json:"slotNumber,omitempty"` - BlockAccessList *bal.BlockAccessList `json:"blockAccessList,omitempty"` + BlockAccessList hexutil.Bytes `json:"blockAccessList,omitempty"` } var enc ExecutableData enc.ParentHash = e.ParentHash @@ -87,7 +86,7 @@ func (e *ExecutableData) UnmarshalJSON(input []byte) error { BlobGasUsed *hexutil.Uint64 `json:"blobGasUsed"` ExcessBlobGas *hexutil.Uint64 `json:"excessBlobGas"` SlotNumber *hexutil.Uint64 `json:"slotNumber,omitempty"` - BlockAccessList *bal.BlockAccessList `json:"blockAccessList,omitempty"` + BlockAccessList *hexutil.Bytes `json:"blockAccessList,omitempty"` } var dec ExecutableData if err := json.Unmarshal(input, &dec); err != nil { @@ -165,7 +164,7 @@ func (e *ExecutableData) UnmarshalJSON(input []byte) error { e.SlotNumber = (*uint64)(dec.SlotNumber) } if dec.BlockAccessList != nil { - e.BlockAccessList = dec.BlockAccessList + e.BlockAccessList = *dec.BlockAccessList } return nil } diff --git a/beacon/engine/types.go b/beacon/engine/types.go index 60d564b8779f..c5414f0a07f5 100644 --- a/beacon/engine/types.go +++ b/beacon/engine/types.go @@ -17,6 +17,7 @@ package engine import ( + "bytes" "fmt" "math/big" "slices" @@ -24,8 +25,9 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/core/types/bal" + "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/params" + "github.com/ethereum/go-ethereum/rlp" "github.com/ethereum/go-ethereum/trie" ) @@ -101,7 +103,7 @@ type ExecutableData struct { BlobGasUsed *uint64 `json:"blobGasUsed"` ExcessBlobGas *uint64 `json:"excessBlobGas"` SlotNumber *uint64 `json:"slotNumber,omitempty"` - BlockAccessList *bal.BlockAccessList `json:"blockAccessList,omitempty"` + BlockAccessList hexutil.Bytes `json:"blockAccessList,omitempty"` } // JSON type overrides for executableData. @@ -314,13 +316,14 @@ func ExecutableDataToBlockNoHash(data ExecutableData, versionedHashes []common.H } // If Amsterdam is enabled, data.BlockAccessList is always non-nil, - // even for empty blocks with no state transitions. + // even for empty blocks with no state transitions. The wire format is + // the RLP-encoded access list; the header hash is keccak256(rlp). // // If Amsterdam is not enabled yet, blockAccessListHash is expected // to be nil. var blockAccessListHash *common.Hash if data.BlockAccessList != nil { - hash := data.BlockAccessList.Hash() + hash := crypto.Keccak256Hash(data.BlockAccessList) blockAccessListHash = &hash } header := &types.Header{ @@ -372,7 +375,14 @@ func BlockToExecutableData(block *types.Block, fees *big.Int, sidecars []*types. BlobGasUsed: block.BlobGasUsed(), ExcessBlobGas: block.ExcessBlobGas(), SlotNumber: block.SlotNumber(), - BlockAccessList: block.AccessList(), + } + // Per Engine API spec (Amsterdam): blockAccessList is the RLP-encoded + // access list, serialized as a hex string. Encode it to bytes here. + if al := block.AccessList(); al != nil { + var buf bytes.Buffer + if err := rlp.Encode(&buf, al); err == nil { + data.BlockAccessList = buf.Bytes() + } } // Add blobs. From 3f0c06c527e37d7cb5aa004ca2798b2a533e904c Mon Sep 17 00:00:00 2001 From: MariusVanDerWijden Date: Thu, 21 May 2026 20:57:34 +0200 Subject: [PATCH 10/17] cmd: add --bal.executionmode flag (still noop) --- cmd/geth/main.go | 1 + cmd/utils/flags.go | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/cmd/geth/main.go b/cmd/geth/main.go index 850e26d1618c..0f88dbbcf105 100644 --- a/cmd/geth/main.go +++ b/cmd/geth/main.go @@ -95,6 +95,7 @@ var ( utils.BinTrieGroupDepthFlag, utils.LightKDFFlag, utils.EthRequiredBlocksFlag, + utils.BALExecutionModeFlag, utils.LegacyWhitelistFlag, // deprecated utils.CacheFlag, utils.CacheDatabaseFlag, diff --git a/cmd/utils/flags.go b/cmd/utils/flags.go index c41cf4ee40e9..ad70d48c2d73 100644 --- a/cmd/utils/flags.go +++ b/cmd/utils/flags.go @@ -243,6 +243,11 @@ var ( Usage: "Comma separated block number-to-hash mappings to require for peering (=)", Category: flags.EthCategory, } + BALExecutionModeFlag = &cli.StringFlag{ + Name: "bal.executionmode", + Usage: "EIP-7928 block-access-list execution mode (no-op placeholder)", + Category: flags.EthCategory, + } BloomFilterSizeFlag = &cli.Uint64Flag{ Name: "bloomfilter.size", Usage: "Megabytes of memory allocated to bloom-filter for pruning", From e9f3b1735bc741e4bb245b62b21e4c66ff36ffb1 Mon Sep 17 00:00:00 2001 From: Jared Wasinger Date: Mon, 1 Jun 2026 18:41:58 -0400 Subject: [PATCH 11/17] core: implement optimized bal execution path --- cmd/evm/blockrunner.go | 2 +- cmd/geth/config.go | 23 ++ cmd/geth/main.go | 2 + cmd/utils/flags.go | 18 + core/block_validator.go | 13 +- core/blockchain.go | 150 +++++++- core/parallel_state_processor.go | 329 ++++++++++++++++++ core/state/bal_state_transition.go | 530 +++++++++++++++++++++++++++++ core/state/database.go | 10 + core/state/database_history.go | 4 + core/state/database_mpt.go | 19 ++ core/state/database_ubt.go | 4 + core/state/reader.go | 9 + core/state/reader_eip_7928.go | 136 +++++--- core/state/statedb.go | 82 ++++- core/state_transition.go | 32 +- core/types.go | 4 +- core/types/bal/bal.go | 135 ++++++++ core/types/bal/bal_encoding.go | 8 +- core/types/bal/bal_reader.go | 114 +++++++ eth/backend.go | 4 + eth/ethconfig/config.go | 5 + tests/block_test.go | 2 +- tests/block_test_util.go | 80 ++++- trie/bintrie/trie.go | 8 + trie/secure_trie.go | 46 +++ trie/tracer.go | 19 +- trie/transitiontrie/transition.go | 29 ++ trie/trie.go | 63 ++++ trie/trie_test.go | 54 +++ 30 files changed, 1834 insertions(+), 100 deletions(-) create mode 100644 core/parallel_state_processor.go create mode 100644 core/state/bal_state_transition.go create mode 100644 core/types/bal/bal_reader.go diff --git a/cmd/evm/blockrunner.go b/cmd/evm/blockrunner.go index c6fac5396e0c..070bdf4c5512 100644 --- a/cmd/evm/blockrunner.go +++ b/cmd/evm/blockrunner.go @@ -117,7 +117,7 @@ func runBlockTest(ctx *cli.Context, fname string) ([]testResult, error) { test := tests[name] result := &testResult{Name: name, Pass: true} var finalRoot *common.Hash - if err := test.Run(false, rawdb.PathScheme, ctx.Bool(WitnessCrossCheckFlag.Name), tracer, func(res error, chain *core.BlockChain) { + if err := test.Run(false, rawdb.PathScheme, ctx.Bool(WitnessCrossCheckFlag.Name), true, tracer, func(res error, chain *core.BlockChain) { if ctx.Bool(DumpFlag.Name) { if s, _ := chain.State(); s != nil { result.State = dump(s) diff --git a/cmd/geth/config.go b/cmd/geth/config.go index c02e307bdc49..a6f42ee32779 100644 --- a/cmd/geth/config.go +++ b/cmd/geth/config.go @@ -20,6 +20,7 @@ import ( "bufio" "errors" "fmt" + "github.com/ethereum/go-ethereum/core/types/bal" "os" "reflect" "runtime" @@ -241,6 +242,28 @@ func makeFullNode(ctx *cli.Context) *node.Node { cfg.Eth.OverrideUBT = &v } + if ctx.IsSet(utils.BALExecutionModeFlag.Name) { + val := ctx.String(utils.BALExecutionModeFlag.Name) + switch val { + case utils.BalExecutionModeOptimized: + cfg.Eth.BALExecutionMode = bal.BALExecutionOptimized + case utils.BalExecutionModeNoBatchIO: + cfg.Eth.BALExecutionMode = bal.BALExecutionNoBatchIO + case utils.BalExecutionModeSequential: + cfg.Eth.BALExecutionMode = bal.BALExecutionSequential + default: + utils.Fatalf("invalid option for --bal.executionmode: %s. acceptable values are full|nobatchio|sequential", val) + } + } + cfg.Eth.BlockingPrefetch = ctx.Bool(utils.BlockingPrefetchFlag.Name) + + prefetchWorkers := ctx.Uint(utils.PrefetchWorkersFlag.Name) + if ctx.IsSet(utils.PrefetchWorkersFlag.Name) && prefetchWorkers == 0 { + prefetchWorkers = uint(runtime.NumCPU()) + log.Warn(fmt.Sprintf("invalid value for --bal.prefetchworkers. got 0. sanitizing to %d", prefetchWorkers)) + } + cfg.Eth.PrefetchWorkers = prefetchWorkers + // Start metrics export if enabled. utils.SetupMetrics(&cfg.Metrics) diff --git a/cmd/geth/main.go b/cmd/geth/main.go index 0f88dbbcf105..36942189afdc 100644 --- a/cmd/geth/main.go +++ b/cmd/geth/main.go @@ -96,6 +96,8 @@ var ( utils.LightKDFFlag, utils.EthRequiredBlocksFlag, utils.BALExecutionModeFlag, + utils.PrefetchWorkersFlag, + utils.BlockingPrefetchFlag, utils.LegacyWhitelistFlag, // deprecated utils.CacheFlag, utils.CacheDatabaseFlag, diff --git a/cmd/utils/flags.go b/cmd/utils/flags.go index ad70d48c2d73..66018186740d 100644 --- a/cmd/utils/flags.go +++ b/cmd/utils/flags.go @@ -28,6 +28,7 @@ import ( "net/http" "os" "path/filepath" + "runtime" godebug "runtime/debug" "strconv" "strings" @@ -248,6 +249,17 @@ var ( Usage: "EIP-7928 block-access-list execution mode (no-op placeholder)", Category: flags.EthCategory, } + PrefetchWorkersFlag = &cli.UintFlag{ + Name: "bal.prefetchworkers", + Usage: "The number of concurrent state loading tasks to perform when prefetching BAL state. Default to the number of cpus", + Value: uint(runtime.NumCPU()), + Category: flags.MiscCategory, + } + BlockingPrefetchFlag = &cli.BoolFlag{ + Name: "bal.blockingprefetch", + Usage: "only relevant when executing in parallel with a BAL: if true, the prefetcher will block tx/state-root calculation until all scheduled fetching tasks have completed.", + Category: flags.MiscCategory, + } BloomFilterSizeFlag = &cli.Uint64Flag{ Name: "bloomfilter.size", Usage: "Megabytes of memory allocated to bloom-filter for pruning", @@ -1119,6 +1131,12 @@ Please note that --` + MetricsHTTPFlag.Name + ` must be set to start the server. } ) +const ( + BalExecutionModeOptimized = "full" + BalExecutionModeNoBatchIO = "nobatchio" + BalExecutionModeSequential = "sequential" +) + var ( // TestnetFlags is the flag group of all built-in supported testnets. TestnetFlags = []cli.Flag{ diff --git a/core/block_validator.go b/core/block_validator.go index 962fffb82a0e..f2df77a27d1c 100644 --- a/core/block_validator.go +++ b/core/block_validator.go @@ -19,9 +19,9 @@ package core import ( "errors" "fmt" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/consensus" - "github.com/ethereum/go-ethereum/core/state" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/params" "github.com/ethereum/go-ethereum/trie" @@ -143,9 +143,14 @@ func (v *BlockValidator) ValidateBody(block *types.Block) error { return nil } +type StateRootSource interface { + IntermediateRoot(deleteEmptyObjects bool) common.Hash + Error() error +} + // ValidateState validates the various changes that happen after a state transition, // such as amount of used gas, the receipt roots and the state root itself. -func (v *BlockValidator) ValidateState(block *types.Block, statedb *state.StateDB, res *ProcessResult, stateless bool) error { +func (v *BlockValidator) ValidateState(block *types.Block, state StateRootSource, res *ProcessResult, stateless bool) error { if res == nil { return errors.New("nil ProcessResult value") } @@ -201,8 +206,8 @@ func (v *BlockValidator) ValidateState(block *types.Block, statedb *state.StateD } // Validate the state root against the received state root and throw // an error if they don't match. - if root := statedb.IntermediateRoot(v.config.IsEIP158(header.Number)); header.Root != root { - return fmt.Errorf("invalid merkle root (remote: %x local: %x) dberr: %w", header.Root, root, statedb.Error()) + if root := state.IntermediateRoot(v.config.IsEIP158(header.Number)); header.Root != root { + return fmt.Errorf("invalid merkle root (remote: %x local: %x) dberr: %w", header.Root, root, state.Error()) } return nil } diff --git a/core/blockchain.go b/core/blockchain.go index eb4ea7ebc1fe..29eca4800d7d 100644 --- a/core/blockchain.go +++ b/core/blockchain.go @@ -21,6 +21,7 @@ import ( "context" "errors" "fmt" + "github.com/ethereum/go-ethereum/core/types/bal" "io" "math/big" "runtime" @@ -221,6 +222,10 @@ type BlockChainConfig struct { // Execution configs StatelessSelfValidation bool // Generate execution witnesses and self-check against them (testing purpose) EnableWitnessStats bool // Whether trie access statistics collection is enabled + + BALExecutionMode bal.BALExecutionMode + BlockingPrefetch bool + PrefetchWorkers int } // DefaultConfig returns the default config. @@ -360,12 +365,13 @@ type BlockChain struct { stopping atomic.Bool // false if chain is running, true when stopped procInterrupt atomic.Bool // interrupt signaler for block processing - engine consensus.Engine - validator Validator // Block and state validator interface - prefetcher Prefetcher - processor Processor // Block transaction processor interface - logger *tracing.Hooks - stateSizer *state.SizeTracker // State size tracking + engine consensus.Engine + validator Validator // Block and state validator interface + prefetcher Prefetcher + processor Processor // Block transaction processor interface + parallelProcessor ParallelStateProcessor // block processor for use with access lists + logger *tracing.Hooks + stateSizer *state.SizeTracker // State size tracking lastForkReadyAlert time.Time // Last time there was a fork readiness print out slowBlockThreshold time.Duration // Block execution time threshold beyond which detailed statistics will be logged @@ -427,6 +433,7 @@ func NewBlockChain(db ethdb.Database, genesis *Genesis, engine consensus.Engine, bc.validator = NewBlockValidator(chainConfig, bc) bc.prefetcher = newStatePrefetcher(chainConfig, bc.hc) bc.processor = NewStateProcessor(bc.hc) + bc.parallelProcessor = NewParallelStateProcessor(bc.hc, bc.GetVMConfig()) genesisHeader := bc.GetHeaderByNumber(0) if genesisHeader == nil { @@ -1642,7 +1649,7 @@ func (bc *BlockChain) writeKnownBlock(block *types.Block) error { // writeBlockWithState writes block, metadata and corresponding state data to the // database. -func (bc *BlockChain) writeBlockWithState(block *types.Block, receipts []*types.Receipt, statedb *state.StateDB) error { +func (bc *BlockChain) writeBlockWithState(block *types.Block, receipts []*types.Receipt, statedb state.Committer) error { if !bc.HasHeader(block.ParentHash(), block.NumberU64()-1) { return consensus.ErrUnknownAncestor } @@ -1756,7 +1763,7 @@ func (bc *BlockChain) writeBlockWithState(block *types.Block, receipts []*types. // writeBlockAndSetHead is the internal implementation of WriteBlockAndSetHead. // This function expects the chain mutex to be held. -func (bc *BlockChain) writeBlockAndSetHead(block *types.Block, receipts []*types.Receipt, logs []*types.Log, state *state.StateDB, emitHeadEvent bool) (status WriteStatus, err error) { +func (bc *BlockChain) writeBlockAndSetHead(block *types.Block, receipts []*types.Receipt, logs []*types.Log, state state.Committer, emitHeadEvent bool) (status WriteStatus, err error) { if err := bc.writeBlockWithState(block, receipts, state); err != nil { return NonStatTy, err } @@ -2111,16 +2118,133 @@ type ExecuteConfig struct { EnableWitnessStats bool } +func (bc *BlockChain) processBlockWithAccessList(parentRoot common.Hash, block *types.Block, setHead bool) (procRes *blockProcessingResult, blockEndErr error) { + var ( + startTime = time.Now() + procTime time.Duration + statedb *state.StateDB + ) + + sdb := state.NewMPTDatabase(bc.triedb, bc.codedb).WithSnapshot(bc.snaps) + + useAsyncReads := bc.cfg.BALExecutionMode != bal.BALExecutionNoBatchIO + al := block.AccessList() + accessListReader := bal.NewAccessListReader(*al) + prefetchReader, err := sdb.ReaderWithPrefetch(parentRoot, accessListReader.StorageKeys(useAsyncReads), bc.cfg.PrefetchWorkers, bc.cfg.BlockingPrefetch) + if err != nil { + return nil, err + } + + stateTransition, err := state.NewBALStateTransition(block, prefetchReader, sdb, parentRoot) + if err != nil { + return nil, err + } + statedb, err = state.NewWithReader(parentRoot, sdb, prefetchReader) + if err != nil { + return nil, err + } + + if bc.logger != nil && bc.logger.OnBlockStart != nil { + bc.logger.OnBlockStart(tracing.BlockEvent{ + Block: block, + Finalized: bc.CurrentFinalBlock(), + Safe: bc.CurrentSafeBlock(), + }) + } + if bc.logger != nil && bc.logger.OnBlockEnd != nil { + defer func() { + bc.logger.OnBlockEnd(blockEndErr) + }() + } + + res, err := bc.parallelProcessor.Process(block, stateTransition, statedb, bc.cfg.VmConfig) + if err != nil { + return nil, err + } + + if err := bc.validator.ValidateState(block, stateTransition, res.ProcessResult, false); err != nil { + return nil, err + } + + procTime = time.Since(startTime) + writeStart := time.Now() + // Write the block to the chain and get the status. + var ( + //wstart = time.Now() + status WriteStatus + ) + if !setHead { + // Don't set the head, only insert the block + err = bc.writeBlockWithState(block, res.ProcessResult.Receipts, stateTransition) + } else { + status, err = bc.writeBlockAndSetHead(block, res.ProcessResult.Receipts, res.ProcessResult.Logs, stateTransition, false) + } + if err != nil { + return nil, err + } + writeTime := time.Since(writeStart) + var stats ExecuteStats + + wc := stateTransition.WrittenCounts() + d := stateTransition.Deletions() + //codeLoaded, codeLoadBytes := prefetchReader.(state.CodeLoadTracker).CodeLoads() + //stats.AccountLoaded = al.UniqueAccountCount() + stats.AccountUpdated = wc.Accounts - d.Accounts + stats.AccountDeleted = d.Accounts + //stats.StorageLoaded = al.UniqueStorageSlotCount() + stats.StorageUpdated = wc.StorageSlots - d.Storage + stats.StorageDeleted = d.Storage + //stats.CodeLoaded = codeLoaded + //stats.CodeLoadBytes = codeLoadBytes + stats.CodeUpdated = wc.Codes + stats.CodeUpdateBytes = wc.CodeBytes + + //stats.ExecWall = res.ExecTime + //stats.PostProcess = res.PostProcessTime + + if m := res.StateTransitionMetrics; m != nil { + stats.AccountHashes = m.AccountUpdate + m.StateUpdate + m.StateHash + stats.AccountCommits = m.AccountCommits + stats.StorageCommits = m.StorageCommits + stats.DatabaseCommit = m.TrieDBCommits + //stats.Prefetch = m.StatePrefetch + } + //stats.Prefetch = prefetchReader.(state.PrefetcherMetricer).Metrics().Elapsed + + stats.StateReadCacheStats = prefetchReader.(state.ReaderStater).GetStats() + + elapsed := time.Since(startTime) + 1 // prevent zero division + stats.TotalTime = elapsed + stats.MgasPerSecond = float64(res.ProcessResult.GasUsed) * 1000 / float64(elapsed) + stats.BlockWrite = writeTime + + // TODO: reinstate + //stats.balTransitionStats = res.StateTransitionMetrics + + return &blockProcessingResult{ + usedGas: res.ProcessResult.GasUsed, + procTime: procTime, + status: status, + witness: nil, + stats: &stats, + }, nil +} + // ProcessBlock executes and validates the given block. If there was no error // it writes the block and associated state to database. func (bc *BlockChain) ProcessBlock(ctx context.Context, parentRoot common.Hash, block *types.Block, config ExecuteConfig) (result *blockProcessingResult, blockEndErr error) { var ( - err error - startTime = time.Now() - statedb *state.StateDB - interrupt atomic.Bool - sdb state.Database + err error + startTime = time.Now() + statedb *state.StateDB + interrupt atomic.Bool + sdb state.Database + blockHasAccessList = block.AccessList() != nil ) + + if blockHasAccessList && bc.cfg.BALExecutionMode != bal.BALExecutionSequential { + return bc.processBlockWithAccessList(parentRoot, block, config.WriteHead) + } defer interrupt.Store(true) // terminate the prefetch at the end if bc.chainConfig.IsUBT(block.Number(), block.Time()) { diff --git a/core/parallel_state_processor.go b/core/parallel_state_processor.go new file mode 100644 index 000000000000..3407aabad780 --- /dev/null +++ b/core/parallel_state_processor.go @@ -0,0 +1,329 @@ +package core + +import ( + "cmp" + "context" + "fmt" + "runtime" + "slices" + "time" + + "github.com/ethereum/go-ethereum/core/state" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/core/types/bal" + "github.com/ethereum/go-ethereum/core/vm" + "golang.org/x/sync/errgroup" +) + +// ProcessResultWithMetrics wraps ProcessResult with timing breakdown for BAL block processing. +type ProcessResultWithMetrics struct { + ProcessResult *ProcessResult + PreProcessTime time.Duration + StateTransitionMetrics *state.BALStateTransitionMetrics + ExecTime time.Duration + PostProcessTime time.Duration +} + +// ParallelStateProcessor is used to execute and verify blocks containing +// access lists. +type ParallelStateProcessor struct { + *StateProcessor + vmCfg *vm.Config +} + +// NewParallelStateProcessor returns a new ParallelStateProcessor instance. +func NewParallelStateProcessor(chain *HeaderChain, vmConfig *vm.Config) ParallelStateProcessor { + res := NewStateProcessor(chain) + return ParallelStateProcessor{ + res, + vmConfig, + } +} + +// called by resultHandler when all transactions have successfully executed. +// performs post-tx state transition (system contracts and withdrawals) +// and calculates the ProcessResult, returning it to be sent on resCh +// by resultHandler +func (p *ParallelStateProcessor) prepareExecResult(block *types.Block, tExecStart time.Time, preTxBal *bal.ConstructionBlockAccessList, statedb *state.StateDB, results []txExecResult) *ProcessResultWithMetrics { + tExec := time.Since(tExecStart) + tPostprocessStart := time.Now() + header := block.Header() + + vmContext := NewEVMBlockContext(header, p.chain, nil) + lastBALIdx := len(block.Transactions()) + 1 + postTxState := statedb.WithReader(state.NewReaderWithBlockLevelAccessList(statedb.Reader(), *block.AccessList(), lastBALIdx)) + + cfg := vm.Config{ + NoBaseFee: p.vmCfg.NoBaseFee, + EnablePreimageRecording: p.vmCfg.EnablePreimageRecording, + ExtraEips: slices.Clone(p.vmCfg.ExtraEips), + } + evm := vm.NewEVM(vmContext, postTxState, p.chainConfig(), cfg) + + // 1. order the receipts by tx index + // 2. correctly calculate the cumulative gas used per receipt, returning bad block error if it goes over the allowed + slices.SortFunc(results, func(a, b txExecResult) int { + return cmp.Compare(a.receipt.TransactionIndex, b.receipt.TransactionIndex) + }) + + var ( + // Per-dimension cumulative sums for 2D block gas (EIP-8037). + sumRegular uint64 + sumState uint64 + cumulativeReceipt uint64 // cumulative receipt gas (what users pay) + ) + + var allLogs []*types.Log + var allReceipts []*types.Receipt + for _, result := range results { + sumRegular += result.txRegular + sumState += result.txState + + cumulativeReceipt += result.execGas + result.receipt.CumulativeGasUsed = cumulativeReceipt + allLogs = append(allLogs, result.receipt.Logs...) + allReceipts = append(allReceipts, result.receipt) + } + // Block gas = max(sum_regular, sum_state) per EIP-8037. + blockGasUsed := max(sumRegular, sumState) + if blockGasUsed > header.GasLimit { + return &ProcessResultWithMetrics{ + ProcessResult: &ProcessResult{Error: fmt.Errorf("gas limit exceeded")}, + } + } + + requests, postBal, err := PostExecution(context.Background(), p.chainConfig(), block.Number(), block.Time(), allLogs, evm, uint32(len(block.Transactions())+1)) + if err != nil { + return &ProcessResultWithMetrics{ + ProcessResult: &ProcessResult{Error: err}, + } + } + + p.chain.Engine().Finalize(p.chain, block.Header(), evm.StateDB, block.Body(), uint32(len(block.Transactions()))+1, postBal) + + blockAccessList := bal.NewConstructionBlockAccessList() + blockAccessList.Merge(preTxBal) + blockAccessList.Merge(postBal) + + for _, res := range results { + blockAccessList.Merge(res.blockAccessList) + } + + // TODO: do we move validation to ValidateState? + if block.AccessList().Hash() != blockAccessList.ToEncodingObj().Hash() { + // TODO: expose json string method on encoding block access list and log it here + return &ProcessResultWithMetrics{ + ProcessResult: &ProcessResult{Error: fmt.Errorf("invalid block access list: mismatch between local and remote block access list")}, + } + } + + tPostprocess := time.Since(tPostprocessStart) + + return &ProcessResultWithMetrics{ + ProcessResult: &ProcessResult{ + Receipts: allReceipts, + Requests: requests, + Logs: allLogs, + GasUsed: blockGasUsed, + Bal: blockAccessList, + }, + PostProcessTime: tPostprocess, + ExecTime: tExec, + } +} + +type txExecResult struct { + idx int // transaction index + receipt *types.Receipt + err error // non-EVM error which would render the block invalid + blockGas uint64 + execGas uint64 + + // Per-tx dimensional gas for Amsterdam 2D gas accounting (EIP-8037). + txRegular uint64 + txState uint64 + + blockAccessList *bal.ConstructionBlockAccessList +} + +// resultHandler polls until all transactions have finished executing and the +// state root calculation is complete. The result is emitted on resCh. +func (p *ParallelStateProcessor) resultHandler(block *types.Block, preTxBAL *bal.ConstructionBlockAccessList, statedb *state.StateDB, tExecStart time.Time, txResCh <-chan txExecResult, stateRootCalcResCh <-chan stateRootCalculationResult, resCh chan *ProcessResultWithMetrics) { + // 1. if the block has transactions, receive the execution results from all of them and return an error on resCh if any txs err'd + // 2. once all txs are executed, compute the post-tx state transition and produce the ProcessResult sending it on resCh (or an error if the post-tx state didn't match what is reported in the BAL) + var results []txExecResult + var cumulativeStateGas, cumulativeRegularGas uint64 + var execErr error + var numTxComplete int + + if len(block.Transactions()) > 0 { + loop: + for { + select { + case res := <-txResCh: + numTxComplete++ + if execErr == nil { + // short-circuit if invalid block was detected + if res.err != nil { + execErr = res.err + } else if bottleneck := max(cumulativeRegularGas+res.txRegular, cumulativeStateGas+res.txState); bottleneck > block.GasLimit() { + execErr = fmt.Errorf("block used too much gas in bottleneck dimension: %d. block gas limit is %d", bottleneck, block.GasLimit()) + } else { + cumulativeStateGas += res.txState + results = append(results, res) + } + } + if numTxComplete == len(block.Transactions()) { + break loop + } + } + } + + if execErr != nil { + // Drain stateRootCalcResCh so calcAndVerifyRoot goroutine can exit. + <-stateRootCalcResCh + resCh <- &ProcessResultWithMetrics{ProcessResult: &ProcessResult{Error: execErr}} + return + } + } + + execResults := p.prepareExecResult(block, tExecStart, preTxBAL, statedb, results) + rootCalcRes := <-stateRootCalcResCh + + if execResults.ProcessResult.Error != nil { + resCh <- execResults + } else if rootCalcRes.err != nil { + resCh <- &ProcessResultWithMetrics{ProcessResult: &ProcessResult{Error: rootCalcRes.err}} + } else { + execResults.StateTransitionMetrics = rootCalcRes.metrics + resCh <- execResults + } +} + +type stateRootCalculationResult struct { + err error + metrics *state.BALStateTransitionMetrics +} + +// calcAndVerifyRoot performs the post-state root hash calculation, verifying +// it against what is reported by the block and returning a result on resCh. +func (p *ParallelStateProcessor) calcAndVerifyRoot(block *types.Block, stateTransition *state.BALStateTransition, resCh chan stateRootCalculationResult) { + root := stateTransition.IntermediateRoot(false) + + res := stateRootCalculationResult{ + metrics: stateTransition.Metrics(), + } + + if root != block.Root() { + res.err = fmt.Errorf("state root mismatch. local: %x. remote: %x", root, block.Root()) + } + resCh <- res +} + +// execTx executes single transaction returning a result which includes state accessed/modified +func (p *ParallelStateProcessor) execTx(block *types.Block, tx *types.Transaction, balIdx int, db *state.StateDB, signer types.Signer) *txExecResult { + header := block.Header() + context := NewEVMBlockContext(header, p.chain, nil) + + cfg := vm.Config{ + NoBaseFee: p.vmCfg.NoBaseFee, + EnablePreimageRecording: p.vmCfg.EnablePreimageRecording, + ExtraEips: slices.Clone(p.vmCfg.ExtraEips), + } + evm := vm.NewEVM(context, db, p.chainConfig(), cfg) + + msg, err := TransactionToMessage(tx, signer, header.BaseFee) + if err != nil { + err = fmt.Errorf("could not apply tx %d [%v]: %w", balIdx, tx.Hash().Hex(), err) + return &txExecResult{err: err} + } + gp := NewGasPool(block.GasLimit()) + sender, err := signer.Sender(tx) + if err != nil { + // TODO: can this even happen at this stage? + err = fmt.Errorf("could not recover sender for tx at bal idx %d: %v\n", balIdx, err) + } + // TODO: make precompiled addresses be resolvable from chain config + block + db.Prepare(evm.GetRules(), sender, block.Coinbase(), tx.To(), vm.PrecompiledAddressesCancun, tx.AccessList()) + + db.SetTxContext(tx.Hash(), balIdx-1, uint32(balIdx)) + + receipt, txBAL, err := ApplyTransactionWithEVM(msg, gp, db, block.Number(), block.Hash(), context.Time, tx, evm) + if err != nil { + err := fmt.Errorf("could not apply tx %d [%v]: %w", balIdx, tx.Hash().Hex(), err) + return &txExecResult{err: err} + } + + return &txExecResult{ + idx: balIdx, + receipt: receipt, + execGas: receipt.GasUsed, + blockGas: gp.Used(), + txRegular: gp.cumulativeRegular, + txState: gp.cumulativeState, + blockAccessList: txBAL, + } +} + +func (p *ParallelStateProcessor) processBlockPreTx(block *types.Block, statedb *state.StateDB, cfg vm.Config) (*bal.ConstructionBlockAccessList, error) { + var ( + header = block.Header() + ) + vmContext := NewEVMBlockContext(header, p.chain, nil) + evm := vm.NewEVM(vmContext, statedb, p.chainConfig(), cfg) + + accessList := PreExecution(context.Background(), block.BeaconRoot(), block.ParentHash(), p.chainConfig(), evm, block.Number(), block.Time()) + return accessList, nil +} + +// Process performs EVM execution and state root computation for a block which is known +// to contain an access list. +func (p *ParallelStateProcessor) Process(block *types.Block, stateTransition *state.BALStateTransition, statedb *state.StateDB, cfg vm.Config) (*ProcessResultWithMetrics, error) { + var ( + header = block.Header() + resCh = make(chan *ProcessResultWithMetrics) + signer = types.MakeSigner(p.chainConfig(), header.Number, header.Time) + rootCalcResultCh = make(chan stateRootCalculationResult) + txResCh = make(chan txExecResult) + + pStart = time.Now() + tExecStart time.Time + tPreprocess time.Duration // time to create a set of prestates for parallel transaction execution + ) + + startingState := statedb.Copy() + preTxBal, err := p.processBlockPreTx(block, statedb, cfg) + if err != nil { + return nil, err + } + + // compute the reads/mutations at the last bal index + tPreprocess = time.Since(pStart) + + // execute transactions and state root calculation in parallel + tExecStart = time.Now() + go p.resultHandler(block, preTxBal, statedb, tExecStart, txResCh, rootCalcResultCh, resCh) + var workers errgroup.Group + workers.SetLimit(runtime.NumCPU()) + for i, t := range block.Transactions() { + tx := t + idx := i + sdb := startingState.Copy() + workers.Go(func() error { + startingState := sdb.WithReader(state.NewReaderWithBlockLevelAccessList(statedb.Reader(), *block.AccessList(), idx+1)) + res := p.execTx(block, tx, idx+1, startingState, signer) + txResCh <- *res + return nil + }) + } + + go p.calcAndVerifyRoot(block, stateTransition, rootCalcResultCh) + + res := <-resCh + if res.ProcessResult.Error != nil { + return nil, res.ProcessResult.Error + } + // TODO: remove preprocess metric ? + res.PreProcessTime = tPreprocess + return res, nil +} diff --git a/core/state/bal_state_transition.go b/core/state/bal_state_transition.go new file mode 100644 index 000000000000..de643ab0d1b9 --- /dev/null +++ b/core/state/bal_state_transition.go @@ -0,0 +1,530 @@ +package state + +import ( + "slices" + "sync" + "sync/atomic" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/core/types/bal" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/trie/trienode" + "github.com/holiman/uint256" + "golang.org/x/sync/errgroup" +) + +// BALStateTransition is responsible for performing the state root update +// and commit for EIP 7928 access-list-containing blocks. An instance of +// this object is only used for a single block. +type BALStateTransition struct { + accessList bal.AccessListReader + written bal.WrittenCounts + db Database + reader Reader + stateTrie Trie + parentRoot common.Hash + + // the computed state root of the block + rootHash common.Hash + // the state modifications performed by the block + diffs bal.StateMutations + + // a map of common.Address -> *types.StateAccount containing the block + // prestate of all accounts that will be modified + prestates sync.Map + + postStates map[common.Address]*types.StateAccount + // a map of common.Address -> Trie containing the account tries for all + // accounts with mutated storage + tries sync.Map //map[common.Address]Trie + deletions map[common.Address]struct{} + + // Deletion counters; not derivable from the BAL alone (selfdestruct vs + // balance drain is indistinguishable without prestate). + accountDeleted int + storageDeleted atomic.Int64 + + stateUpdate *StateUpdate + + metrics BALStateTransitionMetrics + maxBALIdx int + + err error +} + +func (s *BALStateTransition) Metrics() *BALStateTransitionMetrics { + return &s.metrics +} + +// DeletionCounts holds per-block deletion counters for accounts/storage +type DeletionCounts struct { + Accounts int + Storage int +} + +func (s *BALStateTransition) Deletions() DeletionCounts { + return DeletionCounts{ + Accounts: s.accountDeleted, + Storage: int(s.storageDeleted.Load()), + } +} + +type BALStateTransitionMetrics struct { + // trie hashing metrics + AccountUpdate time.Duration + StatePrefetch time.Duration + StateUpdate time.Duration + StateHash time.Duration + + // commit metrics + AccountCommits time.Duration + StorageCommits time.Duration + SnapshotCommits time.Duration + TrieDBCommits time.Duration + TotalCommitTime time.Duration +} + +func NewBALStateTransition(block *types.Block, prefetchReader Reader, db Database, parentRoot common.Hash) (*BALStateTransition, error) { + stateTrie, err := db.OpenTrie(parentRoot) + if err != nil { + return nil, err + } + + return &BALStateTransition{ + accessList: bal.NewAccessListReader(*block.AccessList()), + written: block.AccessList().WrittenCounts(), + db: db, + reader: prefetchReader, + stateTrie: stateTrie, + parentRoot: parentRoot, + rootHash: common.Hash{}, + diffs: make(bal.StateMutations), + prestates: sync.Map{}, + postStates: make(map[common.Address]*types.StateAccount), + tries: sync.Map{}, + deletions: make(map[common.Address]struct{}), + stateUpdate: nil, + maxBALIdx: len(block.Transactions()) + 1, + }, nil +} + +// WrittenCounts returns the cached BAL write counts (computed once per block). +func (s *BALStateTransition) WrittenCounts() bal.WrittenCounts { + return s.written +} + +func (s *BALStateTransition) Error() error { + return s.err +} + +func (s *BALStateTransition) setError(err error) { + if s.err == nil { + s.err = err + } +} + +// isAccountDeleted checks whether the state account was deleted in this block. Post selfdestruct-removal, +// deletions can only occur if an account which has a balance becomes the target of a CREATE2 initcode +// which calls SENDALL, clearing the account and marking it for deletion. +func isAccountDeleted(prestate *types.StateAccount, mutations bal.AccountMutations) bool { + // TODO: figure out how to simplify this method + if mutations.Code != nil && len(mutations.Code) != 0 { + return false + } + if mutations.Nonce != nil && *mutations.Nonce != 0 { + return false + } + if mutations.StorageWrites != nil && len(mutations.StorageWrites) > 0 { + return false + } + if mutations.Balance != nil { + if mutations.Balance.IsZero() { + if prestate.Nonce != 0 || prestate.Balance.IsZero() || common.BytesToHash(prestate.CodeHash) != types.EmptyCodeHash { + return false + } + // consider an empty account with storage to be deleted, so we don't check root here + return true + } + } + return false +} + +// updateAccount applies the block state mutations to a given account returning +// the updated state account and new code (if the account code changed) +func (s *BALStateTransition) updateAccount(addr common.Address) (*types.StateAccount, []byte) { + a, _ := s.prestates.Load(addr) + acct := a.(*types.StateAccount) + + acct, diff := acct.Copy(), s.diffs[addr] + code := diff.Code + + if diff.Nonce != nil { + acct.Nonce = *diff.Nonce + } + if diff.Balance != nil { + acct.Balance = new(uint256.Int).Set(diff.Balance) + } + if tr, ok := s.tries.Load(addr); ok { + acct.Root = tr.(Trie).Hash() + } + return acct, code +} + +func (s *BALStateTransition) commitAccount(addr common.Address) (*AccountUpdate, *trienode.NodeSet, error) { + op := &AccountUpdate{ + Address: addr, + Data: s.postStates[addr], // TODO: cache the updated state account somewhere + } + var prestate *types.StateAccount + if ps, exist := s.prestates.Load(addr); exist { + op.Origin = ps.(*types.StateAccount) + } + + if s.diffs[addr].Code != nil { + code := ContractCode{ + Hash: crypto.Keccak256Hash(s.diffs[addr].Code), + Blob: s.diffs[addr].Code, + } + if prestate == nil { + code.OriginHash = types.EmptyCodeHash + } else { + code.OriginHash = common.BytesToHash(prestate.CodeHash) + } + op.Code = &code + } + + if len(s.diffs[addr].StorageWrites) == 0 { + return op, nil, nil + } + + op.Storages = make(map[common.Hash]common.Hash) + op.StoragesOriginByHash = make(map[common.Hash]common.Hash) + op.StoragesOriginByKey = make(map[common.Hash]common.Hash) + + for key, value := range s.diffs[addr].StorageWrites { + hash := crypto.Keccak256Hash(key[:]) + op.Storages[hash] = value + origin, err := s.reader.Storage(addr, key) + if err != nil { + return nil, nil, err + } + op.StoragesOriginByHash[hash] = origin + op.StoragesOriginByKey[key] = origin + } + tr, _ := s.tries.Load(addr) + root, nodes := tr.(Trie).Commit(false) + s.postStates[addr].Root = root + return op, nodes, nil +} + +// CommitWithUpdate flushes mutated trie nodes and state accounts to disk. +func (s *BALStateTransition) CommitWithUpdate(block uint64, deleteEmptyObjects bool, noStorageWiping bool) (common.Hash, *StateUpdate, error) { + // 1) create a stateUpdate object + // Commit objects to the trie, measuring the elapsed time + var ( + //commitStart = time.Now() + accountTrieNodesUpdated int + accountTrieNodesDeleted int + storageTrieNodesUpdated int + storageTrieNodesDeleted int + + lock sync.Mutex // protect two maps below + nodes = trienode.NewMergedNodeSet() // aggregated trie nodes + updates = make(map[common.Hash]*AccountUpdate, len(s.diffs)) // aggregated account updates + + // merge aggregates the dirty trie nodes into the global set. + // + // Given that some accounts may be destroyed and then recreated within + // the same block, it's possible that a node set with the same owner + // may already exist. In such cases, these two sets are combined, with + // the later one overwriting the previous one if any nodes are modified + // or deleted in both sets. + // + // merge run concurrently across all the state objects and account trie. + merge = func(set *trienode.NodeSet) error { + if set == nil { + return nil + } + lock.Lock() + defer lock.Unlock() + + updates, deletes := set.Size() + if set.Owner == (common.Hash{}) { + accountTrieNodesUpdated += updates + accountTrieNodesDeleted += deletes + } else { + storageTrieNodesUpdated += updates + storageTrieNodesDeleted += deletes + } + return nodes.Merge(set) + } + ) + + destructedPrestates := make(map[common.Address]*types.StateAccount) + s.prestates.Range(func(key, value any) bool { + addr := key.(common.Address) + acct := value.(*types.StateAccount) + destructedPrestates[addr] = acct + return true + }) + + deletes, delNodes, err := handleDestruction(s.db, s.stateTrie, s.parentRoot, noStorageWiping, slices.Values(s.accessList.AllDestructions()), destructedPrestates) + if err != nil { + return common.Hash{}, nil, err + } + for _, set := range delNodes { + if err := merge(set); err != nil { + return common.Hash{}, nil, err + } + } + + // Handle all state updates afterwards, concurrently to one another to shave + // off some milliseconds from the commit operation. Also accumulate the code + // writes to run in parallel with the computations. + var ( + start = time.Now() + root common.Hash + workers errgroup.Group + ) + // Schedule the account trie first since that will be the biggest, so give + // it the most time to crunch. + // + // TODO(karalabe): This account trie commit is *very* heavy. 5-6ms at chain + // heads, which seems excessive given that it doesn't do hashing, it just + // shuffles some data. For comparison, the *hashing* at chain head is 2-3ms. + // We need to investigate what's happening as it seems something's wonky. + // Obviously it's not an end of the world issue, just something the original + // code didn't anticipate for. + workers.Go(func() error { + // Write the account trie changes, measuring the amount of wasted time + newroot, set := s.stateTrie.Commit(true) + root = newroot + + if err := merge(set); err != nil { + return err + } + s.metrics.AccountCommits = time.Since(start) + return nil + }) + + // Schedule each of the storage tries that need to be updated, so they can + // run concurrently to one another. + // + // TODO(karalabe): Experimentally, the account commit takes approximately the + // same time as all the storage commits combined, so we could maybe only have + // 2 threads in total. But that kind of depends on the account commit being + // more expensive than it should be, so let's fix that and revisit this todo. + for addr, _ := range s.diffs { + if _, isDeleted := s.deletions[addr]; isDeleted { + continue + } + + address := addr + // Run the storage updates concurrently to one another + workers.Go(func() error { + // Write any storage changes in the state object to its storage trie + update, set, err := s.commitAccount(address) + if err != nil { + return err + } + + if err := merge(set); err != nil { + return err + } + lock.Lock() + updates[crypto.Keccak256Hash(address[:])] = update + s.metrics.StorageCommits = time.Since(start) // overwrite with the longest storage commit runtime + lock.Unlock() + return nil + }) + } + // Wait for everything to finish and update the metrics + if err := workers.Wait(); err != nil { + return common.Hash{}, nil, err + } + + storageDeleted := s.storageDeleted.Load() + accountUpdatedMeter.Mark(int64(s.written.Accounts - s.accountDeleted)) + storageUpdatedMeter.Mark(int64(s.written.StorageSlots) - storageDeleted) + accountDeletedMeter.Mark(int64(s.accountDeleted)) + storageDeletedMeter.Mark(storageDeleted) + accountTrieUpdatedMeter.Mark(int64(accountTrieNodesUpdated)) + accountTrieDeletedMeter.Mark(int64(accountTrieNodesDeleted)) + storageTriesUpdatedMeter.Mark(int64(storageTrieNodesUpdated)) + storageTriesDeletedMeter.Mark(int64(storageTrieNodesDeleted)) + + storageKeyType := StorageKeyHashed + if noStorageWiping { + storageKeyType = StorageKeyPlain + } + update := NewStateUpdate(storageKeyType, s.parentRoot, root, block, deletes, updates, nodes) + + if err := s.db.Commit(update); err != nil { + return common.Hash{}, nil, err + } + // TODO: fix the following metrics: + /* + snapshotCommits, trieDBCommits, err := flushStateUpdate(s.db, block, ret) + if err != nil { + return common.Hash{}, nil, err + } + + s.metrics.SnapshotCommits, s.metrics.TrieDBCommits = snapshotCommits, trieDBCommits + s.metrics.TotalCommitTime = time.Since(commitStart) + */ + return root, update, nil +} + +func (s *BALStateTransition) Commit(block uint64, deleteEmptyObjects bool, noStorageWiping bool) (common.Hash, error) { + hash, _, err := s.CommitWithUpdate(block, deleteEmptyObjects, noStorageWiping) + return hash, err +} + +// IntermediateRoot applies block state mutations and computes the updated state +// trie root. +func (s *BALStateTransition) IntermediateRoot(_ bool) common.Hash { + if s.rootHash != (common.Hash{}) { + return s.rootHash + } + + // State root calculation proceeds as follows: + + // 1 (a): load the origin storage values for all slots which were modified during the block (this is needed for computing the stateUpdate) + // 1 (b): update each mutated account, producing the post-block state object by applying the state mutations to the prestate (retrieved in 1a). + // 1 (c): prefetch the intermediate trie nodes of the mutated state set from the account trie. + // + // 2: compute the post-state root of the account trie + // + // Steps 1/2 are performed sequentially, with steps 1a-d performed in parallel + + start := time.Now() + + var wg sync.WaitGroup + + s.diffs = *s.accessList.Mutations(s.maxBALIdx + 1) + + for addr, d := range s.diffs { + wg.Add(1) + address := addr + diff := d + go func() { + defer wg.Done() + + // 1 (b): update each mutated account, producing the post-block state object by applying the state mutations to the prestate (retrieved in 1a). + acct, err := s.reader.Account(address) + if err != nil { + s.setError(err) + return + } + + if acct == nil { + acct = types.NewEmptyStateAccount() + } + s.prestates.Store(address, acct) + + if len(diff.StorageWrites) > 0 { + tr, err := s.db.OpenStorageTrie(s.parentRoot, address, acct.Root, s.stateTrie) + if err != nil { + s.setError(err) + return + } + s.tries.Store(address, tr) + + var ( + updateKeys, updateValues [][]byte + deleteKeys [][]byte + ) + for key, val := range diff.StorageWrites { + if val != (common.Hash{}) { + updateKeys = append(updateKeys, key[:]) + updateValues = append(updateValues, common.TrimLeftZeroes(val[:])) + } else { + deleteKeys = append(deleteKeys, key[:]) + } + } + + if err := tr.UpdateStorageBatch(address, updateKeys, updateValues); err != nil { + s.setError(err) + return + } + + for _, key := range deleteKeys { + if err := tr.DeleteStorage(address, key); err != nil { + s.setError(err) + return + } + } + + hashStart := time.Now() + tr.Hash() + s.metrics.StateHash = time.Since(hashStart) + } + }() + } + + wg.Add(1) + // 1 (c): prefetch the intermediate trie nodes of the mutated state set from the account trie. + go func() { + defer wg.Done() + prefetchStart := time.Now() + var prefetchAddrs []common.Address + for addr, _ := range s.diffs { + prefetchAddrs = append(prefetchAddrs, addr) + } + if err := s.stateTrie.PrefetchAccount(prefetchAddrs); err != nil { + s.setError(err) + return + } + s.metrics.StatePrefetch = time.Since(prefetchStart) + }() + + wg.Wait() + s.metrics.AccountUpdate = time.Since(start) + + // 2: compute the post-state root of the account trie + stateUpdateStart := time.Now() + for mutatedAddr, _ := range s.diffs { + p, _ := s.prestates.Load(mutatedAddr) + prestate := p.(*types.StateAccount) + + isDeleted := isAccountDeleted(prestate, s.diffs[mutatedAddr]) + if isDeleted { + if err := s.stateTrie.DeleteAccount(mutatedAddr); err != nil { + s.setError(err) + return common.Hash{} + } + s.deletions[mutatedAddr] = struct{}{} + s.accountDeleted++ + } else { + acct, code := s.updateAccount(mutatedAddr) + + if code != nil { + codeHash := crypto.Keccak256Hash(code) + acct.CodeHash = codeHash.Bytes() + if err := s.stateTrie.UpdateContractCode(mutatedAddr, codeHash, code); err != nil { + s.setError(err) + return common.Hash{} + } + } + if err := s.stateTrie.UpdateAccount(mutatedAddr, acct, len(code)); err != nil { + s.setError(err) + return common.Hash{} + } + s.postStates[mutatedAddr] = acct + } + } + + s.metrics.StateUpdate = time.Since(stateUpdateStart) + + stateTrieHashStart := time.Now() + s.rootHash = s.stateTrie.Hash() + s.metrics.StateHash = time.Since(stateTrieHashStart) + return s.rootHash +} + +func (s *BALStateTransition) Preimages() map[common.Hash][]byte { + // TODO: implement this + return make(map[common.Hash][]byte) +} diff --git a/core/state/database.go b/core/state/database.go index 3b1e627f286c..3eebd18bacac 100644 --- a/core/state/database.go +++ b/core/state/database.go @@ -54,6 +54,10 @@ type Database interface { // Reader returns a state reader associated with the specified state root. Reader(root common.Hash) (Reader, error) + // ReaderWithPrefetch returns a reader which asynchronously fetches block + // access list state in the background. + ReaderWithPrefetch(stateRoot common.Hash, accessList map[common.Address][]common.Hash, threads int, block bool) (Reader, error) + // Iteratee returns a state iteratee associated with the specified state root, // through which the account iterator and storage iterator can be created. Iteratee(root common.Hash) (Iteratee, error) @@ -107,12 +111,18 @@ type Trie interface { // in the trie with provided address. UpdateAccount(address common.Address, account *types.StateAccount, codeLen int) error + // UpdateAccountBatch attempts to update a list accounts in the batch manner. + UpdateAccountBatch(addresses []common.Address, accounts []*types.StateAccount, codeLengths []int) error + // UpdateStorage associates key with value in the trie. If value has length zero, // any existing value is deleted from the trie. The value bytes must not be modified // by the caller while they are stored in the trie. If a node was not found in the // database, a trie.MissingNodeError is returned. UpdateStorage(addr common.Address, key, value []byte) error + // UpdateStorageBatch attempts to update a list storages in the batch manner. + UpdateStorageBatch(_ common.Address, keys [][]byte, values [][]byte) error + // DeleteAccount abstracts an account deletion from the trie. DeleteAccount(address common.Address) error diff --git a/core/state/database_history.go b/core/state/database_history.go index fbf4ab5f9c70..ddc6d7923856 100644 --- a/core/state/database_history.go +++ b/core/state/database_history.go @@ -223,6 +223,10 @@ type HistoricDB struct { codedb *CodeDB } +func (db *HistoricDB) ReaderWithPrefetch(stateRoot common.Hash, accessList map[common.Address][]common.Hash, threads int, block bool) (Reader, error) { + panic("not implemented") +} + // Type returns the trie type of the underlying database. func (db *HistoricDB) Type() DatabaseType { // TODO(rjl493456442) support UBT in the future diff --git a/core/state/database_mpt.go b/core/state/database_mpt.go index 42c5f2e5efe0..5e7d278232a9 100644 --- a/core/state/database_mpt.go +++ b/core/state/database_mpt.go @@ -185,3 +185,22 @@ func (db *MPTDatabase) Commit(update *StateUpdate) error { func (db *MPTDatabase) Iteratee(root common.Hash) (Iteratee, error) { return newStateIteratee(true, root, db.triedb, db.snap) } + +func (db *MPTDatabase) ReaderWithPrefetch(stateRoot common.Hash, accessList map[common.Address][]common.Hash, threads int, block bool) (Reader, error) { + base, err := db.StateReader(stateRoot) + if err != nil { + return nil, err + } + // Construct the state reader with native cache and associated statistics + r := newStateReaderWithStats(newStateReaderWithCache(base)) + + // Construct the state reader with background prefetching + pr := newPrefetchStateReader(r, accessList, threads) + if block { + if err := pr.Wait(); err != nil { + panic("this should unreachable") + } + } + + return newReaderWithPrefetch(db.codedb.Reader(), pr, pr), nil +} diff --git a/core/state/database_ubt.go b/core/state/database_ubt.go index 16579f6d6a0f..5dbbab4ae15c 100644 --- a/core/state/database_ubt.go +++ b/core/state/database_ubt.go @@ -80,6 +80,10 @@ func (db *UBTDatabase) Reader(stateRoot common.Hash) (Reader, error) { return newReader(db.codedb.Reader(), sr), nil } +func (db *UBTDatabase) ReaderWithPrefetch(stateRoot common.Hash, accessList map[common.Address][]common.Hash, threads int, block bool) (Reader, error) { + panic("not implemented") +} + // ReadersWithCacheStats creates a pair of state readers that share the same // underlying state reader and internal state cache, while maintaining separate // statistics respectively. diff --git a/core/state/reader.go b/core/state/reader.go index be07cec0f97f..c485af96edc5 100644 --- a/core/state/reader.go +++ b/core/state/reader.go @@ -560,6 +560,7 @@ func (r *stateReaderWithStats) GetStateStats() StateReaderStats { type reader struct { ContractCodeReader StateReader + PrefetcherMetricer } // newReader constructs a reader with the supplied code reader and state reader. @@ -570,6 +571,14 @@ func newReader(codeReader ContractCodeReader, stateReader StateReader) *reader { } } +func newReaderWithPrefetch(codeReader ContractCodeReader, stateReader StateReader, metricer PrefetcherMetricer) *reader { + return &reader{ + ContractCodeReader: codeReader, + StateReader: stateReader, + PrefetcherMetricer: metricer, + } +} + // GetCodeStats returns the statistics of code access. func (r *reader) GetCodeStats() ContractCodeReaderStats { if stater, ok := r.ContractCodeReader.(ContractCodeReaderStater); ok { diff --git a/core/state/reader_eip_7928.go b/core/state/reader_eip_7928.go index ff315ac5eb6e..72727d35c3ed 100644 --- a/core/state/reader_eip_7928.go +++ b/core/state/reader_eip_7928.go @@ -16,14 +16,6 @@ package state -import ( - "sync" - - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/core/types/bal" -) - // The EIP27928 reader utilizes a hierarchical architecture to optimize state // access during block execution: // @@ -39,15 +31,13 @@ import ( // This layer provides a "unified view" by merging the pre-transition state // with mutated states from preceding transactions in the block. // -// - Tracking Layer: Finally, the readerTracker wraps the execution reader to -// capture all state reads made during a specific transaction. These individual -// reads are subsequently merged to construct a comprehensive access list -// for the entire block. -// // The architecture can be illustrated by the diagram below: -// + +// [ Block Level Access List ] <────────────────┐ +// ▲ │ (Merge) +// │ │ // ┌──────────────┴──────────────┐ ┌──────────────┴──────────────┐ -// │ ReaderWithBlockLevelAL │ │ ReaderWithBlockLevelAL │ +// │ ReaderWithBlockLevelAL │ │ ReaderWithBlockLevelAL │ (Unified View) // │ (Pre-state + Mutations) │ │ (Pre-state + Mutations) │ // └──────────────┬──────────────┘ └──────────────┬──────────────┘ // │ │ @@ -63,11 +53,16 @@ import ( // │ (State & Contract Code) │ // └─────────────────────────────┘ -// Note: The block producer, which is responsible for generating the block -// along with the block-level access list, does not maintain the internal -// hierarchy (e.g., PrefetchStateReader or ReaderWithBlockLevelAL). -// Instead, it directly utilizes the readerTracker, wrapped around the -// base reader, to construct the access list. +import ( + "sync" + "time" + + "github.com/ethereum/go-ethereum/crypto" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/core/types/bal" +) type fetchTask struct { addr common.Address @@ -78,16 +73,27 @@ func (t *fetchTask) weight() int { return 1 + len(t.slots) } type prefetchStateReader struct { StateReader - tasks []*fetchTask nThreads int done chan struct{} term chan struct{} closeOnce sync.Once + start time.Time + metrics PrefetchMetrics } -// nolint:unused -func newPrefetchStateReader(reader StateReader, accessList map[common.Address][]common.Hash, nThreads int) *prefetchStateReader { +type PrefetchMetrics struct { + // the total amount of time it took to complete the scheduled workload + Elapsed time.Duration +} + +// PrefetcherMetricer is an object that can expose metrics related to the state +// prefetching. +type PrefetcherMetricer interface { + Metrics() PrefetchMetrics +} + +func newPrefetchStateReader(reader StateReader, accessList bal.StorageKeys, nThreads int) *prefetchStateReader { tasks := make([]*fetchTask, 0, len(accessList)) for addr, slots := range accessList { tasks = append(tasks, &fetchTask{ @@ -105,11 +111,16 @@ func newPrefetchStateReaderInternal(reader StateReader, tasks []*fetchTask, nThr nThreads: nThreads, done: make(chan struct{}), term: make(chan struct{}), + start: time.Now(), } go r.prefetch() return r } +func (r *prefetchStateReader) Metrics() PrefetchMetrics { + return r.metrics +} + func (r *prefetchStateReader) Close() { r.closeOnce.Do(func() { close(r.term) @@ -127,7 +138,10 @@ func (r *prefetchStateReader) Wait() error { } func (r *prefetchStateReader) prefetch() { - defer close(r.done) + defer func() { + r.metrics = PrefetchMetrics{time.Since(r.start)} + close(r.done) + }() if len(r.tasks) == 0 { return @@ -198,50 +212,88 @@ func (r *prefetchStateReader) process(start, limit int) { // prior to TxIndex. type ReaderWithBlockLevelAccessList struct { Reader - AccessList *bal.ConstructionBlockAccessList + AccessList bal.AccessListReader TxIndex int } -// NewReaderWithBlockLevelAccessList constructs a reader for accessing states -// with the mutations made by transactions prior to txIndex. -// -// The txIndex refers to the call frame as such: -// - 0 for pre‑execution system contract calls. -// - 1 … n for transactions (in block order). -// - n + 1 for post‑execution system contract calls. -func NewReaderWithBlockLevelAccessList(base Reader, accessList *bal.ConstructionBlockAccessList, txIndex int) *ReaderWithBlockLevelAccessList { +func NewReaderWithBlockLevelAccessList(base Reader, accessList bal.BlockAccessList, txIndex int) *ReaderWithBlockLevelAccessList { return &ReaderWithBlockLevelAccessList{ Reader: base, - AccessList: accessList, + AccessList: bal.NewAccessListReader(accessList), TxIndex: txIndex, } } // Account implements Reader, returning the account with the specific address. -func (r *ReaderWithBlockLevelAccessList) Account(addr common.Address) (*types.StateAccount, error) { - panic("implement me") +func (r *ReaderWithBlockLevelAccessList) Account(addr common.Address) (acct *types.StateAccount, err error) { + acct, err = r.Reader.Account(addr) + if err != nil { + return nil, err + } + + mut := r.AccessList.AccountMutations(addr, r.TxIndex) + if mut == nil { + return + } + + if acct == nil { + acct = types.NewEmptyStateAccount() + } else { + // the account returned by the underlying reader is a reference + // copy it to avoid mutating the reader's instance + acct = acct.Copy() + } + + if mut.Balance != nil { + acct.Balance = mut.Balance + } + if mut.Code != nil { + codeHash := crypto.Keccak256Hash(mut.Code) + acct.CodeHash = codeHash[:] + } + if mut.Nonce != nil { + acct.Nonce = *mut.Nonce + } + return } // Storage implements Reader, returning the storage slot with the specific // address and slot key. func (r *ReaderWithBlockLevelAccessList) Storage(addr common.Address, slot common.Hash) (common.Hash, error) { - panic("implement me") + val := r.AccessList.Storage(addr, slot, r.TxIndex) + if val != nil { + return *val, nil + } + return r.Reader.Storage(addr, slot) } // Has implements Reader, returning the flag indicating whether the contract // code with specified address and hash exists or not. func (r *ReaderWithBlockLevelAccessList) Has(addr common.Address, codeHash common.Hash) bool { - panic("implement me") + mut := r.AccessList.AccountMutations(addr, r.TxIndex) + if mut != nil && mut.Code != nil { + return crypto.Keccak256Hash(mut.Code) == codeHash + } + return r.Reader.Has(addr, codeHash) } // Code implements Reader, returning the contract code with specified address // and hash. -func (r *ReaderWithBlockLevelAccessList) Code(addr common.Address, codeHash common.Hash) ([]byte, error) { - panic("implement me") +func (r *ReaderWithBlockLevelAccessList) Code(addr common.Address, codeHash common.Hash) []byte { + mut := r.AccessList.AccountMutations(addr, r.TxIndex) + if mut != nil && mut.Code != nil && crypto.Keccak256Hash(mut.Code) == codeHash { + // TODO: need to copy here? + return mut.Code + } + return r.Reader.Code(addr, codeHash) } // CodeSize implements Reader, returning the contract code size with specified // address and hash. -func (r *ReaderWithBlockLevelAccessList) CodeSize(addr common.Address, codeHash common.Hash) (int, error) { - panic("implement me") +func (r *ReaderWithBlockLevelAccessList) CodeSize(addr common.Address, codeHash common.Hash) int { + mut := r.AccessList.AccountMutations(addr, r.TxIndex) + if mut != nil && mut.Code != nil && crypto.Keccak256Hash(mut.Code) == codeHash { + return len(mut.Code) + } + return r.Reader.CodeSize(addr, codeHash) } diff --git a/core/state/statedb.go b/core/state/statedb.go index 15c175fd3026..79f4f2b530c8 100644 --- a/core/state/statedb.go +++ b/core/state/statedb.go @@ -21,6 +21,7 @@ import ( "bytes" "errors" "fmt" + "iter" "maps" "slices" "sort" @@ -182,6 +183,13 @@ func New(root common.Hash, db Database) (*StateDB, error) { return NewWithReader(root, db, reader) } +// WithReader returns a copy of the statedb instance with the specified reader. +func (s *StateDB) WithReader(reader Reader) *StateDB { + cpy := s.Copy() + cpy.reader = reader + return cpy +} + // NewWithReader creates a new state for the specified state root. Unlike New, // this function accepts an additional Reader which is bound to the given root. func NewWithReader(root common.Hash, db Database, reader Reader) (*StateDB, error) { @@ -1122,12 +1130,15 @@ func (s *StateDB) clearJournalAndRefund() { // deleteStorage is designed to delete the storage trie of a designated account. func (s *StateDB) deleteStorage(addrHash common.Hash, root common.Hash) (map[common.Hash]common.Hash, map[common.Hash]common.Hash, *trienode.NodeSet, error) { + return deleteStorage(s.db, s.originalRoot, addrHash, root) +} +func deleteStorage(db Database, originalRoot common.Hash, addrHash common.Hash, root common.Hash) (map[common.Hash]common.Hash, map[common.Hash]common.Hash, *trienode.NodeSet, error) { var ( nodes = trienode.NewNodeSet(addrHash) // the set for trie node mutations (value is nil) storages = make(map[common.Hash]common.Hash) // the set for storage mutations (value is nil) storageOrigins = make(map[common.Hash]common.Hash) // the set for tracking the original value of slot ) - iteratee, err := s.db.Iteratee(s.originalRoot) + iteratee, err := db.Iteratee(originalRoot) if err != nil { return nil, nil, nil, err } @@ -1556,3 +1567,72 @@ func (s *StateDB) Witness() *stateless.Witness { func (s *StateDB) AccessEvents() *AccessEvents { return s.accessEvents } + +// handleDestruction processes all destruction markers and deletes the account +// and associated storage slots if necessary. There are four potential scenarios +// as following: +// +// (a) the account was not existent and be marked as destructed +// (b) the account was not existent and be marked as destructed, +// however, it's resurrected later in the same block. +// (c) the account was existent and be marked as destructed +// (d) the account was existent and be marked as destructed, +// however it's resurrected later in the same block. +// +// In case (a), nothing needs be deleted, nil to nil transition can be ignored. +// In case (b), nothing needs be deleted, nil is used as the original value for +// newly created account and storages +// In case (c), **original** account along with its storages should be deleted, +// with their values be tracked as original value. +// In case (d), **original** account along with its storages should be deleted, +// with their values be tracked as original value. +func handleDestruction(db Database, trie Trie, root common.Hash, noStorageWiping bool, destructions iter.Seq[common.Address], prestates map[common.Address]*types.StateAccount) (map[common.Hash]*AccountDelete, []*trienode.NodeSet, error) { + var ( + nodes []*trienode.NodeSet + deletes = make(map[common.Hash]*AccountDelete) + ) + for addr := range destructions { + prestate := prestates[addr] + // The account was non-existent, and it's marked as destructed in the scope + // of block. It can be either case (a) or (b) and will be interpreted as + // null->null state transition. + // - for (a), skip it without doing anything + // - for (b), the resurrected account with nil as original will be handled afterwards + if prestate == nil { + continue + } + // The account was existent, it can be either case (c) or (d). + addrHash := crypto.Keccak256Hash(addr.Bytes()) + op := &AccountDelete{ + Address: addr, + Origin: prestate, + } + deletes[addrHash] = op + + // Short circuit if the origin storage was empty. + if prestate.Root == types.EmptyRootHash || db.TrieDB().IsUBT() { + continue + } + if noStorageWiping { + return nil, nil, fmt.Errorf("unexpected storage wiping, %x", addr) + } + // Remove storage slots belonging to the account. + storages, storagesOrigin, set, err := deleteStorage(db, prestate.Root, addrHash, root) + if err != nil { + return nil, nil, fmt.Errorf("failed to delete storage, err: %w", err) + } + op.Storages = storages + op.StoragesOrigin = storagesOrigin + + // Aggregate the associated trie node changes. + nodes = append(nodes, set) + } + return deletes, nodes, nil +} + +// TODO: find better location for this +type Committer interface { + Commit(block uint64, deleteEmptyObjects bool, noStorageWiping bool) (common.Hash, error) + CommitWithUpdate(block uint64, deleteEmptyObjects bool, noStorageWiping bool) (common.Hash, *StateUpdate, error) + Preimages() map[common.Hash][]byte +} diff --git a/core/state_transition.go b/core/state_transition.go index b8ca6714fa35..fdd4b7d66358 100644 --- a/core/state_transition.go +++ b/core/state_transition.go @@ -327,7 +327,7 @@ func ApplyMessage(evm *vm.EVM, msg *Message, gp *GasPool) (*ExecutionResult, err return newStateTransition(evm, msg, gp).execute() } -// stateTransition represents a state transition. +// StateTransition represents a state transition. // // == The State Transitioning Model // @@ -349,7 +349,7 @@ func ApplyMessage(evm *vm.EVM, msg *Message, gp *GasPool) (*ExecutionResult, err // // 5. Run Script section // 6. Derive new state root -type stateTransition struct { +type StateTransition struct { gp *GasPool msg *Message initialBudget vm.GasBudget @@ -359,8 +359,8 @@ type stateTransition struct { } // newStateTransition initialises and returns a new state transition object. -func newStateTransition(evm *vm.EVM, msg *Message, gp *GasPool) *stateTransition { - return &stateTransition{ +func newStateTransition(evm *vm.EVM, msg *Message, gp *GasPool) *StateTransition { + return &StateTransition{ gp: gp, evm: evm, msg: msg, @@ -369,7 +369,7 @@ func newStateTransition(evm *vm.EVM, msg *Message, gp *GasPool) *stateTransition } // to returns the recipient of the message. -func (st *stateTransition) to() common.Address { +func (st *StateTransition) to() common.Address { if st.msg == nil || st.msg.To == nil /* contract creation */ { return common.Address{} } @@ -394,7 +394,7 @@ func (st *stateTransition) to() common.Address { // - Amsterdam+ (EIP-8037): two-dimensional budget. Regular gas is // capped at `MaxTxGas` (EIP-7825, 16_777_216); any excess from // `msg.GasLimit` above that cap becomes the state-gas reservoir. -func (st *stateTransition) buyGas() error { +func (st *StateTransition) buyGas() error { mgval := new(uint256.Int).SetUint64(st.msg.GasLimit) _, overflow := mgval.MulOverflow(mgval, st.msg.GasPrice) if overflow { @@ -482,7 +482,7 @@ func (st *stateTransition) buyGas() error { // // The SkipNonceChecks / SkipTransactionChecks / NoBaseFee flags bypass // subsets of these checks for simulation paths (eth_call, eth_estimateGas). -func (st *stateTransition) preCheck() error { +func (st *StateTransition) preCheck() error { // Only check transactions that are not fake msg := st.msg if !msg.SkipNonceChecks { @@ -581,7 +581,7 @@ func (st *stateTransition) preCheck() error { // reserveBlockGasBudget checks if the remaining gas budget in the block pool is // sufficient for including this transaction. -func (st *stateTransition) reserveBlockGasBudget(rules params.Rules, gasLimit uint64, intrinsicCost vm.GasCosts) error { +func (st *StateTransition) reserveBlockGasBudget(rules params.Rules, gasLimit uint64, intrinsicCost vm.GasCosts) error { var err error if rules.IsAmsterdam { // EIP-8037 per-tx 2D block-inclusion check. For each dimension, @@ -620,7 +620,7 @@ func (st *stateTransition) reserveBlockGasBudget(rules params.Rules, gasLimit ui // // If a consensus error is encountered, it is returned directly with a // nil EVM execution result. -func (st *stateTransition) execute() (*ExecutionResult, error) { +func (st *StateTransition) execute() (*ExecutionResult, error) { // The state-transition pipeline below runs in stages. Each stage may // abort with a consensus error before the EVM is invoked: // @@ -867,7 +867,7 @@ func (st *stateTransition) execute() (*ExecutionResult, error) { } // validateAuthorization validates an EIP-7702 authorization against the state. -func (st *stateTransition) validateAuthorization(auth *types.SetCodeAuthorization) (authority common.Address, err error) { +func (st *StateTransition) validateAuthorization(auth *types.SetCodeAuthorization) (authority common.Address, err error) { // Verify chain ID is null or equal to current chain ID. if !auth.ChainID.IsZero() && auth.ChainID.CmpBig(st.evm.ChainConfig().ChainID) != 0 { return authority, ErrAuthorizationWrongChainID @@ -904,7 +904,7 @@ func (st *stateTransition) validateAuthorization(auth *types.SetCodeAuthorizatio // // Invalid authorizations are silently skipped (their auth-base intrinsic // state gas remains charged, matching the pre-existing behavior). -func (st *stateTransition) applyAuthorizations(rules params.Rules, auths []types.SetCodeAuthorization) { +func (st *StateTransition) applyAuthorizations(rules params.Rules, auths []types.SetCodeAuthorization) { if len(auths) == 0 { return } @@ -938,7 +938,7 @@ func (st *stateTransition) applyAuthorizations(rules params.Rules, auths []types // authority "first creation" budget). The caller is expected to do an // end-of-loop pass over this set and refund any entry whose final delegation // state ended up empty. -func (st *stateTransition) applyAuthorization(rules params.Rules, auth *types.SetCodeAuthorization, authBilledCreations map[common.Address]struct{}) error { +func (st *StateTransition) applyAuthorization(rules params.Rules, auth *types.SetCodeAuthorization, authBilledCreations map[common.Address]struct{}) error { authority, err := st.validateAuthorization(auth) if err != nil { return err @@ -1004,7 +1004,7 @@ func (st *stateTransition) applyAuthorization(rules params.Rules, auth *types.Se } // calcRefund computes refund counter, capped to a refund quotient. -func (st *stateTransition) calcRefund() uint64 { +func (st *StateTransition) calcRefund() uint64 { var refund uint64 if !st.evm.ChainConfig().IsLondon(st.evm.Context.BlockNumber) { // Before EIP-3529: refunds were capped to gasUsed / 2 @@ -1026,7 +1026,7 @@ func (st *stateTransition) calcRefund() uint64 { } // returnGas returns ETH for remaining gas, exchanged at the original rate. -func (st *stateTransition) returnGas() uint64 { +func (st *StateTransition) returnGas() uint64 { gas := st.gasRemaining.RegularGas + st.gasRemaining.StateGas remaining := uint256.NewInt(gas) remaining.Mul(remaining, st.msg.GasPrice) @@ -1039,11 +1039,11 @@ func (st *stateTransition) returnGas() uint64 { } // gasUsed returns the amount of gas used up by the state transition. -func (st *stateTransition) gasUsed() uint64 { +func (st *StateTransition) gasUsed() uint64 { return st.gasRemaining.Used(st.initialBudget) } // blobGasUsed returns the amount of blob gas used by the message. -func (st *stateTransition) blobGasUsed() uint64 { +func (st *StateTransition) blobGasUsed() uint64 { return uint64(len(st.msg.BlobHashes) * params.BlobTxBlobGasPerBlob) } diff --git a/core/types.go b/core/types.go index edbfc43db3ee..32f22bc53400 100644 --- a/core/types.go +++ b/core/types.go @@ -34,7 +34,7 @@ type Validator interface { ValidateBody(block *types.Block) error // ValidateState validates the given statedb and optionally the process result. - ValidateState(block *types.Block, state *state.StateDB, res *ProcessResult, stateless bool) error + ValidateState(block *types.Block, state StateRootSource, res *ProcessResult, stateless bool) error } // Prefetcher is an interface for pre-caching transaction signatures and state. @@ -63,4 +63,6 @@ type ProcessResult struct { // BAL is only meaningful for post-Amsterdam blocks. Please ensure // fork validation is performed before accessing it. Bal *bal.ConstructionBlockAccessList + + Error error } diff --git a/core/types/bal/bal.go b/core/types/bal/bal.go index 2eb5fe93cdde..9845916765da 100644 --- a/core/types/bal/bal.go +++ b/core/types/bal/bal.go @@ -18,6 +18,7 @@ package bal import ( "bytes" + "encoding/json" "maps" "github.com/ethereum/go-ethereum/common" @@ -223,3 +224,137 @@ func (b *ConstructionBlockAccessList) Copy() *ConstructionBlockAccessList { } return res } + +type StorageMutations map[common.Hash]common.Hash + +// AccountMutations contains mutations that were made to an account across +// one or more access list indices. +type AccountMutations struct { + Balance *uint256.Int `json:"Balance,omitempty"` + Nonce *uint64 `json:"Nonce,omitempty"` + Code []byte `json:"Code,omitempty"` + StorageWrites StorageMutations `json:"StorageWrites,omitempty"` +} + +// String returns a human-readable JSON representation of the account mutations. +func (a *AccountMutations) String() string { + var res bytes.Buffer + enc := json.NewEncoder(&res) + enc.SetIndent("", " ") + enc.Encode(a) + return res.String() +} + +// Copy returns a deep-copy of the instance. +func (a *AccountMutations) Copy() *AccountMutations { + res := &AccountMutations{ + nil, + nil, + nil, + nil, + } + if a.Nonce != nil { + res.Nonce = new(uint64) + *res.Nonce = *a.Nonce + } + if a.Code != nil { + res.Code = bytes.Clone(a.Code) + } + if a.Balance != nil { + res.Balance = new(uint256.Int).Set(a.Balance) + } + if a.StorageWrites != nil { + res.StorageWrites = maps.Clone(a.StorageWrites) + } + return res +} + +// Eq returns whether the calling instance is equal to the provided one. +func (a *AccountMutations) Eq(other *AccountMutations) bool { + if a.Balance != nil || other.Balance != nil { + if a.Balance == nil || other.Balance == nil { + return false + } + + if !a.Balance.Eq(other.Balance) { + return false + } + } + + if (len(a.Code) != 0 || len(other.Code) != 0) && !bytes.Equal(a.Code, other.Code) { + return false + } + + if a.Nonce != nil || other.Nonce != nil { + if a.Nonce == nil || other.Nonce == nil { + return false + } + + if *a.Nonce != *other.Nonce { + return false + } + } + + if a.StorageWrites != nil || other.StorageWrites != nil { + if !maps.Equal(a.StorageWrites, other.StorageWrites) { + return false + } + } + return true +} + +type BALExecutionMode int + +const ( + BALExecutionOptimized BALExecutionMode = iota + BALExecutionNoBatchIO + BALExecutionSequential +) + +// WrittenCounts groups per-block aggregate write counts derived from the BAL. +type WrittenCounts struct { + Accounts int + StorageSlots int + Codes int + CodeBytes int +} + +// WrittenCounts walks the BAL once and returns the aggregate write counts. +func (e BlockAccessList) WrittenCounts() WrittenCounts { + var w WrittenCounts + for i := range e { + a := &e[i] + if len(a.StorageChanges) > 0 || len(a.BalanceChanges) > 0 || + len(a.NonceChanges) > 0 || len(a.CodeChanges) > 0 { + w.Accounts++ + } + w.StorageSlots += len(a.StorageChanges) + if n := len(a.CodeChanges); n > 0 { + w.Codes++ + w.CodeBytes += len(a.CodeChanges[n-1].NewCode) + } + } + return w +} + +type StateMutations map[common.Address]AccountMutations + +type StorageKeySet map[common.Hash]struct{} +type StateAccesses map[common.Address]StorageKeySet + +func (s StateAccesses) Eq(other StateAccesses) bool { + if len(s) != len(other) { + return false + } + + for addr, set := range s { + otherSet, ok := other[addr] + if !ok { + return false + } + if !maps.Equal(set, otherSet) { + return false + } + } + return true +} diff --git a/core/types/bal/bal_encoding.go b/core/types/bal/bal_encoding.go index 612d2f877772..26d5aaf7d712 100644 --- a/core/types/bal/bal_encoding.go +++ b/core/types/bal/bal_encoding.go @@ -395,7 +395,7 @@ func (a *ConstructionAccountAccess) toEncodingObj(addr common.Address) AccountAc obj.SlotChanges = make([]encodingStorageWrite, 0, len(slotWrites)) indices := slices.Collect(maps.Keys(slotWrites)) - slices.SortFunc(indices, cmp.Compare) + slices.Sort(indices) for _, index := range indices { val := slotWrites[index] obj.SlotChanges = append(obj.SlotChanges, encodingStorageWrite{ @@ -415,7 +415,7 @@ func (a *ConstructionAccountAccess) toEncodingObj(addr common.Address) AccountAc // Convert balance changes balanceIndices := slices.Collect(maps.Keys(a.BalanceChanges)) - slices.SortFunc(balanceIndices, cmp.Compare) + slices.Sort(balanceIndices) for _, idx := range balanceIndices { res.BalanceChanges = append(res.BalanceChanges, encodingBalanceChange{ BlockAccessIndex: idx, @@ -425,7 +425,7 @@ func (a *ConstructionAccountAccess) toEncodingObj(addr common.Address) AccountAc // Convert nonce changes nonceIndices := slices.Collect(maps.Keys(a.NonceChanges)) - slices.SortFunc(nonceIndices, cmp.Compare) + slices.Sort(nonceIndices) for _, idx := range nonceIndices { res.NonceChanges = append(res.NonceChanges, encodingAccountNonce{ BlockAccessIndex: idx, @@ -435,7 +435,7 @@ func (a *ConstructionAccountAccess) toEncodingObj(addr common.Address) AccountAc // Convert code change codeIndices := slices.Collect(maps.Keys(a.CodeChange)) - slices.SortFunc(codeIndices, cmp.Compare) + slices.Sort(codeIndices) for _, idx := range codeIndices { res.CodeChanges = append(res.CodeChanges, encodingCodeChange{ BlockAccessIndex: idx, diff --git a/core/types/bal/bal_reader.go b/core/types/bal/bal_reader.go new file mode 100644 index 000000000000..e6cffc922eed --- /dev/null +++ b/core/types/bal/bal_reader.go @@ -0,0 +1,114 @@ +package bal + +import ( + "bytes" + "github.com/ethereum/go-ethereum/common" +) + +// AccessListReader exposes utilities to read state mutations and accesses from an access list +type AccessListReader map[common.Address]*AccountAccess + +func NewAccessListReader(bal BlockAccessList) (reader AccessListReader) { + reader = make(AccessListReader) + for _, accountAccess := range bal { + reader[accountAccess.Address] = &accountAccess + } + return +} + +// AccountMutations returns the aggregate mutation for an account up until (and not including) the given block access +// list index. +func (a AccessListReader) AccountMutations(addr common.Address, idx int) (res *AccountMutations) { + diff, exist := a[addr] + if !exist { + return nil + } + + res = &AccountMutations{} + + for i := 0; i < len(diff.BalanceChanges) && diff.BalanceChanges[i].BlockAccessIndex < uint32(idx); i++ { + res.Balance = diff.BalanceChanges[i].PostBalance.Clone() + } + + for i := 0; i < len(diff.CodeChanges) && diff.CodeChanges[i].BlockAccessIndex < uint32(idx); i++ { + res.Code = bytes.Clone(diff.CodeChanges[i].NewCode) + } + + for i := 0; i < len(diff.NonceChanges) && diff.NonceChanges[i].BlockAccessIndex < uint32(idx); i++ { + res.Nonce = new(uint64) + *res.Nonce = diff.NonceChanges[i].PostNonce + } + + if len(diff.StorageChanges) > 0 { + res.StorageWrites = make(map[common.Hash]common.Hash) + for _, slotWrites := range diff.StorageChanges { + for i := 0; i < len(slotWrites.SlotChanges) && slotWrites.SlotChanges[i].BlockAccessIndex < uint32(idx); i++ { + res.StorageWrites[slotWrites.Slot.Bytes32()] = slotWrites.SlotChanges[i].PostValue.Bytes32() + } + } + } + + if res.Code == nil && res.Nonce == nil && len(res.StorageWrites) == 0 && res.Balance == nil { + return nil + } + return res +} + +type StorageKeys map[common.Address][]common.Hash + +// StorageKeys returns the set of accounts and storage keys mutated in the access list. +// If reads is set, the un-mutated accounts/keys are included in the result. +func (a AccessListReader) StorageKeys(reads bool) (keys StorageKeys) { + keys = make(StorageKeys) + for addr, acct := range a { + for _, storageChange := range acct.StorageChanges { + keys[addr] = append(keys[addr], storageChange.Slot.Bytes32()) + } + if !(reads && len(acct.StorageReads) > 0) { + continue + } + for _, storageRead := range acct.StorageReads { + keys[addr] = append(keys[addr], storageRead.Bytes32()) + } + } + return +} + +// Storage returns the value of a storage key at the start of executing an index. +// If the slot has no mutations in the access list, it returns nil. +func (a AccessListReader) Storage(addr common.Address, key common.Hash, idx int) (val *common.Hash) { + storageMuts := a.AccountMutations(addr, idx) + if storageMuts != nil { + res, ok := storageMuts.StorageWrites[key] + if ok { + return &res + } + } + return nil +} + +// Mutations returns the aggregate state mutations from bal indices [0, idx) +func (a AccessListReader) Mutations(idx int) *StateMutations { + res := make(StateMutations) + for addr := range a { + if mut := a.AccountMutations(addr, idx); mut != nil { + res[addr] = *mut + } + } + return &res +} + +// AllDestructions returns all accounts that experienced a destruction, regardless of whether +// they were later resurrected and exist after the block. It excludes ephemeral contracts from +// the result. +func (a AccessListReader) AllDestructions() (res []common.Address) { + for addr, access := range a { + for _, nonce := range access.NonceChanges { + if nonce.PostNonce == 0 { + res = append(res, addr) + break + } + } + } + return res +} diff --git a/eth/backend.go b/eth/backend.go index af8b04bda665..fe80d113ccf8 100644 --- a/eth/backend.go +++ b/eth/backend.go @@ -280,6 +280,10 @@ func New(stack *node.Node, config *ethconfig.Config) (*Ethereum, error) { } options.Overrides = &overrides + options.BALExecutionMode = config.BALExecutionMode + options.BlockingPrefetch = config.BlockingPrefetch + options.PrefetchWorkers = int(config.PrefetchWorkers) + eth.blockchain, err = core.NewBlockChain(chainDb, config.Genesis, eth.engine, options) if err != nil { return nil, err diff --git a/eth/ethconfig/config.go b/eth/ethconfig/config.go index b51b78e199ad..2a7e66de110f 100644 --- a/eth/ethconfig/config.go +++ b/eth/ethconfig/config.go @@ -19,6 +19,7 @@ package ethconfig import ( "errors" + "github.com/ethereum/go-ethereum/core/types/bal" "time" "github.com/ethereum/go-ethereum/common" @@ -216,6 +217,10 @@ type Config struct { // RangeLimit restricts the maximum range (end - start) for range queries. RangeLimit uint64 `toml:",omitempty"` + + BALExecutionMode bal.BALExecutionMode + PrefetchWorkers uint + BlockingPrefetch bool } // CreateConsensusEngine creates a consensus engine for the given chain config. diff --git a/tests/block_test.go b/tests/block_test.go index 31bb4f9d3681..d309d8167046 100644 --- a/tests/block_test.go +++ b/tests/block_test.go @@ -127,7 +127,7 @@ func execBlockTest(t *testing.T, bt *testMatcher, test *BlockTest) { } for _, snapshot := range snapshotConf { for _, dbscheme := range dbschemeConf { - if err := bt.checkFailure(t, test.Run(snapshot, dbscheme, true, nil, nil)); err != nil { + if err := bt.checkFailure(t, test.Run(snapshot, dbscheme, true, true, nil, nil)); err != nil { t.Errorf("test with config {snapshotter:%v, scheme:%v} failed: %v", snapshot, dbscheme, err) return } diff --git a/tests/block_test_util.go b/tests/block_test_util.go index bece8ae61064..ab0c9084706f 100644 --- a/tests/block_test_util.go +++ b/tests/block_test_util.go @@ -113,27 +113,20 @@ type btHeaderMarshaling struct { SlotNumber *math.HexOrDecimal64 } -func (t *BlockTest) Run(snapshotter bool, scheme string, witness bool, tracer *tracing.Hooks, postCheck func(error, *core.BlockChain)) (result error) { - config, ok := Forks[t.json.Network] - if !ok { - return UnsupportedForkError{t.json.Network} - } - +func (t *BlockTest) createTestBlockChain(config *params.ChainConfig, snapshotter bool, scheme string, witness, createAndVerifyBAL bool, tracer *tracing.Hooks) (*core.BlockChain, error) { // import pre accounts & construct test genesis block & state root - // Commit genesis state var ( - gspec = t.genesis(config) db = rawdb.NewMemoryDatabase() tconf = &triedb.Config{ Preimages: true, - IsUBT: gspec.Config.UBTTime != nil && *gspec.Config.UBTTime <= gspec.Timestamp, } ) - if scheme == rawdb.PathScheme || tconf.IsUBT { + if scheme == rawdb.PathScheme { tconf.PathDB = pathdb.Defaults } else { tconf.HashDB = hashdb.Defaults } + gspec := t.genesis(config) // if ttd is not specified, set an arbitrary huge value if gspec.Config.TerminalTotalDifficulty == nil { @@ -142,15 +135,15 @@ func (t *BlockTest) Run(snapshotter bool, scheme string, witness bool, tracer *t triedb := triedb.NewDatabase(db, tconf) gblock, err := gspec.Commit(db, triedb, nil) if err != nil { - return err + return nil, err } triedb.Close() // close the db to prevent memory leak if gblock.Hash() != t.json.Genesis.Hash { - return fmt.Errorf("genesis block hash doesn't match test: computed=%x, test=%x", gblock.Hash().Bytes()[:6], t.json.Genesis.Hash[:6]) + return nil, fmt.Errorf("genesis block hash doesn't match test: computed=%x, test=%x", gblock.Hash().Bytes()[:6], t.json.Genesis.Hash[:6]) } if gblock.Root() != t.json.Genesis.StateRoot { - return fmt.Errorf("genesis block state root does not match test: computed=%x, test=%x", gblock.Root().Bytes()[:6], t.json.Genesis.StateRoot[:6]) + return nil, fmt.Errorf("genesis block state root does not match test: computed=%x, test=%x", gblock.Root().Bytes()[:6], t.json.Genesis.StateRoot[:6]) } // Wrap the original engine within the beacon-engine engine := beacon.New(ethash.NewFaker()) @@ -164,12 +157,28 @@ func (t *BlockTest) Run(snapshotter bool, scheme string, witness bool, tracer *t Tracer: tracer, }, StatelessSelfValidation: witness, + NoPrefetch: true, + BlockingPrefetch: true, + PrefetchWorkers: 100, // note: this is totally unrelated to NoPrefetch, just for BAL execution } if snapshotter { options.SnapshotLimit = 1 options.SnapshotWait = true } chain, err := core.NewBlockChain(db, gspec, engine, options) + if err != nil { + return nil, err + } + return chain, nil +} + +func (t *BlockTest) Run(snapshotter bool, scheme string, witness, createAndVerifyBAL bool, tracer *tracing.Hooks, postCheck func(error, *core.BlockChain)) (result error) { + config, ok := Forks[t.json.Network] + if !ok { + return UnsupportedForkError{t.json.Network} + } + + chain, err := t.createTestBlockChain(config, snapshotter, scheme, witness, createAndVerifyBAL, tracer) if err != nil { return err } @@ -203,7 +212,50 @@ func (t *BlockTest) Run(snapshotter bool, scheme string, witness bool, tracer *t } } } - return t.validateImportedHeaders(chain, validBlocks) + err = t.validateImportedHeaders(chain, validBlocks) + if err != nil { + return err + } + + if createAndVerifyBAL { + newChain, _ := t.createTestBlockChain(config, snapshotter, scheme, witness, createAndVerifyBAL, tracer) + defer newChain.Stop() + + var blocksWithBAL types.Blocks + for i := uint64(1); i <= chain.CurrentBlock().Number.Uint64(); i++ { + block := chain.GetBlockByNumber(i) + if chain.Config().IsAmsterdam(block.Number(), block.Time()) && block.AccessList() == nil { + return fmt.Errorf("block %d missing BAL", block.NumberU64()) + } + blocksWithBAL = append(blocksWithBAL, block) + } + + amt, err := newChain.InsertChain(blocksWithBAL) + if err != nil { + return err + } + _ = amt + newDB, err := newChain.State() + if err != nil { + return err + } + if err = t.validatePostState(newDB); err != nil { + return fmt.Errorf("post state validation failed: %v", err) + } + // Cross-check the snapshot-to-hash against the trie hash + if snapshotter { + if newChain.Snapshots() != nil { + if err := chain.Snapshots().Verify(chain.CurrentBlock().Root); err != nil { + return err + } + } + } + err = t.validateImportedHeaders(newChain, validBlocks) + if err != nil { + return err + } + } + return nil } // Network returns the network/fork name for this test. diff --git a/trie/bintrie/trie.go b/trie/bintrie/trie.go index e3436e3df1bf..3d236174d444 100644 --- a/trie/bintrie/trie.go +++ b/trie/bintrie/trie.go @@ -419,3 +419,11 @@ func (t *BinaryTrie) PrefetchStorage(addr common.Address, keys [][]byte) error { func (t *BinaryTrie) Witness() map[string][]byte { return t.tracer.Values() } + +func (t *BinaryTrie) UpdateStorageBatch(_ common.Address, keys [][]byte, values [][]byte) error { + panic("not implemented") +} + +func (t *BinaryTrie) UpdateAccountBatch(addresses []common.Address, accounts []*types.StateAccount, _ []int) error { + panic("not implemented") +} diff --git a/trie/secure_trie.go b/trie/secure_trie.go index 4d03ca45f011..f2176310d002 100644 --- a/trie/secure_trie.go +++ b/trie/secure_trie.go @@ -210,6 +210,29 @@ func (t *StateTrie) UpdateStorage(_ common.Address, key, value []byte) error { return nil } +// UpdateStorageBatch attempts to update a list storages in the batch manner. +func (t *StateTrie) UpdateStorageBatch(_ common.Address, keys [][]byte, values [][]byte) error { + var ( + hkeys = make([][]byte, 0, len(keys)) + evals = make([][]byte, 0, len(values)) + ) + for _, key := range keys { + hk := crypto.Keccak256(key) + if t.preimages != nil { + t.secKeyCache[common.Hash(hk)] = key + } + hkeys = append(hkeys, hk) + } + for _, val := range values { + data, err := rlp.EncodeToBytes(val) + if err != nil { + return err + } + evals = append(evals, data) + } + return t.trie.UpdateBatch(hkeys, evals) +} + // UpdateAccount will abstract the write of an account to the secure trie. func (t *StateTrie) UpdateAccount(address common.Address, acc *types.StateAccount, _ int) error { hk := crypto.Keccak256(address.Bytes()) @@ -226,6 +249,29 @@ func (t *StateTrie) UpdateAccount(address common.Address, acc *types.StateAccoun return nil } +// UpdateAccountBatch attempts to update a list accounts in the batch manner. +func (t *StateTrie) UpdateAccountBatch(addresses []common.Address, accounts []*types.StateAccount, _ []int) error { + var ( + hkeys = make([][]byte, 0, len(addresses)) + values = make([][]byte, 0, len(accounts)) + ) + for _, addr := range addresses { + hk := crypto.Keccak256(addr.Bytes()) + if t.preimages != nil { + t.secKeyCache[common.Hash(hk)] = addr.Bytes() + } + hkeys = append(hkeys, hk) + } + for _, acc := range accounts { + data, err := rlp.EncodeToBytes(acc) + if err != nil { + return err + } + values = append(values, data) + } + return t.trie.UpdateBatch(hkeys, values) +} + func (t *StateTrie) UpdateContractCode(_ common.Address, _ common.Hash, _ []byte) error { return nil } diff --git a/trie/tracer.go b/trie/tracer.go index 04122d1384f9..042fa468bfa5 100644 --- a/trie/tracer.go +++ b/trie/tracer.go @@ -33,12 +33,10 @@ import ( // while the latter is inserted/deleted in order to follow the rule of trie. // This tool can track all of them no matter the node is embedded in its // parent or not, but valueNode is never tracked. -// -// Note opTracer is not thread-safe, callers should be responsible for handling -// the concurrency issues by themselves. type opTracer struct { inserts map[string]struct{} deletes map[string]struct{} + lock sync.RWMutex } // newOpTracer initializes the tracer for capturing trie changes. @@ -53,6 +51,9 @@ func newOpTracer() *opTracer { // in the deletion set (resurrected node), then just wipe it from // the deletion set as it's "untouched". func (t *opTracer) onInsert(path []byte) { + t.lock.Lock() + defer t.lock.Unlock() + if _, present := t.deletes[string(path)]; present { delete(t.deletes, string(path)) return @@ -64,6 +65,9 @@ func (t *opTracer) onInsert(path []byte) { // in the addition set, then just wipe it from the addition set // as it's untouched. func (t *opTracer) onDelete(path []byte) { + t.lock.Lock() + defer t.lock.Unlock() + if _, present := t.inserts[string(path)]; present { delete(t.inserts, string(path)) return @@ -73,12 +77,18 @@ func (t *opTracer) onDelete(path []byte) { // reset clears the content tracked by tracer. func (t *opTracer) reset() { + t.lock.Lock() + defer t.lock.Unlock() + clear(t.inserts) clear(t.deletes) } // copy returns a deep copied tracer instance. func (t *opTracer) copy() *opTracer { + t.lock.RLock() + defer t.lock.RUnlock() + return &opTracer{ inserts: maps.Clone(t.inserts), deletes: maps.Clone(t.deletes), @@ -87,6 +97,9 @@ func (t *opTracer) copy() *opTracer { // deletedList returns a list of node paths which are deleted from the trie. func (t *opTracer) deletedList() [][]byte { + t.lock.RLock() + defer t.lock.RUnlock() + paths := make([][]byte, 0, len(t.deletes)) for path := range t.deletes { paths = append(paths, []byte(path)) diff --git a/trie/transitiontrie/transition.go b/trie/transitiontrie/transition.go index 3e5511be9ed2..d939e804e371 100644 --- a/trie/transitiontrie/transition.go +++ b/trie/transitiontrie/transition.go @@ -144,6 +144,19 @@ func (t *TransitionTrie) UpdateStorage(address common.Address, key []byte, value return t.overlay.UpdateStorage(address, key, v) } +// UpdateStorageBatch attempts to update a list storages in the batch manner. +func (t *TransitionTrie) UpdateStorageBatch(address common.Address, keys [][]byte, values [][]byte) error { + if len(keys) != len(values) { + return fmt.Errorf("keys and values length mismatch: %d != %d", len(keys), len(values)) + } + for i, key := range keys { + if err := t.UpdateStorage(address, key, values[i]); err != nil { + return err + } + } + return nil +} + // UpdateAccount abstract an account write to the trie. func (t *TransitionTrie) UpdateAccount(addr common.Address, account *types.StateAccount, codeLen int) error { // NOTE: before the rebase, this was saving the state root, so that OpenStorageTrie @@ -152,6 +165,22 @@ func (t *TransitionTrie) UpdateAccount(addr common.Address, account *types.State return t.overlay.UpdateAccount(addr, account, codeLen) } +// UpdateAccountBatch attempts to update a list accounts in the batch manner. +func (t *TransitionTrie) UpdateAccountBatch(addresses []common.Address, accounts []*types.StateAccount, codeLens []int) error { + if len(addresses) != len(accounts) { + return fmt.Errorf("address and accounts length mismatch: %d != %d", len(addresses), len(accounts)) + } + if len(addresses) != len(codeLens) { + return fmt.Errorf("address and code length mismatch: %d != %d", len(addresses), len(codeLens)) + } + for i, addr := range addresses { + if err := t.UpdateAccount(addr, accounts[i], codeLens[i]); err != nil { + return err + } + } + return nil +} + // DeleteStorage removes any existing value for key from the trie. If a node was not // found in the database, a trie.MissingNodeError is returned. func (t *TransitionTrie) DeleteStorage(addr common.Address, key []byte) error { diff --git a/trie/trie.go b/trie/trie.go index 1ef2c2f1a66e..7e69a90823fb 100644 --- a/trie/trie.go +++ b/trie/trie.go @@ -480,6 +480,69 @@ func (t *Trie) insert(n node, prefix, key []byte, value node) (bool, node, error } } +// UpdateBatch updates a batch of entries concurrently. +func (t *Trie) UpdateBatch(keys [][]byte, values [][]byte) error { + // Short circuit if the trie is already committed and unusable. + if t.committed { + return ErrCommitted + } + if len(keys) != len(values) { + return fmt.Errorf("keys and values length mismatch: %d != %d", len(keys), len(values)) + } + // Insert the entries sequentially if there are not too many + // trie nodes in the trie. + fn, ok := t.root.(*fullNode) + if !ok || len(keys) < 4 { // TODO(rjl493456442) the parallelism threshold should be twisted + for i, key := range keys { + err := t.Update(key, values[i]) + if err != nil { + return err + } + } + return nil + } + var ( + ikeys = make(map[byte][][]byte) + ivals = make(map[byte][][]byte) + eg errgroup.Group + ) + for i, key := range keys { + hkey := keybytesToHex(key) + ikeys[hkey[0]] = append(ikeys[hkey[0]], hkey) + ivals[hkey[0]] = append(ivals[hkey[0]], values[i]) + } + if len(keys) > 0 { + fn.flags = t.newFlag() + } + for pos, ks := range ikeys { + eg.Go(func() error { + vs := ivals[pos] + for i, k := range ks { + if len(vs[i]) != 0 { + _, n, err := t.insert(fn.Children[pos], []byte{pos}, k[1:], valueNode(vs[i])) + if err != nil { + return err + } + fn.Children[pos] = n + } else { + _, n, err := t.delete(fn.Children[pos], []byte{pos}, k[1:]) + if err != nil { + return err + } + fn.Children[pos] = n + } + } + return nil + }) + } + if err := eg.Wait(); err != nil { + return err + } + t.unhashed += len(keys) + t.uncommitted += len(keys) + return nil +} + // MustDelete is a wrapper of Delete and will omit any encountered error but // just print out an error message. func (t *Trie) MustDelete(key []byte) { diff --git a/trie/trie_test.go b/trie/trie_test.go index 3661933e2281..949f381f0786 100644 --- a/trie/trie_test.go +++ b/trie/trie_test.go @@ -1580,3 +1580,57 @@ func BenchmarkTrieSeqPrefetch(b *testing.B) { } } } + +func TestUpdateBatch(t *testing.T) { + testUpdateBatch(t, []kv{ + {k: []byte("do"), v: []byte("verb")}, + {k: []byte("ether"), v: []byte("wookiedoo")}, + {k: []byte("horse"), v: []byte("stallion")}, + {k: []byte("shaman"), v: []byte("horse")}, + {k: []byte("doge"), v: []byte("coin")}, + {k: []byte("dog"), v: []byte("puppy")}, + }) + + var entries []kv + for i := 0; i < 256; i++ { + entries = append(entries, kv{k: testrand.Bytes(32), v: testrand.Bytes(32)}) + } + testUpdateBatch(t, entries) +} + +func testUpdateBatch(t *testing.T, entries []kv) { + var ( + base = NewEmpty(nil) + keys [][]byte + vals [][]byte + ) + for _, entry := range entries { + base.Update(entry.k, entry.v) + keys = append(keys, entry.k) + vals = append(vals, entry.v) + } + for i := 0; i < 10; i++ { + k, v := testrand.Bytes(32), testrand.Bytes(32) + base.Update(k, v) + keys = append(keys, k) + vals = append(vals, v) + } + + cmp := NewEmpty(nil) + if err := cmp.UpdateBatch(keys, vals); err != nil { + t.Fatalf("Failed to update batch, %v", err) + } + + // Traverse the original tree, the changes made on the copy one shouldn't + // affect the old one + for _, key := range keys { + v1, _ := base.Get(key) + v2, _ := cmp.Get(key) + if !bytes.Equal(v1, v2) { + t.Errorf("Unexpected data, key: %v, want: %v, got: %v", key, v1, v2) + } + } + if base.Hash() != cmp.Hash() { + t.Errorf("Hash mismatch: want %x, got %x", base.Hash(), cmp.Hash()) + } +} From 704f795ed27b0974229251c2f89c16dc3f53eba2 Mon Sep 17 00:00:00 2001 From: Jared Wasinger Date: Mon, 1 Jun 2026 22:13:18 -0400 Subject: [PATCH 12/17] attach block access list in ExecutableDataToBlockNoHash --- beacon/engine/types.go | 88 ++++++++++++++++++++++++------------------ eth/catalyst/api.go | 6 ++- 2 files changed, 54 insertions(+), 40 deletions(-) diff --git a/beacon/engine/types.go b/beacon/engine/types.go index c5414f0a07f5..2946f55ccc3d 100644 --- a/beacon/engine/types.go +++ b/beacon/engine/types.go @@ -19,6 +19,7 @@ package engine import ( "bytes" "fmt" + "github.com/ethereum/go-ethereum/core/types/bal" "math/big" "slices" @@ -85,25 +86,25 @@ type payloadAttributesMarshaling struct { // ExecutableData is the data necessary to execute an EL payload. type ExecutableData struct { - ParentHash common.Hash `json:"parentHash" gencodec:"required"` - FeeRecipient common.Address `json:"feeRecipient" gencodec:"required"` - StateRoot common.Hash `json:"stateRoot" gencodec:"required"` - ReceiptsRoot common.Hash `json:"receiptsRoot" gencodec:"required"` - LogsBloom []byte `json:"logsBloom" gencodec:"required"` - Random common.Hash `json:"prevRandao" gencodec:"required"` - Number uint64 `json:"blockNumber" gencodec:"required"` - GasLimit uint64 `json:"gasLimit" gencodec:"required"` - GasUsed uint64 `json:"gasUsed" gencodec:"required"` - Timestamp uint64 `json:"timestamp" gencodec:"required"` - ExtraData []byte `json:"extraData" gencodec:"required"` - BaseFeePerGas *big.Int `json:"baseFeePerGas" gencodec:"required"` - BlockHash common.Hash `json:"blockHash" gencodec:"required"` - Transactions [][]byte `json:"transactions" gencodec:"required"` - Withdrawals []*types.Withdrawal `json:"withdrawals"` - BlobGasUsed *uint64 `json:"blobGasUsed"` - ExcessBlobGas *uint64 `json:"excessBlobGas"` - SlotNumber *uint64 `json:"slotNumber,omitempty"` - BlockAccessList hexutil.Bytes `json:"blockAccessList,omitempty"` + ParentHash common.Hash `json:"parentHash" gencodec:"required"` + FeeRecipient common.Address `json:"feeRecipient" gencodec:"required"` + StateRoot common.Hash `json:"stateRoot" gencodec:"required"` + ReceiptsRoot common.Hash `json:"receiptsRoot" gencodec:"required"` + LogsBloom []byte `json:"logsBloom" gencodec:"required"` + Random common.Hash `json:"prevRandao" gencodec:"required"` + Number uint64 `json:"blockNumber" gencodec:"required"` + GasLimit uint64 `json:"gasLimit" gencodec:"required"` + GasUsed uint64 `json:"gasUsed" gencodec:"required"` + Timestamp uint64 `json:"timestamp" gencodec:"required"` + ExtraData []byte `json:"extraData" gencodec:"required"` + BaseFeePerGas *big.Int `json:"baseFeePerGas" gencodec:"required"` + BlockHash common.Hash `json:"blockHash" gencodec:"required"` + Transactions [][]byte `json:"transactions" gencodec:"required"` + Withdrawals []*types.Withdrawal `json:"withdrawals"` + BlobGasUsed *uint64 `json:"blobGasUsed"` + ExcessBlobGas *uint64 `json:"excessBlobGas"` + SlotNumber *uint64 `json:"slotNumber,omitempty"` + BlockAccessList hexutil.Bytes `json:"blockAccessList,omitempty"` } // JSON type overrides for executableData. @@ -350,31 +351,42 @@ func ExecutableDataToBlockNoHash(data ExecutableData, versionedHashes []common.H SlotNumber: data.SlotNumber, BlockAccessListHash: blockAccessListHash, } - return types.NewBlockWithHeader(header).WithBody(types.Body{Transactions: txs, Uncles: nil, Withdrawals: data.Withdrawals}), nil + body := types.Body{Transactions: txs, Uncles: nil, Withdrawals: data.Withdrawals} + if data.BlockAccessList != nil { + balHash := crypto.Keccak256Hash(data.BlockAccessList) + header.BlockAccessListHash = &balHash + var accessList bal.BlockAccessList + if err := rlp.DecodeBytes(data.BlockAccessList, &accessList); err != nil { + return nil, fmt.Errorf("failed to decode BAL: %w\n", err) + } + block := types.NewBlockWithHeader(header).WithBody(body).WithAccessList(&accessList) + return block, nil + } + return types.NewBlockWithHeader(header).WithBody(body), nil } // BlockToExecutableData constructs the ExecutableData structure by filling the // fields from the given block. It assumes the given block is post-merge block. func BlockToExecutableData(block *types.Block, fees *big.Int, sidecars []*types.BlobTxSidecar, requests [][]byte) *ExecutionPayloadEnvelope { data := &ExecutableData{ - BlockHash: block.Hash(), - ParentHash: block.ParentHash(), - FeeRecipient: block.Coinbase(), - StateRoot: block.Root(), - Number: block.NumberU64(), - GasLimit: block.GasLimit(), - GasUsed: block.GasUsed(), - BaseFeePerGas: block.BaseFee(), - Timestamp: block.Time(), - ReceiptsRoot: block.ReceiptHash(), - LogsBloom: block.Bloom().Bytes(), - Transactions: encodeTransactions(block.Transactions()), - Random: block.MixDigest(), - ExtraData: block.Extra(), - Withdrawals: block.Withdrawals(), - BlobGasUsed: block.BlobGasUsed(), - ExcessBlobGas: block.ExcessBlobGas(), - SlotNumber: block.SlotNumber(), + BlockHash: block.Hash(), + ParentHash: block.ParentHash(), + FeeRecipient: block.Coinbase(), + StateRoot: block.Root(), + Number: block.NumberU64(), + GasLimit: block.GasLimit(), + GasUsed: block.GasUsed(), + BaseFeePerGas: block.BaseFee(), + Timestamp: block.Time(), + ReceiptsRoot: block.ReceiptHash(), + LogsBloom: block.Bloom().Bytes(), + Transactions: encodeTransactions(block.Transactions()), + Random: block.MixDigest(), + ExtraData: block.Extra(), + Withdrawals: block.Withdrawals(), + BlobGasUsed: block.BlobGasUsed(), + ExcessBlobGas: block.ExcessBlobGas(), + SlotNumber: block.SlotNumber(), } // Per Engine API spec (Amsterdam): blockAccessList is the RLP-encoded // access list, serialized as a hex string. Encode it to bytes here. diff --git a/eth/catalyst/api.go b/eth/catalyst/api.go index b31185a40f90..52038fde3b66 100644 --- a/eth/catalyst/api.go +++ b/eth/catalyst/api.go @@ -770,10 +770,12 @@ func (api *ConsensusAPI) NewPayloadV5(ctx context.Context, params engine.Executa return invalidStatus, paramsErr("nil beaconRoot post-cancun") case executionRequests == nil: return invalidStatus, paramsErr("nil executionRequests post-prague") - case params.SlotNumber == nil: - return invalidStatus, paramsErr("nil slotnumber post-amsterdam") case !api.checkFork(params.Timestamp, forks.Amsterdam): return invalidStatus, unsupportedForkErr("newPayloadV5 must only be called for amsterdam payloads") + case params.SlotNumber == nil: + return invalidStatus, paramsErr("nil slotnumber post-amsterdam") + case params.BlockAccessList == nil: + return invalidStatus, paramsErr("nil block access list post-amsterdam") } requests := convertRequests(executionRequests) if err := validateRequests(requests); err != nil { From e230772f11ca13d90f3797124e10d7a6e7029596 Mon Sep 17 00:00:00 2001 From: MariusVanDerWijden Date: Tue, 2 Jun 2026 18:37:57 +0200 Subject: [PATCH 13/17] core/types/bal: faster bal reader --- core/blockchain.go | 9 +- core/parallel_state_processor.go | 13 +- core/state/bal_state_transition.go | 13 +- core/state/reader_eip_7928.go | 70 +++++---- core/types/bal/bal_reader.go | 221 ++++++++++++++++++++++------- 5 files changed, 231 insertions(+), 95 deletions(-) diff --git a/core/blockchain.go b/core/blockchain.go index 29eca4800d7d..edc89cab1eeb 100644 --- a/core/blockchain.go +++ b/core/blockchain.go @@ -2129,13 +2129,16 @@ func (bc *BlockChain) processBlockWithAccessList(parentRoot common.Hash, block * useAsyncReads := bc.cfg.BALExecutionMode != bal.BALExecutionNoBatchIO al := block.AccessList() - accessListReader := bal.NewAccessListReader(*al) - prefetchReader, err := sdb.ReaderWithPrefetch(parentRoot, accessListReader.StorageKeys(useAsyncReads), bc.cfg.PrefetchWorkers, bc.cfg.BlockingPrefetch) + // Preprocess the access list once for the whole block; the resulting + // structure is read-only and shared by the prefetch reader, the state + // transition and every per-transaction execution reader. + prepared := bal.NewPreparedAccessList(*al) + prefetchReader, err := sdb.ReaderWithPrefetch(parentRoot, prepared.StorageKeys(useAsyncReads), bc.cfg.PrefetchWorkers, bc.cfg.BlockingPrefetch) if err != nil { return nil, err } - stateTransition, err := state.NewBALStateTransition(block, prefetchReader, sdb, parentRoot) + stateTransition, err := state.NewBALStateTransition(block, prefetchReader, sdb, parentRoot, prepared) if err != nil { return nil, err } diff --git a/core/parallel_state_processor.go b/core/parallel_state_processor.go index 3407aabad780..d2b67a6a86ca 100644 --- a/core/parallel_state_processor.go +++ b/core/parallel_state_processor.go @@ -44,14 +44,14 @@ func NewParallelStateProcessor(chain *HeaderChain, vmConfig *vm.Config) Parallel // performs post-tx state transition (system contracts and withdrawals) // and calculates the ProcessResult, returning it to be sent on resCh // by resultHandler -func (p *ParallelStateProcessor) prepareExecResult(block *types.Block, tExecStart time.Time, preTxBal *bal.ConstructionBlockAccessList, statedb *state.StateDB, results []txExecResult) *ProcessResultWithMetrics { +func (p *ParallelStateProcessor) prepareExecResult(block *types.Block, tExecStart time.Time, preTxBal *bal.ConstructionBlockAccessList, prepared *bal.PreparedAccessList, statedb *state.StateDB, results []txExecResult) *ProcessResultWithMetrics { tExec := time.Since(tExecStart) tPostprocessStart := time.Now() header := block.Header() vmContext := NewEVMBlockContext(header, p.chain, nil) lastBALIdx := len(block.Transactions()) + 1 - postTxState := statedb.WithReader(state.NewReaderWithBlockLevelAccessList(statedb.Reader(), *block.AccessList(), lastBALIdx)) + postTxState := statedb.WithReader(state.NewReaderWithPreparedAccessList(statedb.Reader(), prepared, lastBALIdx)) cfg := vm.Config{ NoBaseFee: p.vmCfg.NoBaseFee, @@ -148,7 +148,7 @@ type txExecResult struct { // resultHandler polls until all transactions have finished executing and the // state root calculation is complete. The result is emitted on resCh. -func (p *ParallelStateProcessor) resultHandler(block *types.Block, preTxBAL *bal.ConstructionBlockAccessList, statedb *state.StateDB, tExecStart time.Time, txResCh <-chan txExecResult, stateRootCalcResCh <-chan stateRootCalculationResult, resCh chan *ProcessResultWithMetrics) { +func (p *ParallelStateProcessor) resultHandler(block *types.Block, preTxBAL *bal.ConstructionBlockAccessList, prepared *bal.PreparedAccessList, statedb *state.StateDB, tExecStart time.Time, txResCh <-chan txExecResult, stateRootCalcResCh <-chan stateRootCalculationResult, resCh chan *ProcessResultWithMetrics) { // 1. if the block has transactions, receive the execution results from all of them and return an error on resCh if any txs err'd // 2. once all txs are executed, compute the post-tx state transition and produce the ProcessResult sending it on resCh (or an error if the post-tx state didn't match what is reported in the BAL) var results []txExecResult @@ -187,7 +187,7 @@ func (p *ParallelStateProcessor) resultHandler(block *types.Block, preTxBAL *bal } } - execResults := p.prepareExecResult(block, tExecStart, preTxBAL, statedb, results) + execResults := p.prepareExecResult(block, tExecStart, preTxBAL, prepared, statedb, results) rootCalcRes := <-stateRootCalcResCh if execResults.ProcessResult.Error != nil { @@ -292,6 +292,7 @@ func (p *ParallelStateProcessor) Process(block *types.Block, stateTransition *st ) startingState := statedb.Copy() + prepared := stateTransition.PreparedAccessList() preTxBal, err := p.processBlockPreTx(block, statedb, cfg) if err != nil { return nil, err @@ -302,7 +303,7 @@ func (p *ParallelStateProcessor) Process(block *types.Block, stateTransition *st // execute transactions and state root calculation in parallel tExecStart = time.Now() - go p.resultHandler(block, preTxBal, statedb, tExecStart, txResCh, rootCalcResultCh, resCh) + go p.resultHandler(block, preTxBal, prepared, statedb, tExecStart, txResCh, rootCalcResultCh, resCh) var workers errgroup.Group workers.SetLimit(runtime.NumCPU()) for i, t := range block.Transactions() { @@ -310,7 +311,7 @@ func (p *ParallelStateProcessor) Process(block *types.Block, stateTransition *st idx := i sdb := startingState.Copy() workers.Go(func() error { - startingState := sdb.WithReader(state.NewReaderWithBlockLevelAccessList(statedb.Reader(), *block.AccessList(), idx+1)) + startingState := sdb.WithReader(state.NewReaderWithPreparedAccessList(statedb.Reader(), prepared, idx+1)) res := p.execTx(block, tx, idx+1, startingState, signer) txResCh <- *res return nil diff --git a/core/state/bal_state_transition.go b/core/state/bal_state_transition.go index de643ab0d1b9..7e44496f7f08 100644 --- a/core/state/bal_state_transition.go +++ b/core/state/bal_state_transition.go @@ -19,7 +19,7 @@ import ( // and commit for EIP 7928 access-list-containing blocks. An instance of // this object is only used for a single block. type BALStateTransition struct { - accessList bal.AccessListReader + accessList *bal.PreparedAccessList written bal.WrittenCounts db Database reader Reader @@ -86,14 +86,14 @@ type BALStateTransitionMetrics struct { TotalCommitTime time.Duration } -func NewBALStateTransition(block *types.Block, prefetchReader Reader, db Database, parentRoot common.Hash) (*BALStateTransition, error) { +func NewBALStateTransition(block *types.Block, prefetchReader Reader, db Database, parentRoot common.Hash, prepared *bal.PreparedAccessList) (*BALStateTransition, error) { stateTrie, err := db.OpenTrie(parentRoot) if err != nil { return nil, err } return &BALStateTransition{ - accessList: bal.NewAccessListReader(*block.AccessList()), + accessList: prepared, written: block.AccessList().WrittenCounts(), db: db, reader: prefetchReader, @@ -115,6 +115,13 @@ func (s *BALStateTransition) WrittenCounts() bal.WrittenCounts { return s.written } +// PreparedAccessList returns the shared, read-only preprocessed access list for +// the block. It is built once per block and reused by the parallel execution +// readers so the preprocessing is not repeated per transaction. +func (s *BALStateTransition) PreparedAccessList() *bal.PreparedAccessList { + return s.accessList +} + func (s *BALStateTransition) Error() error { return s.err } diff --git a/core/state/reader_eip_7928.go b/core/state/reader_eip_7928.go index 72727d35c3ed..b3390ad63c7d 100644 --- a/core/state/reader_eip_7928.go +++ b/core/state/reader_eip_7928.go @@ -210,20 +210,34 @@ func (r *prefetchStateReader) process(start, limit int) { // ReaderWithBlockLevelAccessList provides state access that reflects the // pre-transition state combined with the mutations made by transactions // prior to TxIndex. +// +// It is a cheap, per-transaction view over a shared, read-only +// bal.PreparedAccessList: constructing one is O(1) and every lookup is an +// allocation-free binary search. type ReaderWithBlockLevelAccessList struct { Reader - AccessList bal.AccessListReader - TxIndex int + prepared *bal.PreparedAccessList + TxIndex int } -func NewReaderWithBlockLevelAccessList(base Reader, accessList bal.BlockAccessList, txIndex int) *ReaderWithBlockLevelAccessList { +// NewReaderWithPreparedAccessList wraps a base reader with a shared, already +// preprocessed access list. This is the cheap constructor used on the hot path: +// the prepared list is built once per block and borrowed by every per-tx reader. +func NewReaderWithPreparedAccessList(base Reader, prepared *bal.PreparedAccessList, txIndex int) *ReaderWithBlockLevelAccessList { return &ReaderWithBlockLevelAccessList{ - Reader: base, - AccessList: bal.NewAccessListReader(accessList), - TxIndex: txIndex, + Reader: base, + prepared: prepared, + TxIndex: txIndex, } } +// NewReaderWithBlockLevelAccessList wraps a base reader with a raw access list, +// preprocessing it on the spot. Prefer NewReaderWithPreparedAccessList when the +// prepared list can be built once and shared across multiple readers. +func NewReaderWithBlockLevelAccessList(base Reader, accessList bal.BlockAccessList, txIndex int) *ReaderWithBlockLevelAccessList { + return NewReaderWithPreparedAccessList(base, bal.NewPreparedAccessList(accessList), txIndex) +} + // Account implements Reader, returning the account with the specific address. func (r *ReaderWithBlockLevelAccessList) Account(addr common.Address) (acct *types.StateAccount, err error) { acct, err = r.Reader.Account(addr) @@ -231,9 +245,11 @@ func (r *ReaderWithBlockLevelAccessList) Account(addr common.Address) (acct *typ return nil, err } - mut := r.AccessList.AccountMutations(addr, r.TxIndex) - if mut == nil { - return + balance := r.prepared.Balance(addr, r.TxIndex) + code := r.prepared.Code(addr, r.TxIndex) + nonce, hasNonce := r.prepared.Nonce(addr, r.TxIndex) + if balance == nil && code == nil && !hasNonce { + return acct, nil } if acct == nil { @@ -244,15 +260,18 @@ func (r *ReaderWithBlockLevelAccessList) Account(addr common.Address) (acct *typ acct = acct.Copy() } - if mut.Balance != nil { - acct.Balance = mut.Balance + // balance and code alias the shared access list; this is safe because the + // EVM never mutates them in place (it replaces the pointer/slice wholesale, + // and the journal clones before stashing). + if balance != nil { + acct.Balance = balance } - if mut.Code != nil { - codeHash := crypto.Keccak256Hash(mut.Code) + if code != nil { + codeHash := crypto.Keccak256Hash(code) acct.CodeHash = codeHash[:] } - if mut.Nonce != nil { - acct.Nonce = *mut.Nonce + if hasNonce { + acct.Nonce = nonce } return } @@ -260,9 +279,8 @@ func (r *ReaderWithBlockLevelAccessList) Account(addr common.Address) (acct *typ // Storage implements Reader, returning the storage slot with the specific // address and slot key. func (r *ReaderWithBlockLevelAccessList) Storage(addr common.Address, slot common.Hash) (common.Hash, error) { - val := r.AccessList.Storage(addr, slot, r.TxIndex) - if val != nil { - return *val, nil + if val, ok := r.prepared.StorageAt(addr, slot, r.TxIndex); ok { + return val, nil } return r.Reader.Storage(addr, slot) } @@ -270,9 +288,8 @@ func (r *ReaderWithBlockLevelAccessList) Storage(addr common.Address, slot commo // Has implements Reader, returning the flag indicating whether the contract // code with specified address and hash exists or not. func (r *ReaderWithBlockLevelAccessList) Has(addr common.Address, codeHash common.Hash) bool { - mut := r.AccessList.AccountMutations(addr, r.TxIndex) - if mut != nil && mut.Code != nil { - return crypto.Keccak256Hash(mut.Code) == codeHash + if code := r.prepared.Code(addr, r.TxIndex); code != nil { + return crypto.Keccak256Hash(code) == codeHash } return r.Reader.Has(addr, codeHash) } @@ -280,10 +297,8 @@ func (r *ReaderWithBlockLevelAccessList) Has(addr common.Address, codeHash commo // Code implements Reader, returning the contract code with specified address // and hash. func (r *ReaderWithBlockLevelAccessList) Code(addr common.Address, codeHash common.Hash) []byte { - mut := r.AccessList.AccountMutations(addr, r.TxIndex) - if mut != nil && mut.Code != nil && crypto.Keccak256Hash(mut.Code) == codeHash { - // TODO: need to copy here? - return mut.Code + if code := r.prepared.Code(addr, r.TxIndex); code != nil && crypto.Keccak256Hash(code) == codeHash { + return code } return r.Reader.Code(addr, codeHash) } @@ -291,9 +306,8 @@ func (r *ReaderWithBlockLevelAccessList) Code(addr common.Address, codeHash comm // CodeSize implements Reader, returning the contract code size with specified // address and hash. func (r *ReaderWithBlockLevelAccessList) CodeSize(addr common.Address, codeHash common.Hash) int { - mut := r.AccessList.AccountMutations(addr, r.TxIndex) - if mut != nil && mut.Code != nil && crypto.Keccak256Hash(mut.Code) == codeHash { - return len(mut.Code) + if code := r.prepared.Code(addr, r.TxIndex); code != nil && crypto.Keccak256Hash(code) == codeHash { + return len(code) } return r.Reader.CodeSize(addr, codeHash) } diff --git a/core/types/bal/bal_reader.go b/core/types/bal/bal_reader.go index e6cffc922eed..07b32c8ab137 100644 --- a/core/types/bal/bal_reader.go +++ b/core/types/bal/bal_reader.go @@ -1,53 +1,176 @@ package bal import ( - "bytes" + "sort" + "github.com/ethereum/go-ethereum/common" + "github.com/holiman/uint256" ) -// AccessListReader exposes utilities to read state mutations and accesses from an access list -type AccessListReader map[common.Address]*AccountAccess +// PreparedAccessList is an immutable, per-block preprocessed view of a +// BlockAccessList optimized for repeated point-in-time reads. +// +// It is built once per block (NewPreparedAccessList) before parallel +// transaction execution begins. The change slices it holds are the +// already-sorted slices decoded from the BlockAccessList, borrowed by +// reference (never copied, never mutated). After construction the structure +// is read-only and therefore safe for concurrent use by all per-transaction +// readers without any synchronization. +// +// Each lookup binary-searches the relevant change slice for the last mutation +// strictly before the queried block-access index, which is O(log K) and +// allocation-free, in contrast to the previous map-backed reader that +// re-walked every change array from index 0 and re-allocated an aggregate +// mutation object on every call. +type PreparedAccessList struct { + accounts map[common.Address]*preparedAccount +} + +type preparedAccount struct { + // The following slices are borrowed directly from the decoded + // AccountAccess. They are validated to be strictly sorted ascending by + // BlockAccessIndex (see bal_encoding.go), which is exactly the key we + // binary-search on. + balances []encodingBalanceChange + nonces []encodingAccountNonce + codes []encodingCodeChange + storage map[common.Hash]*preparedSlot + + // access is retained to back the once-per-block aggregate helpers + // (StorageKeys, AllDestructions) without re-deriving anything. + access *AccountAccess +} + +type preparedSlot struct { + changes []encodingStorageWrite // borrowed, sorted asc by BlockAccessIndex +} -func NewAccessListReader(bal BlockAccessList) (reader AccessListReader) { - reader = make(AccessListReader) - for _, accountAccess := range bal { - reader[accountAccess.Address] = &accountAccess +// NewPreparedAccessList preprocesses a BlockAccessList into a PreparedAccessList. +// It performs a single linear pass and borrows the underlying change slices by +// reference; the provided list must not be mutated afterwards. +func NewPreparedAccessList(list BlockAccessList) *PreparedAccessList { + accounts := make(map[common.Address]*preparedAccount, len(list)) + for i := range list { + a := &list[i] // index; do not range-copy the AccountAccess + pa := &preparedAccount{ + balances: a.BalanceChanges, + nonces: a.NonceChanges, + codes: a.CodeChanges, + access: a, + } + if len(a.StorageChanges) > 0 { + pa.storage = make(map[common.Hash]*preparedSlot, len(a.StorageChanges)) + for j := range a.StorageChanges { + sc := &a.StorageChanges[j] + pa.storage[sc.Slot.Bytes32()] = &preparedSlot{changes: sc.SlotChanges} + } + } + accounts[a.Address] = pa } - return + return &PreparedAccessList{accounts: accounts} +} + +// lastBefore returns the position of the last element in a slice of n elements +// sorted ascending by BlockAccessIndex whose key is strictly less than idx, or +// -1 if no such element exists. keyAt returns the BlockAccessIndex at position k. +func lastBefore(n int, idx uint32, keyAt func(k int) uint32) int { + // sort.Search returns the smallest position whose key is >= idx; everything + // before it is strictly less than idx, so the answer is that position - 1. + return sort.Search(n, func(k int) bool { return keyAt(k) >= idx }) - 1 } -// AccountMutations returns the aggregate mutation for an account up until (and not including) the given block access -// list index. -func (a AccessListReader) AccountMutations(addr common.Address, idx int) (res *AccountMutations) { - diff, exist := a[addr] - if !exist { +// Balance returns the post-balance in effect immediately before the given block +// access index, or nil if the account's balance was not changed before idx. +// The returned pointer aliases the access list and must not be mutated. +func (p *PreparedAccessList) Balance(addr common.Address, idx int) *uint256.Int { + a := p.accounts[addr] + if a == nil { + return nil + } + k := lastBefore(len(a.balances), uint32(idx), func(i int) uint32 { return a.balances[i].BlockAccessIndex }) + if k < 0 { return nil } + return a.balances[k].PostBalance +} - res = &AccountMutations{} +// Nonce returns the post-nonce in effect immediately before the given block +// access index. The boolean is false if the nonce was not changed before idx. +func (p *PreparedAccessList) Nonce(addr common.Address, idx int) (uint64, bool) { + a := p.accounts[addr] + if a == nil { + return 0, false + } + k := lastBefore(len(a.nonces), uint32(idx), func(i int) uint32 { return a.nonces[i].BlockAccessIndex }) + if k < 0 { + return 0, false + } + return a.nonces[k].PostNonce, true +} - for i := 0; i < len(diff.BalanceChanges) && diff.BalanceChanges[i].BlockAccessIndex < uint32(idx); i++ { - res.Balance = diff.BalanceChanges[i].PostBalance.Clone() +// Code returns the contract code in effect immediately before the given block +// access index, or nil if the code was not changed before idx. The returned +// slice aliases the access list and must not be mutated. +func (p *PreparedAccessList) Code(addr common.Address, idx int) []byte { + a := p.accounts[addr] + if a == nil { + return nil } + k := lastBefore(len(a.codes), uint32(idx), func(i int) uint32 { return a.codes[i].BlockAccessIndex }) + if k < 0 { + return nil + } + return a.codes[k].NewCode +} - for i := 0; i < len(diff.CodeChanges) && diff.CodeChanges[i].BlockAccessIndex < uint32(idx); i++ { - res.Code = bytes.Clone(diff.CodeChanges[i].NewCode) +// StorageAt returns the post-value of a storage slot immediately before the +// given block access index. The boolean is false if the slot was not written +// before idx. +func (p *PreparedAccessList) StorageAt(addr common.Address, slot common.Hash, idx int) (common.Hash, bool) { + a := p.accounts[addr] + if a == nil { + return common.Hash{}, false + } + s := a.storage[slot] + if s == nil { + return common.Hash{}, false + } + k := lastBefore(len(s.changes), uint32(idx), func(i int) uint32 { return s.changes[i].BlockAccessIndex }) + if k < 0 { + return common.Hash{}, false } + return s.changes[k].PostValue.Bytes32(), true +} - for i := 0; i < len(diff.NonceChanges) && diff.NonceChanges[i].BlockAccessIndex < uint32(idx); i++ { +// AccountMutations returns the aggregate mutation for an account up until (and +// not including) the given block access list index, or nil if the account was +// not mutated before idx. +func (p *PreparedAccessList) AccountMutations(addr common.Address, idx int) *AccountMutations { + a := p.accounts[addr] + if a == nil { + return nil + } + res := &AccountMutations{} + if bal := p.Balance(addr, idx); bal != nil { + res.Balance = bal.Clone() + } + if code := p.Code(addr, idx); code != nil { + res.Code = code + } + if nonce, ok := p.Nonce(addr, idx); ok { res.Nonce = new(uint64) - *res.Nonce = diff.NonceChanges[i].PostNonce + *res.Nonce = nonce } - - if len(diff.StorageChanges) > 0 { - res.StorageWrites = make(map[common.Hash]common.Hash) - for _, slotWrites := range diff.StorageChanges { - for i := 0; i < len(slotWrites.SlotChanges) && slotWrites.SlotChanges[i].BlockAccessIndex < uint32(idx); i++ { - res.StorageWrites[slotWrites.Slot.Bytes32()] = slotWrites.SlotChanges[i].PostValue.Bytes32() - } + for slot, s := range a.storage { + k := lastBefore(len(s.changes), uint32(idx), func(i int) uint32 { return s.changes[i].BlockAccessIndex }) + if k < 0 { + continue } + if res.StorageWrites == nil { + res.StorageWrites = make(map[common.Hash]common.Hash) + } + res.StorageWrites[slot] = s.changes[k].PostValue.Bytes32() } - if res.Code == nil && res.Nonce == nil && len(res.StorageWrites) == 0 && res.Balance == nil { return nil } @@ -56,11 +179,12 @@ func (a AccessListReader) AccountMutations(addr common.Address, idx int) (res *A type StorageKeys map[common.Address][]common.Hash -// StorageKeys returns the set of accounts and storage keys mutated in the access list. -// If reads is set, the un-mutated accounts/keys are included in the result. -func (a AccessListReader) StorageKeys(reads bool) (keys StorageKeys) { +// StorageKeys returns the set of accounts and storage keys mutated in the access +// list. If reads is set, the un-mutated accounts/keys are included in the result. +func (p *PreparedAccessList) StorageKeys(reads bool) (keys StorageKeys) { keys = make(StorageKeys) - for addr, acct := range a { + for addr, a := range p.accounts { + acct := a.access for _, storageChange := range acct.StorageChanges { keys[addr] = append(keys[addr], storageChange.Slot.Bytes32()) } @@ -74,36 +198,23 @@ func (a AccessListReader) StorageKeys(reads bool) (keys StorageKeys) { return } -// Storage returns the value of a storage key at the start of executing an index. -// If the slot has no mutations in the access list, it returns nil. -func (a AccessListReader) Storage(addr common.Address, key common.Hash, idx int) (val *common.Hash) { - storageMuts := a.AccountMutations(addr, idx) - if storageMuts != nil { - res, ok := storageMuts.StorageWrites[key] - if ok { - return &res - } - } - return nil -} - -// Mutations returns the aggregate state mutations from bal indices [0, idx) -func (a AccessListReader) Mutations(idx int) *StateMutations { +// Mutations returns the aggregate state mutations from bal indices [0, idx). +func (p *PreparedAccessList) Mutations(idx int) *StateMutations { res := make(StateMutations) - for addr := range a { - if mut := a.AccountMutations(addr, idx); mut != nil { + for addr := range p.accounts { + if mut := p.AccountMutations(addr, idx); mut != nil { res[addr] = *mut } } return &res } -// AllDestructions returns all accounts that experienced a destruction, regardless of whether -// they were later resurrected and exist after the block. It excludes ephemeral contracts from -// the result. -func (a AccessListReader) AllDestructions() (res []common.Address) { - for addr, access := range a { - for _, nonce := range access.NonceChanges { +// AllDestructions returns all accounts that experienced a destruction, regardless +// of whether they were later resurrected and exist after the block. It excludes +// ephemeral contracts from the result. +func (p *PreparedAccessList) AllDestructions() (res []common.Address) { + for addr, a := range p.accounts { + for _, nonce := range a.access.NonceChanges { if nonce.PostNonce == 0 { res = append(res, addr) break From 2bf974b6e680300d6e4ce74cbfefc57ce9b6b8bd Mon Sep 17 00:00:00 2001 From: Jared Wasinger Date: Mon, 8 Jun 2026 18:21:57 -0400 Subject: [PATCH 14/17] clean up BAL state diff reader logic --- core/blockchain.go | 2 +- core/parallel_state_processor.go | 4 +- core/state/bal_state_transition.go | 6 +- core/state/reader_eip_7928.go | 8 +-- core/types/bal/bal_reader.go | 93 ++++++++++-------------------- 5 files changed, 42 insertions(+), 71 deletions(-) diff --git a/core/blockchain.go b/core/blockchain.go index edc89cab1eeb..4ecb986978b8 100644 --- a/core/blockchain.go +++ b/core/blockchain.go @@ -2132,7 +2132,7 @@ func (bc *BlockChain) processBlockWithAccessList(parentRoot common.Hash, block * // Preprocess the access list once for the whole block; the resulting // structure is read-only and shared by the prefetch reader, the state // transition and every per-transaction execution reader. - prepared := bal.NewPreparedAccessList(*al) + prepared := bal.NewAccessListReader(*al) prefetchReader, err := sdb.ReaderWithPrefetch(parentRoot, prepared.StorageKeys(useAsyncReads), bc.cfg.PrefetchWorkers, bc.cfg.BlockingPrefetch) if err != nil { return nil, err diff --git a/core/parallel_state_processor.go b/core/parallel_state_processor.go index d2b67a6a86ca..be2af36dbf41 100644 --- a/core/parallel_state_processor.go +++ b/core/parallel_state_processor.go @@ -44,7 +44,7 @@ func NewParallelStateProcessor(chain *HeaderChain, vmConfig *vm.Config) Parallel // performs post-tx state transition (system contracts and withdrawals) // and calculates the ProcessResult, returning it to be sent on resCh // by resultHandler -func (p *ParallelStateProcessor) prepareExecResult(block *types.Block, tExecStart time.Time, preTxBal *bal.ConstructionBlockAccessList, prepared *bal.PreparedAccessList, statedb *state.StateDB, results []txExecResult) *ProcessResultWithMetrics { +func (p *ParallelStateProcessor) prepareExecResult(block *types.Block, tExecStart time.Time, preTxBal *bal.ConstructionBlockAccessList, prepared *bal.AccessListReader, statedb *state.StateDB, results []txExecResult) *ProcessResultWithMetrics { tExec := time.Since(tExecStart) tPostprocessStart := time.Now() header := block.Header() @@ -148,7 +148,7 @@ type txExecResult struct { // resultHandler polls until all transactions have finished executing and the // state root calculation is complete. The result is emitted on resCh. -func (p *ParallelStateProcessor) resultHandler(block *types.Block, preTxBAL *bal.ConstructionBlockAccessList, prepared *bal.PreparedAccessList, statedb *state.StateDB, tExecStart time.Time, txResCh <-chan txExecResult, stateRootCalcResCh <-chan stateRootCalculationResult, resCh chan *ProcessResultWithMetrics) { +func (p *ParallelStateProcessor) resultHandler(block *types.Block, preTxBAL *bal.ConstructionBlockAccessList, prepared *bal.AccessListReader, statedb *state.StateDB, tExecStart time.Time, txResCh <-chan txExecResult, stateRootCalcResCh <-chan stateRootCalculationResult, resCh chan *ProcessResultWithMetrics) { // 1. if the block has transactions, receive the execution results from all of them and return an error on resCh if any txs err'd // 2. once all txs are executed, compute the post-tx state transition and produce the ProcessResult sending it on resCh (or an error if the post-tx state didn't match what is reported in the BAL) var results []txExecResult diff --git a/core/state/bal_state_transition.go b/core/state/bal_state_transition.go index 7e44496f7f08..5cf761d64733 100644 --- a/core/state/bal_state_transition.go +++ b/core/state/bal_state_transition.go @@ -19,7 +19,7 @@ import ( // and commit for EIP 7928 access-list-containing blocks. An instance of // this object is only used for a single block. type BALStateTransition struct { - accessList *bal.PreparedAccessList + accessList *bal.AccessListReader written bal.WrittenCounts db Database reader Reader @@ -86,7 +86,7 @@ type BALStateTransitionMetrics struct { TotalCommitTime time.Duration } -func NewBALStateTransition(block *types.Block, prefetchReader Reader, db Database, parentRoot common.Hash, prepared *bal.PreparedAccessList) (*BALStateTransition, error) { +func NewBALStateTransition(block *types.Block, prefetchReader Reader, db Database, parentRoot common.Hash, prepared *bal.AccessListReader) (*BALStateTransition, error) { stateTrie, err := db.OpenTrie(parentRoot) if err != nil { return nil, err @@ -118,7 +118,7 @@ func (s *BALStateTransition) WrittenCounts() bal.WrittenCounts { // PreparedAccessList returns the shared, read-only preprocessed access list for // the block. It is built once per block and reused by the parallel execution // readers so the preprocessing is not repeated per transaction. -func (s *BALStateTransition) PreparedAccessList() *bal.PreparedAccessList { +func (s *BALStateTransition) PreparedAccessList() *bal.AccessListReader { return s.accessList } diff --git a/core/state/reader_eip_7928.go b/core/state/reader_eip_7928.go index b3390ad63c7d..b89636870d6b 100644 --- a/core/state/reader_eip_7928.go +++ b/core/state/reader_eip_7928.go @@ -212,18 +212,18 @@ func (r *prefetchStateReader) process(start, limit int) { // prior to TxIndex. // // It is a cheap, per-transaction view over a shared, read-only -// bal.PreparedAccessList: constructing one is O(1) and every lookup is an +// bal.AccessListReader: constructing one is O(1) and every lookup is an // allocation-free binary search. type ReaderWithBlockLevelAccessList struct { Reader - prepared *bal.PreparedAccessList + prepared *bal.AccessListReader TxIndex int } // NewReaderWithPreparedAccessList wraps a base reader with a shared, already // preprocessed access list. This is the cheap constructor used on the hot path: // the prepared list is built once per block and borrowed by every per-tx reader. -func NewReaderWithPreparedAccessList(base Reader, prepared *bal.PreparedAccessList, txIndex int) *ReaderWithBlockLevelAccessList { +func NewReaderWithPreparedAccessList(base Reader, prepared *bal.AccessListReader, txIndex int) *ReaderWithBlockLevelAccessList { return &ReaderWithBlockLevelAccessList{ Reader: base, prepared: prepared, @@ -235,7 +235,7 @@ func NewReaderWithPreparedAccessList(base Reader, prepared *bal.PreparedAccessLi // preprocessing it on the spot. Prefer NewReaderWithPreparedAccessList when the // prepared list can be built once and shared across multiple readers. func NewReaderWithBlockLevelAccessList(base Reader, accessList bal.BlockAccessList, txIndex int) *ReaderWithBlockLevelAccessList { - return NewReaderWithPreparedAccessList(base, bal.NewPreparedAccessList(accessList), txIndex) + return NewReaderWithPreparedAccessList(base, bal.NewAccessListReader(accessList), txIndex) } // Account implements Reader, returning the account with the specific address. diff --git a/core/types/bal/bal_reader.go b/core/types/bal/bal_reader.go index 07b32c8ab137..bb4a97c8d474 100644 --- a/core/types/bal/bal_reader.go +++ b/core/types/bal/bal_reader.go @@ -7,67 +7,39 @@ import ( "github.com/holiman/uint256" ) -// PreparedAccessList is an immutable, per-block preprocessed view of a -// BlockAccessList optimized for repeated point-in-time reads. -// -// It is built once per block (NewPreparedAccessList) before parallel -// transaction execution begins. The change slices it holds are the -// already-sorted slices decoded from the BlockAccessList, borrowed by -// reference (never copied, never mutated). After construction the structure -// is read-only and therefore safe for concurrent use by all per-transaction -// readers without any synchronization. -// -// Each lookup binary-searches the relevant change slice for the last mutation -// strictly before the queried block-access index, which is O(log K) and -// allocation-free, in contrast to the previous map-backed reader that -// re-walked every change array from index 0 and re-allocated an aggregate -// mutation object on every call. -type PreparedAccessList struct { +// AccessListReader enables efficient state diff lookups from a block access +// list during block execution. +type AccessListReader struct { accounts map[common.Address]*preparedAccount } type preparedAccount struct { - // The following slices are borrowed directly from the decoded - // AccountAccess. They are validated to be strictly sorted ascending by - // BlockAccessIndex (see bal_encoding.go), which is exactly the key we - // binary-search on. - balances []encodingBalanceChange - nonces []encodingAccountNonce - codes []encodingCodeChange - storage map[common.Hash]*preparedSlot - - // access is retained to back the once-per-block aggregate helpers - // (StorageKeys, AllDestructions) without re-deriving anything. - access *AccountAccess + storage map[common.Hash]preparedSlot + AccountAccess } type preparedSlot struct { changes []encodingStorageWrite // borrowed, sorted asc by BlockAccessIndex } -// NewPreparedAccessList preprocesses a BlockAccessList into a PreparedAccessList. -// It performs a single linear pass and borrows the underlying change slices by -// reference; the provided list must not be mutated afterwards. -func NewPreparedAccessList(list BlockAccessList) *PreparedAccessList { +// NewAccessListReader instantiates an access list reader. +func NewAccessListReader(list BlockAccessList) *AccessListReader { accounts := make(map[common.Address]*preparedAccount, len(list)) for i := range list { - a := &list[i] // index; do not range-copy the AccountAccess + a := list[i] // index; do not range-copy the AccountAccess pa := &preparedAccount{ - balances: a.BalanceChanges, - nonces: a.NonceChanges, - codes: a.CodeChanges, - access: a, + AccountAccess: a, } if len(a.StorageChanges) > 0 { - pa.storage = make(map[common.Hash]*preparedSlot, len(a.StorageChanges)) + pa.storage = make(map[common.Hash]preparedSlot, len(a.StorageChanges)) for j := range a.StorageChanges { sc := &a.StorageChanges[j] - pa.storage[sc.Slot.Bytes32()] = &preparedSlot{changes: sc.SlotChanges} + pa.storage[sc.Slot.Bytes32()] = preparedSlot{changes: sc.SlotChanges} } } accounts[a.Address] = pa } - return &PreparedAccessList{accounts: accounts} + return &AccessListReader{accounts: accounts} } // lastBefore returns the position of the last element in a slice of n elements @@ -82,57 +54,57 @@ func lastBefore(n int, idx uint32, keyAt func(k int) uint32) int { // Balance returns the post-balance in effect immediately before the given block // access index, or nil if the account's balance was not changed before idx. // The returned pointer aliases the access list and must not be mutated. -func (p *PreparedAccessList) Balance(addr common.Address, idx int) *uint256.Int { +func (p *AccessListReader) Balance(addr common.Address, idx int) *uint256.Int { a := p.accounts[addr] if a == nil { return nil } - k := lastBefore(len(a.balances), uint32(idx), func(i int) uint32 { return a.balances[i].BlockAccessIndex }) + k := lastBefore(len(a.BalanceChanges), uint32(idx), func(i int) uint32 { return a.BalanceChanges[i].BlockAccessIndex }) if k < 0 { return nil } - return a.balances[k].PostBalance + return a.BalanceChanges[k].PostBalance } // Nonce returns the post-nonce in effect immediately before the given block // access index. The boolean is false if the nonce was not changed before idx. -func (p *PreparedAccessList) Nonce(addr common.Address, idx int) (uint64, bool) { +func (p *AccessListReader) Nonce(addr common.Address, idx int) (uint64, bool) { a := p.accounts[addr] if a == nil { return 0, false } - k := lastBefore(len(a.nonces), uint32(idx), func(i int) uint32 { return a.nonces[i].BlockAccessIndex }) + k := lastBefore(len(a.NonceChanges), uint32(idx), func(i int) uint32 { return a.NonceChanges[i].BlockAccessIndex }) if k < 0 { return 0, false } - return a.nonces[k].PostNonce, true + return a.NonceChanges[k].PostNonce, true } // Code returns the contract code in effect immediately before the given block // access index, or nil if the code was not changed before idx. The returned // slice aliases the access list and must not be mutated. -func (p *PreparedAccessList) Code(addr common.Address, idx int) []byte { +func (p *AccessListReader) Code(addr common.Address, idx int) []byte { a := p.accounts[addr] if a == nil { return nil } - k := lastBefore(len(a.codes), uint32(idx), func(i int) uint32 { return a.codes[i].BlockAccessIndex }) + k := lastBefore(len(a.CodeChanges), uint32(idx), func(i int) uint32 { return a.CodeChanges[i].BlockAccessIndex }) if k < 0 { return nil } - return a.codes[k].NewCode + return a.CodeChanges[k].NewCode } // StorageAt returns the post-value of a storage slot immediately before the // given block access index. The boolean is false if the slot was not written // before idx. -func (p *PreparedAccessList) StorageAt(addr common.Address, slot common.Hash, idx int) (common.Hash, bool) { +func (p *AccessListReader) StorageAt(addr common.Address, slot common.Hash, idx int) (common.Hash, bool) { a := p.accounts[addr] if a == nil { return common.Hash{}, false } - s := a.storage[slot] - if s == nil { + s, ok := a.storage[slot] + if !ok { return common.Hash{}, false } k := lastBefore(len(s.changes), uint32(idx), func(i int) uint32 { return s.changes[i].BlockAccessIndex }) @@ -145,7 +117,7 @@ func (p *PreparedAccessList) StorageAt(addr common.Address, slot common.Hash, id // AccountMutations returns the aggregate mutation for an account up until (and // not including) the given block access list index, or nil if the account was // not mutated before idx. -func (p *PreparedAccessList) AccountMutations(addr common.Address, idx int) *AccountMutations { +func (p *AccessListReader) AccountMutations(addr common.Address, idx int) *AccountMutations { a := p.accounts[addr] if a == nil { return nil @@ -181,17 +153,16 @@ type StorageKeys map[common.Address][]common.Hash // StorageKeys returns the set of accounts and storage keys mutated in the access // list. If reads is set, the un-mutated accounts/keys are included in the result. -func (p *PreparedAccessList) StorageKeys(reads bool) (keys StorageKeys) { +func (p *AccessListReader) StorageKeys(reads bool) (keys StorageKeys) { keys = make(StorageKeys) for addr, a := range p.accounts { - acct := a.access - for _, storageChange := range acct.StorageChanges { + for _, storageChange := range a.StorageChanges { keys[addr] = append(keys[addr], storageChange.Slot.Bytes32()) } - if !(reads && len(acct.StorageReads) > 0) { + if !(reads && len(a.StorageReads) > 0) { continue } - for _, storageRead := range acct.StorageReads { + for _, storageRead := range a.StorageReads { keys[addr] = append(keys[addr], storageRead.Bytes32()) } } @@ -199,7 +170,7 @@ func (p *PreparedAccessList) StorageKeys(reads bool) (keys StorageKeys) { } // Mutations returns the aggregate state mutations from bal indices [0, idx). -func (p *PreparedAccessList) Mutations(idx int) *StateMutations { +func (p *AccessListReader) Mutations(idx int) *StateMutations { res := make(StateMutations) for addr := range p.accounts { if mut := p.AccountMutations(addr, idx); mut != nil { @@ -212,9 +183,9 @@ func (p *PreparedAccessList) Mutations(idx int) *StateMutations { // AllDestructions returns all accounts that experienced a destruction, regardless // of whether they were later resurrected and exist after the block. It excludes // ephemeral contracts from the result. -func (p *PreparedAccessList) AllDestructions() (res []common.Address) { +func (p *AccessListReader) AllDestructions() (res []common.Address) { for addr, a := range p.accounts { - for _, nonce := range a.access.NonceChanges { + for _, nonce := range a.NonceChanges { if nonce.PostNonce == 0 { res = append(res, addr) break From f773cfed30ae20be0d752618f10e94875eeda417 Mon Sep 17 00:00:00 2001 From: jwasinger Date: Wed, 10 Jun 2026 14:52:08 -0400 Subject: [PATCH 15/17] core: clean up parallel state processor (#35143) --- core/blockchain.go | 2 +- core/parallel_state_processor.go | 228 +++++++++++++++---------------- core/state/reader_eip_7928.go | 8 +- 3 files changed, 113 insertions(+), 125 deletions(-) diff --git a/core/blockchain.go b/core/blockchain.go index 4ecb986978b8..b2c6ee9d9949 100644 --- a/core/blockchain.go +++ b/core/blockchain.go @@ -433,7 +433,7 @@ func NewBlockChain(db ethdb.Database, genesis *Genesis, engine consensus.Engine, bc.validator = NewBlockValidator(chainConfig, bc) bc.prefetcher = newStatePrefetcher(chainConfig, bc.hc) bc.processor = NewStateProcessor(bc.hc) - bc.parallelProcessor = NewParallelStateProcessor(bc.hc, bc.GetVMConfig()) + bc.parallelProcessor = *NewParallelStateProcessor(bc.hc, bc.GetVMConfig()) genesisHeader := bc.GetHeaderByNumber(0) if genesisHeader == nil { diff --git a/core/parallel_state_processor.go b/core/parallel_state_processor.go index be2af36dbf41..4c4d09b747de 100644 --- a/core/parallel_state_processor.go +++ b/core/parallel_state_processor.go @@ -24,6 +24,11 @@ type ProcessResultWithMetrics struct { PostProcessTime time.Duration } +// errResult wraps an error into a new ProcessResultWithMetrics instance +func errResult(err error) *ProcessResultWithMetrics { + return &ProcessResultWithMetrics{ProcessResult: &ProcessResult{Error: err}} +} + // ParallelStateProcessor is used to execute and verify blocks containing // access lists. type ParallelStateProcessor struct { @@ -32,11 +37,23 @@ type ParallelStateProcessor struct { } // NewParallelStateProcessor returns a new ParallelStateProcessor instance. -func NewParallelStateProcessor(chain *HeaderChain, vmConfig *vm.Config) ParallelStateProcessor { - res := NewStateProcessor(chain) - return ParallelStateProcessor{ - res, - vmConfig, +func NewParallelStateProcessor(chain *HeaderChain, vmConfig *vm.Config) *ParallelStateProcessor { + return &ParallelStateProcessor{ + StateProcessor: NewStateProcessor(chain), + vmCfg: vmConfig, + } +} + +// execVMConfig returns the subset of the configured VM options that is safe to +// reuse across the parallel per-transaction and post-transaction executions. +// Only the fields explicitly copied here are propagated (mirroring the original +// per-tx behaviour); notably the full caller-supplied config is used only for +// pre-execution in processBlockPreTx. +func (p *ParallelStateProcessor) execVMConfig() vm.Config { + return vm.Config{ + NoBaseFee: p.vmCfg.NoBaseFee, + EnablePreimageRecording: p.vmCfg.EnablePreimageRecording, + ExtraEips: slices.Clone(p.vmCfg.ExtraEips), } } @@ -44,21 +61,17 @@ func NewParallelStateProcessor(chain *HeaderChain, vmConfig *vm.Config) Parallel // performs post-tx state transition (system contracts and withdrawals) // and calculates the ProcessResult, returning it to be sent on resCh // by resultHandler -func (p *ParallelStateProcessor) prepareExecResult(block *types.Block, tExecStart time.Time, preTxBal *bal.ConstructionBlockAccessList, prepared *bal.AccessListReader, statedb *state.StateDB, results []txExecResult) *ProcessResultWithMetrics { +func (p *ParallelStateProcessor) prepareExecResult(block *types.Block, tExecStart time.Time, preTxBAL *bal.ConstructionBlockAccessList, accessList *bal.AccessListReader, statedb *state.StateDB, results []txExecResult) *ProcessResultWithMetrics { tExec := time.Since(tExecStart) tPostprocessStart := time.Now() header := block.Header() - vmContext := NewEVMBlockContext(header, p.chain, nil) + // The post-execution changes are recorded at the BAL index immediately + // following the last transaction. lastBALIdx := len(block.Transactions()) + 1 - postTxState := statedb.WithReader(state.NewReaderWithPreparedAccessList(statedb.Reader(), prepared, lastBALIdx)) + postTxState := statedb.WithReader(state.NewReaderWithAccessList(statedb.Reader(), accessList, lastBALIdx)) - cfg := vm.Config{ - NoBaseFee: p.vmCfg.NoBaseFee, - EnablePreimageRecording: p.vmCfg.EnablePreimageRecording, - ExtraEips: slices.Clone(p.vmCfg.ExtraEips), - } - evm := vm.NewEVM(vmContext, postTxState, p.chainConfig(), cfg) + evm := vm.NewEVM(NewEVMBlockContext(header, p.chain, nil), postTxState, p.chainConfig(), p.execVMConfig()) // 1. order the receipts by tx index // 2. correctly calculate the cumulative gas used per receipt, returning bad block error if it goes over the allowed @@ -71,10 +84,10 @@ func (p *ParallelStateProcessor) prepareExecResult(block *types.Block, tExecStar sumRegular uint64 sumState uint64 cumulativeReceipt uint64 // cumulative receipt gas (what users pay) - ) - var allLogs []*types.Log - var allReceipts []*types.Receipt + allLogs []*types.Log + allReceipts []*types.Receipt + ) for _, result := range results { sumRegular += result.txRegular sumState += result.txState @@ -87,24 +100,19 @@ func (p *ParallelStateProcessor) prepareExecResult(block *types.Block, tExecStar // Block gas = max(sum_regular, sum_state) per EIP-8037. blockGasUsed := max(sumRegular, sumState) if blockGasUsed > header.GasLimit { - return &ProcessResultWithMetrics{ - ProcessResult: &ProcessResult{Error: fmt.Errorf("gas limit exceeded")}, - } + return errResult(fmt.Errorf("gas limit exceeded")) } - requests, postBal, err := PostExecution(context.Background(), p.chainConfig(), block.Number(), block.Time(), allLogs, evm, uint32(len(block.Transactions())+1)) + requests, postBAL, err := PostExecution(context.Background(), p.chainConfig(), block.Number(), block.Time(), allLogs, evm, uint32(lastBALIdx)) if err != nil { - return &ProcessResultWithMetrics{ - ProcessResult: &ProcessResult{Error: err}, - } + return errResult(err) } - p.chain.Engine().Finalize(p.chain, block.Header(), evm.StateDB, block.Body(), uint32(len(block.Transactions()))+1, postBal) + p.chain.Engine().Finalize(p.chain, block.Header(), evm.StateDB, block.Body(), uint32(lastBALIdx), postBAL) blockAccessList := bal.NewConstructionBlockAccessList() - blockAccessList.Merge(preTxBal) - blockAccessList.Merge(postBal) - + blockAccessList.Merge(preTxBAL) + blockAccessList.Merge(postBAL) for _, res := range results { blockAccessList.Merge(res.blockAccessList) } @@ -112,9 +120,7 @@ func (p *ParallelStateProcessor) prepareExecResult(block *types.Block, tExecStar // TODO: do we move validation to ValidateState? if block.AccessList().Hash() != blockAccessList.ToEncodingObj().Hash() { // TODO: expose json string method on encoding block access list and log it here - return &ProcessResultWithMetrics{ - ProcessResult: &ProcessResult{Error: fmt.Errorf("invalid block access list: mismatch between local and remote block access list")}, - } + return errResult(fmt.Errorf("invalid block access list: mismatch between local and remote block access list")) } tPostprocess := time.Since(tPostprocessStart) @@ -133,11 +139,9 @@ func (p *ParallelStateProcessor) prepareExecResult(block *types.Block, tExecStar } type txExecResult struct { - idx int // transaction index - receipt *types.Receipt - err error // non-EVM error which would render the block invalid - blockGas uint64 - execGas uint64 + receipt *types.Receipt + err error // non-EVM error which would render the block invalid + execGas uint64 // gas reported on the receipt (what the user pays) // Per-tx dimensional gas for Amsterdam 2D gas accounting (EIP-8037). txRegular uint64 @@ -151,38 +155,40 @@ type txExecResult struct { func (p *ParallelStateProcessor) resultHandler(block *types.Block, preTxBAL *bal.ConstructionBlockAccessList, prepared *bal.AccessListReader, statedb *state.StateDB, tExecStart time.Time, txResCh <-chan txExecResult, stateRootCalcResCh <-chan stateRootCalculationResult, resCh chan *ProcessResultWithMetrics) { // 1. if the block has transactions, receive the execution results from all of them and return an error on resCh if any txs err'd // 2. once all txs are executed, compute the post-tx state transition and produce the ProcessResult sending it on resCh (or an error if the post-tx state didn't match what is reported in the BAL) - var results []txExecResult - var cumulativeStateGas, cumulativeRegularGas uint64 - var execErr error - var numTxComplete int - - if len(block.Transactions()) > 0 { - loop: - for { - select { - case res := <-txResCh: - numTxComplete++ - if execErr == nil { - // short-circuit if invalid block was detected - if res.err != nil { - execErr = res.err - } else if bottleneck := max(cumulativeRegularGas+res.txRegular, cumulativeStateGas+res.txState); bottleneck > block.GasLimit() { - execErr = fmt.Errorf("block used too much gas in bottleneck dimension: %d. block gas limit is %d", bottleneck, block.GasLimit()) - } else { - cumulativeStateGas += res.txState - results = append(results, res) - } - } - if numTxComplete == len(block.Transactions()) { - break loop + var ( + results []txExecResult + cumulativeStateGas uint64 + cumulativeRegularGas uint64 + execErr error + ) + + if numTx := len(block.Transactions()); numTx > 0 { + for completed := 0; completed < numTx; completed++ { + res := <-txResCh + if execErr != nil { + // A block-invalidating result was already seen; keep draining so + // the worker goroutines don't block on their sends. + continue + } + switch { + case res.err != nil: + execErr = res.err + default: + bottleneck := max(cumulativeRegularGas+res.txRegular, cumulativeStateGas+res.txState) + if bottleneck > block.GasLimit() { + execErr = fmt.Errorf("block used too much gas in bottleneck dimension: %d. block gas limit is %d", bottleneck, block.GasLimit()) + continue } + cumulativeRegularGas += res.txRegular + cumulativeStateGas += res.txState + results = append(results, res) } } if execErr != nil { - // Drain stateRootCalcResCh so calcAndVerifyRoot goroutine can exit. + // Drain stateRootCalcResCh so the calcAndVerifyRoot goroutine can exit. <-stateRootCalcResCh - resCh <- &ProcessResultWithMetrics{ProcessResult: &ProcessResult{Error: execErr}} + resCh <- errResult(execErr) return } } @@ -190,11 +196,12 @@ func (p *ParallelStateProcessor) resultHandler(block *types.Block, preTxBAL *bal execResults := p.prepareExecResult(block, tExecStart, preTxBAL, prepared, statedb, results) rootCalcRes := <-stateRootCalcResCh - if execResults.ProcessResult.Error != nil { + switch { + case execResults.ProcessResult.Error != nil: resCh <- execResults - } else if rootCalcRes.err != nil { - resCh <- &ProcessResultWithMetrics{ProcessResult: &ProcessResult{Error: rootCalcRes.err}} - } else { + case rootCalcRes.err != nil: + resCh <- errResult(rootCalcRes.err) + default: execResults.StateTransitionMetrics = rootCalcRes.metrics resCh <- execResults } @@ -213,107 +220,88 @@ func (p *ParallelStateProcessor) calcAndVerifyRoot(block *types.Block, stateTran res := stateRootCalculationResult{ metrics: stateTransition.Metrics(), } - if root != block.Root() { res.err = fmt.Errorf("state root mismatch. local: %x. remote: %x", root, block.Root()) } resCh <- res } -// execTx executes single transaction returning a result which includes state accessed/modified +// execTx executes a single transaction returning a result which includes state accessed/modified. func (p *ParallelStateProcessor) execTx(block *types.Block, tx *types.Transaction, balIdx int, db *state.StateDB, signer types.Signer) *txExecResult { header := block.Header() - context := NewEVMBlockContext(header, p.chain, nil) - - cfg := vm.Config{ - NoBaseFee: p.vmCfg.NoBaseFee, - EnablePreimageRecording: p.vmCfg.EnablePreimageRecording, - ExtraEips: slices.Clone(p.vmCfg.ExtraEips), - } - evm := vm.NewEVM(context, db, p.chainConfig(), cfg) + evmContext := NewEVMBlockContext(header, p.chain, nil) + evm := vm.NewEVM(evmContext, db, p.chainConfig(), p.execVMConfig()) msg, err := TransactionToMessage(tx, signer, header.BaseFee) if err != nil { - err = fmt.Errorf("could not apply tx %d [%v]: %w", balIdx, tx.Hash().Hex(), err) - return &txExecResult{err: err} + return &txExecResult{err: fmt.Errorf("could not apply tx %d [%v]: %w", balIdx, tx.Hash().Hex(), err)} } - gp := NewGasPool(block.GasLimit()) sender, err := signer.Sender(tx) if err != nil { - // TODO: can this even happen at this stage? - err = fmt.Errorf("could not recover sender for tx at bal idx %d: %v\n", balIdx, err) + return &txExecResult{err: fmt.Errorf("could not recover sender for tx at bal idx %d: %w", balIdx, err)} } + + gp := NewGasPool(block.GasLimit()) // TODO: make precompiled addresses be resolvable from chain config + block db.Prepare(evm.GetRules(), sender, block.Coinbase(), tx.To(), vm.PrecompiledAddressesCancun, tx.AccessList()) - db.SetTxContext(tx.Hash(), balIdx-1, uint32(balIdx)) - receipt, txBAL, err := ApplyTransactionWithEVM(msg, gp, db, block.Number(), block.Hash(), context.Time, tx, evm) + receipt, txBAL, err := ApplyTransactionWithEVM(msg, gp, db, block.Number(), block.Hash(), evmContext.Time, tx, evm) if err != nil { - err := fmt.Errorf("could not apply tx %d [%v]: %w", balIdx, tx.Hash().Hex(), err) - return &txExecResult{err: err} + return &txExecResult{err: fmt.Errorf("could not apply tx %d [%v]: %w", balIdx, tx.Hash().Hex(), err)} } return &txExecResult{ - idx: balIdx, receipt: receipt, execGas: receipt.GasUsed, - blockGas: gp.Used(), txRegular: gp.cumulativeRegular, txState: gp.cumulativeState, blockAccessList: txBAL, } } -func (p *ParallelStateProcessor) processBlockPreTx(block *types.Block, statedb *state.StateDB, cfg vm.Config) (*bal.ConstructionBlockAccessList, error) { - var ( - header = block.Header() - ) - vmContext := NewEVMBlockContext(header, p.chain, nil) - evm := vm.NewEVM(vmContext, statedb, p.chainConfig(), cfg) - - accessList := PreExecution(context.Background(), block.BeaconRoot(), block.ParentHash(), p.chainConfig(), evm, block.Number(), block.Time()) - return accessList, nil +func (p *ParallelStateProcessor) processBlockPreTx(block *types.Block, statedb *state.StateDB, cfg vm.Config) *bal.ConstructionBlockAccessList { + header := block.Header() + evm := vm.NewEVM(NewEVMBlockContext(header, p.chain, nil), statedb, p.chainConfig(), cfg) + return PreExecution(context.Background(), block.BeaconRoot(), block.ParentHash(), p.chainConfig(), evm, block.Number(), block.Time()) } // Process performs EVM execution and state root computation for a block which is known // to contain an access list. func (p *ParallelStateProcessor) Process(block *types.Block, stateTransition *state.BALStateTransition, statedb *state.StateDB, cfg vm.Config) (*ProcessResultWithMetrics, error) { + header := block.Header() + signer := types.MakeSigner(p.chainConfig(), header.Number, header.Time) + var ( - header = block.Header() resCh = make(chan *ProcessResultWithMetrics) - signer = types.MakeSigner(p.chainConfig(), header.Number, header.Time) rootCalcResultCh = make(chan stateRootCalculationResult) txResCh = make(chan txExecResult) - - pStart = time.Now() - tExecStart time.Time - tPreprocess time.Duration // time to create a set of prestates for parallel transaction execution ) + // Pre-transaction processing: system-contract updates and the pre-tx BAL. + pStart := time.Now() startingState := statedb.Copy() prepared := stateTransition.PreparedAccessList() - preTxBal, err := p.processBlockPreTx(block, statedb, cfg) - if err != nil { - return nil, err - } - - // compute the reads/mutations at the last bal index - tPreprocess = time.Since(pStart) - - // execute transactions and state root calculation in parallel - tExecStart = time.Now() - go p.resultHandler(block, preTxBal, prepared, statedb, tExecStart, txResCh, rootCalcResultCh, resCh) + preTxBAL := p.processBlockPreTx(block, statedb, cfg) + tPreprocess := time.Since(pStart) + + // Execute transactions and the state-root calculation in parallel. + tExecStart := time.Now() + go p.resultHandler(block, preTxBAL, prepared, statedb, tExecStart, txResCh, rootCalcResultCh, resCh) + + // Workers execute transactions concurrently against per-tx state copies. + // Each worker reports completion (and any block-invalidating error) on + // txResCh, which resultHandler drains. Worker errors therefore flow through + // the channel rather than the errgroup, so the group is used purely to bound + // concurrency and Wait() is intentionally not called. var workers errgroup.Group workers.SetLimit(runtime.NumCPU()) - for i, t := range block.Transactions() { - tx := t - idx := i - sdb := startingState.Copy() + for i, tx := range block.Transactions() { + balIdx := i + 1 + prestate := startingState.Copy() workers.Go(func() error { - startingState := sdb.WithReader(state.NewReaderWithPreparedAccessList(statedb.Reader(), prepared, idx+1)) - res := p.execTx(block, tx, idx+1, startingState, signer) - txResCh <- *res + prestate = prestate.WithReader(state.NewReaderWithAccessList(statedb.Reader(), prepared, balIdx)) + txResCh <- *p.execTx(block, tx, balIdx, prestate, signer) return nil }) } diff --git a/core/state/reader_eip_7928.go b/core/state/reader_eip_7928.go index b89636870d6b..72d9672b19b4 100644 --- a/core/state/reader_eip_7928.go +++ b/core/state/reader_eip_7928.go @@ -220,10 +220,10 @@ type ReaderWithBlockLevelAccessList struct { TxIndex int } -// NewReaderWithPreparedAccessList wraps a base reader with a shared, already +// NewReaderWithAccessList wraps a base reader with a shared, already // preprocessed access list. This is the cheap constructor used on the hot path: // the prepared list is built once per block and borrowed by every per-tx reader. -func NewReaderWithPreparedAccessList(base Reader, prepared *bal.AccessListReader, txIndex int) *ReaderWithBlockLevelAccessList { +func NewReaderWithAccessList(base Reader, prepared *bal.AccessListReader, txIndex int) *ReaderWithBlockLevelAccessList { return &ReaderWithBlockLevelAccessList{ Reader: base, prepared: prepared, @@ -232,10 +232,10 @@ func NewReaderWithPreparedAccessList(base Reader, prepared *bal.AccessListReader } // NewReaderWithBlockLevelAccessList wraps a base reader with a raw access list, -// preprocessing it on the spot. Prefer NewReaderWithPreparedAccessList when the +// preprocessing it on the spot. Prefer NewReaderWithAccessList when the // prepared list can be built once and shared across multiple readers. func NewReaderWithBlockLevelAccessList(base Reader, accessList bal.BlockAccessList, txIndex int) *ReaderWithBlockLevelAccessList { - return NewReaderWithPreparedAccessList(base, bal.NewAccessListReader(accessList), txIndex) + return NewReaderWithAccessList(base, bal.NewAccessListReader(accessList), txIndex) } // Account implements Reader, returning the account with the specific address. From 3e6e4f17a08e67ebd11c16e838aca54b0b627e81 Mon Sep 17 00:00:00 2001 From: Guillaume Ballet <3272758+gballet@users.noreply.github.com> Date: Tue, 20 Jan 2026 15:11:43 +0100 Subject: [PATCH 16/17] trie, cmd/geth: add archiver command and on-demand resurrection Co-authored-by: Guillaume Ballet <3272758+gballet@users.noreply.github.com> Co-authored-by: tellabg <249254436+tellabg@users.noreply.github.com> --- cmd/geth/archivecmd.go | 576 ++++++++++++++++++++++++++ cmd/geth/config.go | 3 +- cmd/geth/main.go | 2 + core/block_validator.go | 1 + core/blockchain.go | 3 +- node/node.go | 2 + trie/archive/archive.go | 95 +++++ trie/archive/writer.go | 92 +++++ trie/archiver.go | 599 +++++++++++++++++++++++++++ trie/committer.go | 2 + trie/expired_node.go | 262 ++++++++++++ trie/expired_node_test.go | 601 ++++++++++++++++++++++++++++ trie/hasher.go | 23 ++ trie/node.go | 12 + trie/proof.go | 13 + trie/trie.go | 131 +++++- triedb/database.go | 22 + triedb/pathdb/database.go | 24 ++ triedb/pathdb/history.go | 10 +- triedb/pathdb/history_state_test.go | 4 +- triedb/pathdb/layertree.go | 14 + triedb/pathdb/reader.go | 2 +- 22 files changed, 2478 insertions(+), 15 deletions(-) create mode 100644 cmd/geth/archivecmd.go create mode 100644 trie/archive/archive.go create mode 100644 trie/archive/writer.go create mode 100644 trie/archiver.go create mode 100644 trie/expired_node.go create mode 100644 trie/expired_node_test.go diff --git a/cmd/geth/archivecmd.go b/cmd/geth/archivecmd.go new file mode 100644 index 000000000000..d6e241974e2c --- /dev/null +++ b/cmd/geth/archivecmd.go @@ -0,0 +1,576 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of go-ethereum. +// +// go-ethereum is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// go-ethereum 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 General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with go-ethereum. If not, see . + +package main + +import ( + "encoding/binary" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "time" + + "github.com/ethereum/go-ethereum/cmd/utils" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/rawdb" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethdb" + "github.com/ethereum/go-ethereum/log" + "github.com/ethereum/go-ethereum/rlp" + "github.com/ethereum/go-ethereum/trie" + "github.com/ethereum/go-ethereum/trie/archive" + "github.com/ethereum/go-ethereum/triedb/database" + "github.com/urfave/cli/v2" +) + +var ( + // Flags for the archive command + archiveOutputFlag = &cli.StringFlag{ + Name: "output", + Usage: "Path to archive output file", + Value: "", // Default: /nodearchive + } + archiveCompactionIntervalFlag = &cli.Uint64Flag{ + Name: "compaction-interval", + Usage: "Run compaction after this many subtrees (0 = disable)", + Value: 1000, + } + archiveDryRunFlag = &cli.BoolFlag{ + Name: "dry-run", + Usage: "Simulate without modifying database", + } + + // Commands + archiveCheckNodeFlag = &cli.StringFlag{ + Name: "owner", + Usage: "Owner hash (hex) for the trie node to check", + } + archiveCheckPathFlag = &cli.StringFlag{ + Name: "path", + Usage: "Path (hex nibbles) of the trie node to check", + } + + archiveCommand = &cli.Command{ + Name: "archive", + Usage: "Archive state trie nodes to reduce database size", + Subcommands: []*cli.Command{ + archiveGenerateCmd, + archiveVerifyCmd, + archiveDeleteJournalCmd, + archiveCheckNodeCmd, + }, + } + + archiveCheckNodeCmd = &cli.Command{ + Name: "check-node", + Usage: "Check if a specific trie node exists in the raw DB", + Action: archiveCheckNode, + Flags: slices.Concat([]cli.Flag{ + archiveCheckNodeFlag, + archiveCheckPathFlag, + }, utils.NetworkFlags, utils.DatabaseFlags), + } + + archiveDeleteJournalCmd = &cli.Command{ + Name: "delete-journal", + Usage: "Delete the pathdb journal to force a clean restart", + Action: archiveDeleteJournal, + Flags: slices.Concat(utils.NetworkFlags, utils.DatabaseFlags), + Description: ` +Deletes the pathdb journal (TrieJournal key and merkle.journal file) from the +database. This forces geth to restart with a bare disk layer, discarding any +in-memory diff layers that may be inconsistent with archived state. + +Use this after running 'archive generate' if geth was started in between and +recreated the journal. + +Examples: + geth archive delete-journal --datadir /path/to/datadir + geth archive delete-journal --hoodi +`, + } + + archiveVerifyCmd = &cli.Command{ + Name: "verify", + Usage: "Verify all archived nodes can be correctly resurrected", + Action: archiveVerify, + Flags: slices.Concat(utils.NetworkFlags, utils.DatabaseFlags), + Description: ` +Walks the entire state trie, resolving every expired node from the archive +file and verifying that the reconstructed subtree hash matches the original. +Also walks all storage tries referenced by accounts. + +The database is opened read-only. No modifications are made. + +Examples: + geth archive verify --datadir /path/to/datadir + geth archive verify --hoodi +`, + } + + archiveGenerateCmd = &cli.Command{ + Name: "generate", + Usage: "Generate archive files from height-3 subtrees", + ArgsUsage: "[state-root]", + Action: archiveGenerate, + Flags: slices.Concat([]cli.Flag{ + archiveOutputFlag, + archiveCompactionIntervalFlag, + archiveDryRunFlag, + }, utils.NetworkFlags, utils.DatabaseFlags), + Description: ` +Walks the state trie of the specified root (or head block) and archives +subtrees at height 3. Each archived subtree is replaced with an expiredNode +that references the archive file offset and size. + +Height is measured from leaves: leaves=0, parents=1, etc. A height-3 node +has leaves at most 3 levels below it. + +The archiver reads trie nodes directly from the persistent database layer, +bypassing any in-memory diff layers. This ensures consistency between the +data it reads and the data it modifies. + +Examples: + # Archive from the persistent disk state + geth archive generate --datadir /path/to/datadir + + # Dry run to see what would be archived + geth archive generate --dry-run --datadir /path/to/datadir + + # Custom output and compaction interval + geth archive generate --output /path/to/archive --compaction-interval 500 +`, + } +) + +// rawDBNodeReader implements database.NodeReader by reading trie nodes directly +// from the raw key-value database, bypassing pathdb's in-memory diff layers. +// This ensures the archiver sees the same trie state it modifies. +type rawDBNodeReader struct { + db ethdb.KeyValueReader +} + +func (r *rawDBNodeReader) Node(owner common.Hash, path []byte, hash common.Hash) ([]byte, error) { + var blob []byte + if owner == (common.Hash{}) { + blob = rawdb.ReadAccountTrieNode(r.db, path) + } else { + blob = rawdb.ReadStorageTrieNode(r.db, owner, path) + } + // Skip hash verification: the raw DB may contain expiredNode markers + // (blob[0] == 0x00) which have different hashes than the original nodes. + return blob, nil +} + +// rawDBNodeDatabase implements database.NodeDatabase using direct raw DB reads. +type rawDBNodeDatabase struct { + db ethdb.KeyValueReader + root common.Hash +} + +func (d *rawDBNodeDatabase) NodeReader(stateRoot common.Hash) (database.NodeReader, error) { + // Only allow reading the persistent disk root state + if stateRoot != d.root { + return nil, fmt.Errorf("raw DB reader only supports disk root %x, got %x", d.root, stateRoot) + } + return &rawDBNodeReader{db: d.db}, nil +} + +func archiveGenerate(ctx *cli.Context) error { + // 1. Setup node and databases + stack, _ := makeConfigNode(ctx) + defer stack.Close() + + dryRun := ctx.Bool(archiveDryRunFlag.Name) + chaindb := utils.MakeChainDatabase(ctx, stack, dryRun) + defer chaindb.Close() + + // Check state scheme - we only support PathDB + scheme := cycleCheckScheme(ctx, chaindb) + if scheme != rawdb.PathScheme { + return fmt.Errorf("archive generation requires path-based state scheme, got: %s", scheme) + } + + // 2. Flush diff layers to disk via pathdb. This ensures the raw DB + // contains the complete, up-to-date state trie and that state history + // entries are properly written to the freezer. + trieDB := utils.MakeTrieDatabase(ctx, stack, chaindb, false, dryRun, false) + head, hasDiff := trieDB.DiffHead() + if hasDiff { + log.Info("Flushing diff layers to disk", "head", head) + if err := trieDB.Commit(head, true); err != nil { + trieDB.Close() + return fmt.Errorf("failed to flush diff layers: %w", err) + } + log.Info("Diff layers flushed successfully") + } else { + log.Info("No diff layers to flush, disk state is current", "root", head) + } + // Close triedb — we work directly with raw DB for archival. + // We'll re-open it at the end to write a fresh journal. + trieDB.Close() + + // 3. Determine the disk state root (now up-to-date after flush). + rootBlob := rawdb.ReadAccountTrieNode(chaindb, nil) + if len(rootBlob) == 0 { + return errors.New("state trie not found in database") + } + root := crypto.Keccak256Hash(rootBlob) + log.Info("Using disk state root", "root", root) + + // Create a raw DB node reader that bypasses pathdb layers + nodeDB := &rawDBNodeDatabase{db: chaindb, root: root} + + // 4. Open archive writer (unless dry-run). + // The archive file is placed at /geth/nodearchive by default, + // matching the path used by ArchivedNodeResolver when reading back. + var writer *archive.ArchiveWriter + archivePath := ctx.String(archiveOutputFlag.Name) + if archivePath == "" { + archivePath = filepath.Join(stack.ResolvePath(""), "nodearchive") + } + + if !dryRun { + var err error + writer, err = archive.NewArchiveWriter(archivePath) + if err != nil { + return fmt.Errorf("failed to open archive file %s: %w", archivePath, err) + } + defer writer.Close() + log.Info("Opened archive file", "path", archivePath) + } else { + log.Info("Dry run mode - no changes will be made") + } + + // 5. Create and run archiver + archiver := trie.NewArchiver( + chaindb, + nodeDB, + writer, + ctx.Uint64(archiveCompactionIntervalFlag.Name), + dryRun, + ) + + start := time.Now() + if err := archiver.ProcessState(root); err != nil { + return fmt.Errorf("archive generation failed: %w", err) + } + + // 6. Get stats and optionally run final compaction + subtrees, leaves, bytesDeleted := archiver.Stats() + + if !dryRun && subtrees > 0 { + log.Info("Running final database compaction") + if err := chaindb.Compact(nil, nil); err != nil { + log.Warn("Final compaction failed", "err", err) + } + } + + // 7. Re-journal the pathdb state with the current disk root. + // After archiving, some trie nodes have been replaced with expired + // markers. We re-open pathdb and write a fresh journal (disk layer + // only, since all diff layers were flushed in step 2) so that geth + // can restart cleanly. + if !dryRun { + log.Info("Re-journaling pathdb state") + freshTrieDB := utils.MakeTrieDatabase(ctx, stack, chaindb, false, false, false) + freshRoot := crypto.Keccak256Hash(rawdb.ReadAccountTrieNode(chaindb, nil)) + if err := freshTrieDB.Journal(freshRoot); err != nil { + log.Warn("Failed to re-journal pathdb state", "err", err) + } + freshTrieDB.Close() + } + + // 8. Print summary + var archiveSize uint64 + if writer != nil { + archiveSize = writer.Offset() + } + + log.Info("Archive generation complete", + "subtrees", subtrees, + "leaves", leaves, + "bytesDeleted", bytesDeleted, + "archiveSize", archiveSize, + "elapsed", common.PrettyDuration(time.Since(start))) + + if dryRun { + log.Info("This was a dry run - no changes were made to the database") + } + + return nil +} + +func archiveVerify(ctx *cli.Context) error { + stack, _ := makeConfigNode(ctx) + defer stack.Close() + + // Open database read-only + chaindb := utils.MakeChainDatabase(ctx, stack, true) + defer chaindb.Close() + + scheme := cycleCheckScheme(ctx, chaindb) + if scheme != rawdb.PathScheme { + return fmt.Errorf("archive verify requires path-based state scheme, got: %s", scheme) + } + + // Set archive data dir so ArchivedNodeResolver can find the file + // ResolvePath("") returns the node's data directory (e.g. .ethereum/hoodi/geth), + // but ArchivedNodeResolver expects the instance directory (.ethereum/hoodi) + // since it appends "geth/nodearchive" itself. + archive.ArchiveDataDir = filepath.Dir(stack.ResolvePath("")) + + // Compute disk root + rootBlob := rawdb.ReadAccountTrieNode(chaindb, nil) + if len(rootBlob) == 0 { + return errors.New("state trie not found in database") + } + root := crypto.Keccak256Hash(rootBlob) + log.Info("Verifying archived nodes", "root", root) + + nodeDB := &rawDBNodeDatabase{db: chaindb, root: root} + + // Open account trie + accountTrie, err := trie.New(trie.StateTrieID(root), nodeDB) + if err != nil { + return fmt.Errorf("failed to open account trie: %w", err) + } + + var ( + totalAccounts int + totalStorageTries int + totalLeaves int + totalExpired int + totalErrors int + start = time.Now() + lastLog = time.Now() + ) + + // Walk the account trie — this resolves all expired nodes and verifies hashes + accountStats, err := accountTrie.Walk(func(path []byte, value []byte) error { + totalAccounts++ + if time.Since(lastLog) > 30*time.Second { + log.Info("Verification progress", + "accounts", totalAccounts, + "storageTries", totalStorageTries, + "leaves", totalLeaves, + "expired", totalExpired, + "errors", totalErrors) + lastLog = time.Now() + } + + // Decode account to check for storage trie + var acc types.StateAccount + if err := rlp.DecodeBytes(value, &acc); err != nil { + log.Warn("Failed to decode account", "err", err) + totalErrors++ + return nil // continue walking + } + if acc.Root == types.EmptyRootHash { + return nil + } + + // Open and walk storage trie. + // path is hex-nibble encoded (with a 16 terminator from the trie key), + // so convert nibble pairs back to the 32-byte account hash. + nibbles := path + if len(nibbles) > 0 && nibbles[len(nibbles)-1] == 16 { + nibbles = nibbles[:len(nibbles)-1] + } + keyBytes := make([]byte, len(nibbles)/2) + for i := 0; i < len(nibbles); i += 2 { + keyBytes[i/2] = nibbles[i]<<4 | nibbles[i+1] + } + accountHash := common.BytesToHash(keyBytes) + storageID := trie.StorageTrieID(root, accountHash, acc.Root) + storageTrie, err := trie.New(storageID, nodeDB) + if err != nil { + log.Warn("Failed to open storage trie", "account", accountHash, "err", err) + totalErrors++ + return nil + } + + storageStats, err := storageTrie.Walk(func(spath []byte, svalue []byte) error { + return nil + }) + if err != nil { + log.Warn("Storage trie walk failed", "account", accountHash, "err", err) + totalErrors++ + return nil + } + totalStorageTries++ + totalLeaves += storageStats.Leaves + totalExpired += storageStats.ExpiredResolved + return nil + }) + if err != nil { + return fmt.Errorf("account trie walk failed: %w", err) + } + + totalLeaves += accountStats.Leaves + totalExpired += accountStats.ExpiredResolved + + log.Info("Archive verification complete", + "accounts", totalAccounts, + "storageTries", totalStorageTries, + "totalLeaves", totalLeaves, + "expiredResolved", totalExpired, + "errors", totalErrors, + "elapsed", common.PrettyDuration(time.Since(start))) + + if totalErrors > 0 { + return fmt.Errorf("verification completed with %d errors", totalErrors) + } + return nil +} + +func archiveDeleteJournal(ctx *cli.Context) error { + stack, _ := makeConfigNode(ctx) + defer stack.Close() + + chaindb := utils.MakeChainDatabase(ctx, stack, false) + defer chaindb.Close() + + // Delete the pathdb journal KV key + if err := chaindb.Delete([]byte("TrieJournal")); err != nil { + log.Warn("Failed to delete pathdb journal key", "err", err) + } else { + log.Info("Deleted pathdb journal key (TrieJournal)") + } + + // Delete the journal file(s) - check both legacy and current locations + for _, dir := range []string{"triedb", ""} { + for _, name := range []string{"merkle.journal", "verkle.journal"} { + journalFile := filepath.Join(stack.ResolvePath(dir), name) + if err := os.Remove(journalFile); err == nil { + log.Info("Deleted journal file", "path", journalFile) + } else if !os.IsNotExist(err) { + log.Warn("Failed to delete journal file", "path", journalFile, "err", err) + } + } + } + + return nil +} + +func archiveCheckNode(ctx *cli.Context) error { + stack, _ := makeConfigNode(ctx) + defer stack.Close() + + chaindb := utils.MakeChainDatabase(ctx, stack, true) + defer chaindb.Close() + + ownerHex := ctx.String(archiveCheckNodeFlag.Name) + pathHex := ctx.String(archiveCheckPathFlag.Name) + + if ownerHex == "" { + return errors.New("--owner flag is required") + } + + owner := common.HexToHash(ownerHex) + + // Parse path: hex nibbles like "08" → []byte{0, 8} + var path []byte + for _, c := range pathHex { + var nibble byte + switch { + case c >= '0' && c <= '9': + nibble = byte(c - '0') + case c >= 'a' && c <= 'f': + nibble = byte(c-'a') + 10 + case c >= 'A' && c <= 'F': + nibble = byte(c-'A') + 10 + default: + return fmt.Errorf("invalid hex char in path: %c", c) + } + path = append(path, nibble) + } + + log.Info("Checking node in raw DB", "owner", owner, "path", fmt.Sprintf("%x", path)) + + // Read the node directly from the raw DB + isAccount := owner == (common.Hash{}) + + // Check the target path and all prefixes up to root + for i := len(path); i >= 0; i-- { + subpath := path[:i] + var blob []byte + if isAccount { + blob = rawdb.ReadAccountTrieNode(chaindb, subpath) + } else { + blob = rawdb.ReadStorageTrieNode(chaindb, owner, subpath) + } + + status := "MISSING" + details := "" + if len(blob) > 0 { + if blob[0] == 0x00 { + status = "EXPIRED" + if len(blob) == 17 { + offset := binary.BigEndian.Uint64(blob[1:9]) + size := binary.BigEndian.Uint64(blob[9:17]) + details = fmt.Sprintf("offset=%d size=%d", offset, size) + } + } else { + status = fmt.Sprintf("PRESENT (%d bytes, first=0x%02x)", len(blob), blob[0]) + } + } + label := "prefix" + if i == len(path) { + label = "TARGET" + } + if i == 0 { + label = "ROOT" + } + log.Info("Node check", + "label", label, + "path", fmt.Sprintf("%x", subpath), + "pathLen", i, + "status", status, + "details", details) + } + + // Also check a few child paths to see what's below the target + for nibble := byte(0); nibble < 16; nibble++ { + childPath := append(append([]byte{}, path...), nibble) + var blob []byte + if isAccount { + blob = rawdb.ReadAccountTrieNode(chaindb, childPath) + } else { + blob = rawdb.ReadStorageTrieNode(chaindb, owner, childPath) + } + if len(blob) > 0 { + status := fmt.Sprintf("PRESENT (%d bytes, first=0x%02x)", len(blob), blob[0]) + if blob[0] == 0x00 && len(blob) == 17 { + offset := binary.BigEndian.Uint64(blob[1:9]) + size := binary.BigEndian.Uint64(blob[9:17]) + status = fmt.Sprintf("EXPIRED offset=%d size=%d", offset, size) + } + log.Info("Child node", "path", fmt.Sprintf("%x", childPath), "status", status) + } + } + + return nil +} + +// cycleCheckScheme returns the state scheme for the database. +// It's a helper to check what scheme is in use. +func cycleCheckScheme(ctx *cli.Context, db ethdb.Database) string { + return rawdb.ReadStateScheme(db) +} diff --git a/cmd/geth/config.go b/cmd/geth/config.go index a6f42ee32779..e6ef7802d98f 100644 --- a/cmd/geth/config.go +++ b/cmd/geth/config.go @@ -20,7 +20,6 @@ import ( "bufio" "errors" "fmt" - "github.com/ethereum/go-ethereum/core/types/bal" "os" "reflect" "runtime" @@ -28,6 +27,8 @@ import ( "strings" "unicode" + "github.com/ethereum/go-ethereum/core/types/bal" + "github.com/ethereum/go-ethereum/accounts" "github.com/ethereum/go-ethereum/accounts/external" "github.com/ethereum/go-ethereum/accounts/keystore" diff --git a/cmd/geth/main.go b/cmd/geth/main.go index 36942189afdc..b43675f50fe8 100644 --- a/cmd/geth/main.go +++ b/cmd/geth/main.go @@ -257,6 +257,8 @@ func init() { dumpConfigCommand, // see dbcmd.go dbCommand, + // See archivecmd.go + archiveCommand, // See cmd/utils/flags_legacy.go utils.ShowDeprecated, // See snapshot.go diff --git a/core/block_validator.go b/core/block_validator.go index f2df77a27d1c..cdfbe1074d9e 100644 --- a/core/block_validator.go +++ b/core/block_validator.go @@ -19,6 +19,7 @@ package core import ( "errors" "fmt" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/consensus" diff --git a/core/blockchain.go b/core/blockchain.go index b2c6ee9d9949..016d4c39ffac 100644 --- a/core/blockchain.go +++ b/core/blockchain.go @@ -21,7 +21,6 @@ import ( "context" "errors" "fmt" - "github.com/ethereum/go-ethereum/core/types/bal" "io" "math/big" "runtime" @@ -32,6 +31,8 @@ import ( "sync/atomic" "time" + "github.com/ethereum/go-ethereum/core/types/bal" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/lru" "github.com/ethereum/go-ethereum/common/mclock" diff --git a/node/node.go b/node/node.go index 56ecd7d52278..3beade3ed1c9 100644 --- a/node/node.go +++ b/node/node.go @@ -38,6 +38,7 @@ import ( "github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/p2p" "github.com/ethereum/go-ethereum/rpc" + "github.com/ethereum/go-ethereum/trie/archive" "github.com/gofrs/flock" ) @@ -85,6 +86,7 @@ func New(conf *Config) (*Node, error) { return nil, err } conf.DataDir = absdatadir + archive.ArchiveDataDir = absdatadir } if conf.Logger == nil { conf.Logger = log.New() diff --git a/trie/archive/archive.go b/trie/archive/archive.go new file mode 100644 index 000000000000..d4f4fb23825d --- /dev/null +++ b/trie/archive/archive.go @@ -0,0 +1,95 @@ +// Copyright 2026 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 archive + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/ethereum/go-ethereum/rlp" +) + +// ResolverFn is a callback to resolve expired nodes from an archive file. +// Given an offset and size, it returns the serialized node data from the archive. +type ResolverFn func(offset, size uint64) ([]*Record, error) + +// OffsetSize is the size of the file offset in bytes. +const OffsetSize = 8 + +var ( + EmptyArchiveRecord = errors.New("empty record") // The archive contained a size-zero record. + ErrNoResolver = errors.New("no archive resolver set for expired node") // An expired node is accessed without a resolver. +) + +// Record contains an archive file record. It is not the most optimal +// structure, since any modification to it will need to be overwritten. +type Record struct { + Path []byte + Value []byte +} + +// ArchiveDataDir is the data directory where the archive file is stored. +var ArchiveDataDir string + +// ArchivedNodeResolver takes a buffer containing the archive data +// held by an expiring node (an offset and a size) and returns a +// list of records, which is a list of serialized leaf nodes. The +// caller knows the context (MPT, binary trie) and is responsible +// for decoding the nodes. +func ArchivedNodeResolver(offset, size uint64) ([]*Record, error) { + file, err := os.Open(filepath.Join(ArchiveDataDir, "geth", "nodearchive")) + if err != nil { + return nil, fmt.Errorf("error opening archive file: %w", err) + } + defer file.Close() + + o, err := file.Seek(int64(offset), io.SeekStart) + if err != nil { + return nil, fmt.Errorf("error seeking into archive file: %w", err) + } + if uint64(o) != offset { + return nil, fmt.Errorf("invalid offset: want %d, got %d", offset, o) + } + + data := make([]byte, size) + if _, err := io.ReadFull(file, data); err != nil { + return nil, fmt.Errorf("error reading data from archive: %w", err) + } + + var records []*Record + stream := rlp.NewStream(bytes.NewReader(data), uint64(len(data))) + for len(data) > 0 { + _, size, err := stream.Kind() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("error getting rlp kind from archive data: %w", err) + } + var record Record + err = stream.Decode(&record) + if err != nil { + return nil, fmt.Errorf("error decoding rlp record from archive data (offset=%d, size=%d): %w", offset, size, err) + } + records = append(records, &record) + } + return records, nil +} diff --git a/trie/archive/writer.go b/trie/archive/writer.go new file mode 100644 index 000000000000..98b4ecce4b7f --- /dev/null +++ b/trie/archive/writer.go @@ -0,0 +1,92 @@ +// Copyright 2026 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 archive + +import ( + "os" + "sync" + + "github.com/ethereum/go-ethereum/rlp" +) + +// ArchiveWriter is an append-only writer for archive files. +// It writes RLP-encoded records to a file and tracks the current offset. +type ArchiveWriter struct { + file *os.File + offset uint64 + mu sync.Mutex +} + +// NewArchiveWriter creates a new archive writer that appends to the given file. +// If the file exists, it will be opened in append mode and writing continues +// from the current end of file. If it doesn't exist, it will be created. +func NewArchiveWriter(path string) (*ArchiveWriter, error) { + file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return nil, err + } + info, err := file.Stat() + if err != nil { + file.Close() + return nil, err + } + return &ArchiveWriter{ + file: file, + offset: uint64(info.Size()), + }, nil +} + +// WriteSubtree writes all records belonging to a subtree and returns +// the starting offset and total size of the written data. +// This is the atomic unit of archival - all records for a subtree are +// written together and can be retrieved together using the returned +// offset and size. +func (w *ArchiveWriter) WriteSubtree(records []*Record) (offset uint64, size uint64, err error) { + w.mu.Lock() + defer w.mu.Unlock() + + startOffset := w.offset + for _, rec := range records { + encoded, err := rlp.EncodeToBytes(rec) + if err != nil { + return 0, 0, err + } + if _, err := w.file.Write(encoded); err != nil { + return 0, 0, err + } + w.offset += uint64(len(encoded)) + } + return startOffset, w.offset - startOffset, nil +} + +// Sync flushes the file to disk. This should be called after writing +// a subtree and before modifying the database to ensure crash consistency. +func (w *ArchiveWriter) Sync() error { + return w.file.Sync() +} + +// Close closes the archive file. +func (w *ArchiveWriter) Close() error { + return w.file.Close() +} + +// Offset returns the current write offset in the file. +func (w *ArchiveWriter) Offset() uint64 { + w.mu.Lock() + defer w.mu.Unlock() + return w.offset +} diff --git a/trie/archiver.go b/trie/archiver.go new file mode 100644 index 000000000000..32b2eae71172 --- /dev/null +++ b/trie/archiver.go @@ -0,0 +1,599 @@ +// Copyright 2026 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 trie + +import ( + "encoding/binary" + "fmt" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/rawdb" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethdb" + "github.com/ethereum/go-ethereum/log" + "github.com/ethereum/go-ethereum/rlp" + "github.com/ethereum/go-ethereum/trie/archive" + "github.com/ethereum/go-ethereum/triedb/database" +) + +// subtreeInfo holds information about a subtree to be archived. +// It contains all the data needed to write the subtree to an archive +// and replace it with an expiredNode in the database. +type subtreeInfo struct { + path []byte // Hex-encoded path to subtree root + owner common.Hash // Zero for account trie, account hash for storage + height int // Height of subtree (from leaves) + leaves []*archive.Record // All leaf records (relative path + encoded node) + nodePaths [][]byte // Paths of all nodes to delete + rootHash common.Hash // Hash of the original subtree root (for verification) +} + +// Archiver handles the archival process of trie nodes. +// It walks the state trie, identifies subtrees at height 3, +// archives their leaf data, and replaces them with expiredNode markers. +// +// The archiver uses a streaming approach: it walks the trie using a +// NodeIterator, probes each node's height via bounded raw DB reads, +// and archives subtrees immediately when found. This keeps memory +// usage proportional to the iterator stack depth + the current subtree +// being processed, rather than loading the entire trie into memory. +type Archiver struct { + db ethdb.Database + triedb database.NodeDatabase + writer *archive.ArchiveWriter + compactionInterval uint64 + dryRun bool + stateRoot common.Hash + + // Progress tracking + subtreesArchived uint64 + bytesDeleted uint64 + leavesArchived uint64 + lastCompaction uint64 +} + +// NewArchiver creates a new archiver instance. +// +// Parameters: +// - db: The underlying key-value database +// - triedb: The trie database for reading nodes +// - writer: Archive file writer (can be nil for dry run) +// - compactionInterval: Run compaction after this many subtrees (0 = disable) +// - dryRun: If true, don't modify the database +func NewArchiver(db ethdb.Database, triedb database.NodeDatabase, + writer *archive.ArchiveWriter, compactionInterval uint64, dryRun bool) *Archiver { + return &Archiver{ + db: db, + triedb: triedb, + writer: writer, + compactionInterval: compactionInterval, + dryRun: dryRun, + } +} + +// ProcessState archives subtrees from the given state root. +// It processes storage tries first, then the account trie. +func (a *Archiver) ProcessState(root common.Hash) error { + a.stateRoot = root + + accountTrie, err := New(StateTrieID(root), a.triedb) + if err != nil { + return fmt.Errorf("failed to open account trie: %w", err) + } + + log.Info("Processing storage tries") + iter, err := accountTrie.NodeIterator(nil) + if err != nil { + return fmt.Errorf("failed to create account iterator: %w", err) + } + + kvIter := NewIterator(iter) + for kvIter.Next() { + // Decode the account to check for storage + var acc types.StateAccount + if err := rlp.DecodeBytes(kvIter.Value, &acc); err != nil { + log.Warn("Failed to decode account", "err", err) + continue + } + if acc.Root == types.EmptyRootHash { + continue + } + + // Process this account's storage trie + accountHash := common.BytesToHash(kvIter.Key) + storageID := StorageTrieID(root, accountHash, acc.Root) + storageTrie, err := New(storageID, a.triedb) + if err != nil { + log.Warn("Failed to open storage trie", "account", accountHash, "err", err) + continue + } + + if err := a.processTrie(accountHash, storageTrie); err != nil { + log.Warn("Failed to process storage trie", "account", accountHash, "err", err) + } + } + + if kvIter.Err != nil { + return fmt.Errorf("account iteration error: %w", kvIter.Err) + } + + log.Info("Processing account trie", "root", root) + if err := a.processTrie(common.Hash{}, accountTrie); err != nil { + return fmt.Errorf("failed to process account trie: %w", err) + } + + return nil +} + +// processTrie finds and archives all height-3 subtrees in the trie using +// a streaming approach. It walks the trie with a NodeIterator, probes each +// node's height via bounded raw DB reads, and archives subtrees immediately. +// +// Memory usage is O(iterator_stack_depth + current_subtree_size) instead of +// O(entire_trie) as with the previous recursive approach. +func (a *Archiver) processTrie(owner common.Hash, t *Trie) error { + if t.root == nil { + return nil + } + + iter, err := t.NodeIterator(nil) + if err != nil { + return fmt.Errorf("failed to create node iterator: %w", err) + } + + var ( + lastLog = time.Now() + found uint64 + ) + + for iter.Next(true) { + if iter.Leaf() { + continue + } + + // Progress logging + if time.Since(lastLog) > 30*time.Second { + log.Info("Scanning trie for subtrees", + "owner", owner, + "path", common.Bytes2Hex(iter.Path()), + "found", found, + "archived", a.subtreesArchived) + lastLog = time.Now() + } + + path := copyBytes(iter.Path()) + hash := iter.Hash() + if hash == (common.Hash{}) { + // Embedded node (no hash), skip — it will be part of a + // parent subtree. + continue + } + + // Probe subtree height via bounded raw DB reads. + // This does NOT load the trie into memory — it reads blobs from + // the DB, decodes them, computes height, and discards them. + height := a.probeHeight(owner, path, hash, 3) + if height != 3 { + // Too small to archive; the iterator will visit children. + // Too tall — descend into children to find height-3 subtrees. + continue + } + + // height == 3: collect and archive this subtree immediately. + info := a.collectSubtree(owner, path, hash) + if info == nil { + continue + } + found++ + + if err := a.archiveSubtree(info); err != nil { + log.Warn("Failed to archive subtree", "path", common.Bytes2Hex(path), "err", err) + continue + } + a.subtreesArchived++ + a.leavesArchived += uint64(len(info.leaves)) + + if err := a.maybeCompact(); err != nil { + log.Warn("Compaction failed", "err", err) + } + + // Skip children — they're now archived. + // We call Next(false) to move past the subtree without descending. + iter.Next(false) + } + + if iter.Error() != nil { + return fmt.Errorf("iterator error: %w", iter.Error()) + } + + log.Info("Found subtrees to archive", "owner", owner, "count", found) + return nil +} + +// probeHeight computes the height of a node by reading from the raw DB. +// It stops early once height exceeds maxHeight (returns maxHeight+1). +// The decoded nodes are not retained — they are discarded after inspection. +// +// Height is measured from leaves: leaves=0, their parents=1, etc. +func (a *Archiver) probeHeight(owner common.Hash, path []byte, hash common.Hash, maxHeight int) int { + blob := a.readNodeBlob(owner, path) + if len(blob) == 0 { + return 0 + } + + // Already expired — skip. + if blob[0] == expiredNodeMarker { + return -1 + } + + n, err := decodeNodeUnsafe(hash[:], blob) + if err != nil { + return 0 + } + + return a.nodeHeight(n, path, owner, maxHeight) +} + +// nodeHeight computes the height of a decoded node, bounded by maxHeight. +// Returns maxHeight+1 early if the subtree is taller than maxHeight. +func (a *Archiver) nodeHeight(n node, path []byte, owner common.Hash, maxHeight int) int { + switch n := n.(type) { + case nil: + return 0 + + case valueNode: + return 0 + + case *shortNode: + childPath := append(append([]byte{}, path...), n.Key...) + switch child := n.Val.(type) { + case valueNode: + return 1 // shortNode → leaf + case hashNode: + if maxHeight <= 1 { + return maxHeight + 1 + } + childHeight := a.probeHeight(owner, childPath, common.BytesToHash(child), maxHeight-1) + if childHeight < 0 { + return -1 // expired child + } + return childHeight + 1 + default: + // Inline node + childHeight := a.nodeHeight(child, childPath, owner, maxHeight-1) + if childHeight < 0 { + return -1 + } + return childHeight + 1 + } + + case *fullNode: + maxH := 0 + for i, child := range n.Children[:16] { + if child == nil { + continue + } + childPath := append(append([]byte{}, path...), byte(i)) + var childHeight int + switch c := child.(type) { + case valueNode: + childHeight = 0 + case hashNode: + if maxH+1 > maxHeight { + return maxHeight + 1 + } + childHeight = a.probeHeight(owner, childPath, common.BytesToHash(c), maxHeight-1) + default: + childHeight = a.nodeHeight(c, childPath, owner, maxHeight-1) + } + if childHeight < 0 { + continue // expired child, skip + } + h := childHeight + 1 + if h > maxH { + maxH = h + } + if maxH > maxHeight { + return maxHeight + 1 + } + } + return maxH + + case hashNode: + return a.probeHeight(owner, path, common.BytesToHash(n), maxHeight) + + case *expiredNode: + return -1 + } + return 0 +} + +// collectSubtree reads a height-3 subtree from the raw DB and collects its +// leaves and node paths for archival. The subtree is bounded (height ≤ 3), +// so memory usage is limited. +func (a *Archiver) collectSubtree(owner common.Hash, path []byte, hash common.Hash) *subtreeInfo { + blob := a.readNodeBlob(owner, path) + if len(blob) == 0 { + return nil + } + if blob[0] == expiredNodeMarker { + return nil + } + + n, err := decodeNodeUnsafe(hash[:], blob) + if err != nil { + log.Warn("Failed to decode node for collection", "path", common.Bytes2Hex(path), "err", err) + return nil + } + + info := &subtreeInfo{ + path: copyBytes(path), + owner: owner, + rootHash: hash, + } + + leaves, nodePaths, height, err := a.collectNodeLeaves(n, path, nil, owner) + if err != nil { + log.Warn("Failed to collect subtree leaves", "path", common.Bytes2Hex(path), "err", err) + return nil + } + + info.height = height + info.leaves = leaves + info.nodePaths = append([][]byte{copyBytes(path)}, nodePaths...) + return info +} + +// collectNodeLeaves recursively collects all leaves and node paths in a +// bounded subtree. relPath is the path relative to the subtree root. +// Returns (leaves, nodePaths, height, error). +func (a *Archiver) collectNodeLeaves(n node, absPath, relPath []byte, owner common.Hash) ([]*archive.Record, [][]byte, int, error) { + switch n := n.(type) { + case nil: + return nil, nil, 0, nil + + case valueNode: + return []*archive.Record{{ + Path: copyBytes(relPath), + Value: []byte(n), + }}, nil, 0, nil + + case *shortNode: + childAbsPath := append(append([]byte{}, absPath...), n.Key...) + var childNode node + switch c := n.Val.(type) { + case hashNode: + resolved, err := a.resolveRawNode(owner, childAbsPath, common.BytesToHash(c)) + if err != nil { + return nil, nil, 0, fmt.Errorf("resolve shortNode child at %s: %w", common.Bytes2Hex(childAbsPath), err) + } + childNode = resolved + default: + childNode = c + } + + // Pass nil relPath to child — we prepend the key ourselves + leaves, nodePaths, height, err := a.collectNodeLeaves(childNode, childAbsPath, nil, owner) + if err != nil { + return nil, nil, 0, err + } + + // Prepend [relPath + extension key] to leaf relative paths + prefix := append(append([]byte{}, relPath...), n.Key...) + for _, leaf := range leaves { + leaf.Path = append(append([]byte{}, prefix...), leaf.Path...) + } + + return leaves, append([][]byte{copyBytes(absPath)}, nodePaths...), height + 1, nil + + case *fullNode: + var ( + allLeaves []*archive.Record + allPaths [][]byte + maxHeight int + ) + for i, child := range n.Children[:16] { + if child == nil { + continue + } + childAbsPath := append(append([]byte{}, absPath...), byte(i)) + + var childNode node + switch c := child.(type) { + case hashNode: + resolved, err := a.resolveRawNode(owner, childAbsPath, common.BytesToHash(c)) + if err != nil { + return nil, nil, 0, fmt.Errorf("resolve fullNode child[%x] at %s: %w", i, common.Bytes2Hex(childAbsPath), err) + } + childNode = resolved + default: + childNode = c + } + + // Pass nil relPath to child — we prepend the index ourselves + leaves, nodePaths, height, err := a.collectNodeLeaves(childNode, childAbsPath, nil, owner) + if err != nil { + return nil, nil, 0, err + } + + // Prepend [relPath + branch index] to leaf relative paths + prefix := append(append([]byte{}, relPath...), byte(i)) + for _, leaf := range leaves { + leaf.Path = append(append([]byte{}, prefix...), leaf.Path...) + } + + allLeaves = append(allLeaves, leaves...) + allPaths = append(allPaths, nodePaths...) + h := height + 1 + if h > maxHeight { + maxHeight = h + } + } + return allLeaves, allPaths, maxHeight, nil + + case hashNode: + resolved, err := a.resolveRawNode(owner, absPath, common.BytesToHash(n)) + if err != nil { + return nil, nil, 0, err + } + return a.collectNodeLeaves(resolved, absPath, relPath, owner) + + case *expiredNode: + return nil, nil, 0, nil + } + return nil, nil, 0, nil +} + +// readNodeBlob reads a trie node blob directly from the raw key-value +// database, bypassing pathdb layers. +func (a *Archiver) readNodeBlob(owner common.Hash, path []byte) []byte { + if owner == (common.Hash{}) { + return rawdb.ReadAccountTrieNode(a.db, path) + } + return rawdb.ReadStorageTrieNode(a.db, owner, path) +} + +// resolveRawNode reads and decodes a trie node directly from the raw DB. +// Unlike resolveNode, this does NOT use the trie database (no caching, +// no diff layers). The decoded node is ephemeral and will be GC'd after use. +func (a *Archiver) resolveRawNode(owner common.Hash, path []byte, hash common.Hash) (node, error) { + blob := a.readNodeBlob(owner, path) + if len(blob) == 0 { + return nil, fmt.Errorf("node not found: owner=%s path=%s", owner, common.Bytes2Hex(path)) + } + if blob[0] == expiredNodeMarker { + return &expiredNode{}, nil + } + return decodeNodeUnsafe(hash[:], blob) +} + +// archiveSubtree writes leaves to archive and replaces subtree with expiredNode. +func (a *Archiver) archiveSubtree(info *subtreeInfo) error { + if a.dryRun { + log.Info("Would archive subtree", + "path", common.Bytes2Hex(info.path), + "owner", info.owner, + "height", info.height, + "leaves", len(info.leaves), + "nodes", len(info.nodePaths)) + return nil + } + + // 1. Write to archive file + offset, size, err := a.writer.WriteSubtree(info.leaves) + if err != nil { + return fmt.Errorf("failed to write subtree to archive: %w", err) + } + + // 2. Sync to ensure durability before modifying DB + if err := a.writer.Sync(); err != nil { + return fmt.Errorf("failed to sync archive: %w", err) + } + + // 3. Verify archive round-trip: reconstruct trie from records and + // check that the hash matches the original subtree root. This + // catches any data corruption before we delete the original nodes. + if info.rootHash != (common.Hash{}) { + reconstructed, err := archiveRecordsToNode(info.leaves) + if err != nil { + return fmt.Errorf("archive verification failed: cannot reconstruct trie from records: %w", err) + } + h := newHasher(false) + gotHash := common.BytesToHash(h.hash(reconstructed, true)) + returnHasherToPool(h) + if gotHash != info.rootHash { + return fmt.Errorf("archive verification failed: hash mismatch at path %s owner %s: got %s want %s (leaves=%d offset=%d size=%d)", + common.Bytes2Hex(info.path), info.owner, gotHash, info.rootHash, + len(info.leaves), offset, size) + } + } + + // 4. Batch database operations + batch := a.db.NewBatch() + + // Delete all nodes in subtree (except the root which we'll overwrite) + for _, nodePath := range info.nodePaths[1:] { // Skip first (root) + if info.owner == (common.Hash{}) { + rawdb.DeleteAccountTrieNode(batch, nodePath) + } else { + rawdb.DeleteStorageTrieNode(batch, info.owner, nodePath) + } + a.bytesDeleted += uint64(len(nodePath)) + } + + // Write expiredNode at subtree root + expiredBlob := encodeExpiredNodeBlob(offset, size) + if info.owner == (common.Hash{}) { + rawdb.WriteAccountTrieNode(batch, info.path, expiredBlob) + } else { + rawdb.WriteStorageTrieNode(batch, info.owner, info.path, expiredBlob) + } + + if err := batch.Write(); err != nil { + return fmt.Errorf("failed to write batch: %w", err) + } + + log.Debug("Archived subtree", + "path", common.Bytes2Hex(info.path), + "owner", info.owner, + "leaves", len(info.leaves), + "offset", offset, + "size", size) + + return nil +} + +// maybeCompact runs database compaction if the threshold is reached. +func (a *Archiver) maybeCompact() error { + if a.compactionInterval == 0 { + return nil + } + if a.subtreesArchived-a.lastCompaction >= a.compactionInterval { + log.Info("Running database compaction", "subtrees", a.subtreesArchived) + if err := a.db.Compact(nil, nil); err != nil { + return err + } + a.lastCompaction = a.subtreesArchived + } + return nil +} + +// encodeExpiredNodeBlob creates the raw bytes for an expiredNode. +// Format: 1-byte marker (0x00) + 8-byte offset + 8-byte size = 17 bytes +func encodeExpiredNodeBlob(offset, size uint64) []byte { + buf := make([]byte, 1+2*archive.OffsetSize) // 17 bytes + buf[0] = expiredNodeMarker // 0x00 + binary.BigEndian.PutUint64(buf[1:], offset) + binary.BigEndian.PutUint64(buf[1+archive.OffsetSize:], size) + return buf +} + +// Stats returns archival statistics. +func (a *Archiver) Stats() (subtrees, leaves, bytesDeleted uint64) { + return a.subtreesArchived, a.leavesArchived, a.bytesDeleted +} + +// copyBytes returns a copy of the given byte slice. +func copyBytes(b []byte) []byte { + if b == nil { + return nil + } + c := make([]byte, len(b)) + copy(c, b) + return c +} diff --git a/trie/committer.go b/trie/committer.go index 2a2142e0ffaa..7ea4e690cf45 100644 --- a/trie/committer.go +++ b/trie/committer.go @@ -79,6 +79,8 @@ func (c *committer) commit(path []byte, n node, parallel bool) node { return cn case hashNode: return cn + case *expiredNode: + return cn default: // nil, valuenode shouldn't be committed panic(fmt.Sprintf("%T: invalid node: %v", n, n)) diff --git a/trie/expired_node.go b/trie/expired_node.go new file mode 100644 index 000000000000..ce622daa03bd --- /dev/null +++ b/trie/expired_node.go @@ -0,0 +1,262 @@ +// Copyright 2026 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 trie + +import ( + "bytes" + "encoding/binary" + "fmt" + "time" + + "github.com/ethereum/go-ethereum/log" + "github.com/ethereum/go-ethereum/rlp" + "github.com/ethereum/go-ethereum/trie/archive" +) + +// expiredNodeMarker is a special marker byte to identify expired nodes. +// Using 0x00 as a marker since valid MPT nodes are always RLP lists (starting with 0xc0+). +const expiredNodeMarker = 0x00 + +// expiredNode represents a node whose data has been archived. +// It stores the file offset and size of the archived data. +type expiredNode struct { + offset uint64 + size uint64 + cachedHash hashNode + archiveResolver archive.ResolverFn +} + +func (n *expiredNode) cache() (hashNode, bool) { + return n.cachedHash, n.cachedHash == nil +} + +func (n *expiredNode) encode(w rlp.EncoderBuffer) { + var buf [1 + 2*archive.OffsetSize]byte + buf[0] = expiredNodeMarker + binary.BigEndian.PutUint64(buf[1:], n.offset) + binary.BigEndian.PutUint64(buf[1+archive.OffsetSize:], n.size) + w.Write(buf[:]) +} + +func (n *expiredNode) fstring(ind string) string { + return fmt.Sprintf(" ", n.offset, n.size) +} + +// Offset returns the archive file offset for this expired node. +func (n *expiredNode) Offset() uint64 { + return n.offset +} + +// SetArchiveResolver sets the resolver function for this expired node. +func (n *expiredNode) SetArchiveResolver(resolver archive.ResolverFn) { + n.archiveResolver = resolver +} + +// resolveExpiredNodeData resolves an expired node from the archive, verifies +// the reconstructed subtree hash, and stamps the cached hash onto the root. +// Returns an error if the archive data is corrupted (hash mismatch). +func resolveExpiredNodeData(n *expiredNode) (node, error) { + start := time.Now() + records, err := archive.ArchivedNodeResolver(n.offset, n.size) + if err != nil { + return nil, fmt.Errorf("failed to resolve expired node: %w", err) + } + resolved, err := archiveRecordsToNode(records) + if err != nil { + return nil, fmt.Errorf("failed to rebuild expired node from archive: %w", err) + } + depth := subtreeDepth(resolved) + log.Debug("Resurrected expired node from archive", + "offset", n.offset, "archiveBytes", n.size, + "records", len(records), "depth", depth, + "elapsed", time.Since(start)) + // Verify hash integrity: if the original hash is known, check that the + // reconstructed subtree produces the same hash. A mismatch means the + // archive is corrupted (e.g. missing leaves due to unresolvable hashNodes + // during archival) and any data from it is unreliable. + if n.cachedHash != nil { + h := newHasher(false) + gotHash := h.hash(resolved, true) + returnHasherToPool(h) + if !bytes.Equal(gotHash, n.cachedHash) { + return nil, fmt.Errorf("expired node hash mismatch at offset=%d size=%d: archive data is corrupted (expected %x got %x, %d records)", + n.offset, n.size, []byte(n.cachedHash), gotHash, len(records)) + } + // Stamp the original hash onto the resolved subtree root so the + // hasher returns it directly instead of re-computing. + switch nn := resolved.(type) { + case *fullNode: + nn.flags.hash = n.cachedHash + case *shortNode: + nn.flags.hash = n.cachedHash + } + } + // Mark the entire resolved subtree as dirty. This is critical for + // correctness with pathdb's diff layer model: when a trie with expired + // nodes is modified and committed, the committer only captures dirty + // nodes into the NodeSet (which becomes the diff layer). Without this + // marking, resolved-but-unmodified sibling nodes within the subtree + // would exist nowhere — not in any diff layer (they're clean) and not + // in the raw DB (the archiver deleted them). Subsequent trie accesses + // from higher diff layers would fall through to the disk layer, find + // nothing, and produce MissingNodeError. + // + // For read-only tries (only get operations, no commit), this dirty + // marking is harmless — the nodes are discarded when the trie is GC'd. + markSubtreeDirty(resolved) + return resolved, nil +} + +// subtreeDepth returns the maximum depth of a trie subtree. +func subtreeDepth(n node) int { + switch n := n.(type) { + case *fullNode: + max := 0 + for _, child := range &n.Children { + if child != nil { + if d := subtreeDepth(child); d > max { + max = d + } + } + } + return 1 + max + case *shortNode: + return 1 + subtreeDepth(n.Val) + default: + return 0 + } +} + +// markSubtreeDirty recursively marks all fullNode and shortNode in the +// subtree as dirty, preserving any cached hashes. This ensures the +// committer will capture them in the NodeSet during trie commit. +func markSubtreeDirty(n node) { + switch n := n.(type) { + case *fullNode: + n.flags.dirty = true + for _, child := range n.Children[:16] { + if child != nil { + markSubtreeDirty(child) + } + } + case *shortNode: + n.flags.dirty = true + markSubtreeDirty(n.Val) + } + // valueNode, hashNode, nil: no flags to mark +} + +func archiveRecordsToNode(records []*archive.Record) (node, error) { + if len(records) == 0 { + return nil, archive.EmptyArchiveRecord + } + + // Build the trie incrementally from nil to produce the canonical + // MPT structure. Starting with a fullNode would be wrong when the + // original subtree root was a shortNode (shared prefix). + var root node + for i, record := range records { + if err := validateRecordPath(record.Path); err != nil { + return nil, err + } + + key, err := normalizeRecordKey(record.Path) + if err != nil { + return nil, err + } + if len(key) < 1 { + return nil, fmt.Errorf("empty key in record #%d", i) + } + root, err = insertTrieNode(root, key, valueNode(record.Value)) + if err != nil { + return nil, err + } + } + return root, nil +} + +func validateRecordPath(path []byte) error { + for i, b := range path { + if b > 16 { + return fmt.Errorf("invalid nibble in record path: %d", b) + } + if b == 16 && i != len(path)-1 { + return fmt.Errorf("terminator nibble in middle of record path") + } + } + return nil +} + +// normalizeRecordKey ensures the record path is a hex-nibble key suitable for +// leaf insertion by guaranteeing a single terminator nibble and preserving any +// already-terminated path. Empty paths are normalized to a sole terminator. +func normalizeRecordKey(path []byte) ([]byte, error) { + if len(path) == 0 { + return []byte{16}, nil + } + if hasTerm(path) { + return path, nil + } + key := append([]byte{}, path...) + key = append(key, 16) + return key, nil +} + +func insertTrieNode(n node, key []byte, value node) (node, error) { + if len(key) == 0 { + return value, nil + } + switch n := n.(type) { + case *shortNode: + matchlen := prefixLen(key, n.Key) + if matchlen == len(n.Key) { + nn, err := insertTrieNode(n.Val, key[matchlen:], value) + if err != nil { + return nil, err + } + return &shortNode{Key: n.Key, Val: nn}, nil + } + branch := &fullNode{} + var err error + branch.Children[n.Key[matchlen]], err = insertTrieNode(nil, n.Key[matchlen+1:], n.Val) + if err != nil { + return nil, err + } + branch.Children[key[matchlen]], err = insertTrieNode(nil, key[matchlen+1:], value) + if err != nil { + return nil, err + } + if matchlen == 0 { + return branch, nil + } + return &shortNode{Key: key[:matchlen], Val: branch}, nil + + case *fullNode: + child, err := insertTrieNode(n.Children[key[0]], key[1:], value) + if err != nil { + return nil, err + } + n.Children[key[0]] = child + return n, nil + + case nil: + return &shortNode{Key: key, Val: value}, nil + + default: + return nil, fmt.Errorf("invalid node type in trie insert: %T", n) + } +} diff --git a/trie/expired_node_test.go b/trie/expired_node_test.go new file mode 100644 index 000000000000..40b3b056b415 --- /dev/null +++ b/trie/expired_node_test.go @@ -0,0 +1,601 @@ +// Copyright 2026 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 trie + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/ethereum/go-ethereum/rlp" + "github.com/ethereum/go-ethereum/trie/archive" +) + +// setupTestArchive creates a temporary archive directory with an archive file +// containing the given records, and configures archive.ArchiveDataDir to point +// to it. It returns the offset and size of the written data, and a cleanup function. +func setupTestArchive(t *testing.T, records []*archive.Record) (offset, size uint64, cleanup func()) { + t.Helper() + tmpDir := t.TempDir() + gethDir := filepath.Join(tmpDir, "geth") + if err := os.MkdirAll(gethDir, 0755); err != nil { + t.Fatal(err) + } + + writer, err := archive.NewArchiveWriter(filepath.Join(gethDir, "nodearchive")) + if err != nil { + t.Fatal(err) + } + + offset, size, err = writer.WriteSubtree(records) + if err != nil { + writer.Close() + t.Fatal(err) + } + writer.Close() + + oldDir := archive.ArchiveDataDir + archive.ArchiveDataDir = tmpDir + + return offset, size, func() { + archive.ArchiveDataDir = oldDir + } +} + +func TestExpiredNodeEncodeDecode(t *testing.T) { + testCases := []struct { + offset uint64 + size uint64 + }{ + {0, 0}, + {1, 100}, + {255, 1024}, + {256, 4096}, + {1 << 16, 1 << 20}, + {1 << 32, 1 << 32}, + {1<<64 - 1, 1<<64 - 1}, + } + + for _, tc := range testCases { + original := &expiredNode{offset: tc.offset, size: tc.size} + + w := rlp.NewEncoderBuffer(nil) + original.encode(w) + encoded := w.ToBytes() + w.Flush() + + decoded, err := decodeNodeUnsafe(nil, encoded) + if err != nil { + t.Fatalf("failed to decode expired node with offset %d, size %d: %v", tc.offset, tc.size, err) + } + + expNode, ok := decoded.(*expiredNode) + if !ok { + t.Fatalf("decoded node is not an expired node, got %T", decoded) + } + + if expNode.offset != original.offset { + t.Errorf("offset mismatch: got %d, want %d", expNode.offset, original.offset) + } + if expNode.size != original.size { + t.Errorf("size mismatch: got %d, want %d", expNode.size, original.size) + } + } +} + +func TestExpiredNodeEncodedFormat(t *testing.T) { + node := &expiredNode{offset: 0x0102030405060708, size: 0x1112131415161718} + + w := rlp.NewEncoderBuffer(nil) + node.encode(w) + encoded := w.ToBytes() + w.Flush() + + expected := []byte{ + 0x00, + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, + } + if !bytes.Equal(encoded, expected) { + t.Errorf("encoded format mismatch: got %x, want %x", encoded, expected) + } +} + +func TestExpiredNodeFstring(t *testing.T) { + node := &expiredNode{offset: 12345, size: 6789} + s := node.fstring("") + if s != " " { + t.Errorf("fstring mismatch: got %q", s) + } +} + +func TestExpiredNodeCache(t *testing.T) { + node := &expiredNode{offset: 100} + hash, dirty := node.cache() + if hash != nil { + t.Error("expected nil hash from expired node cache") + } + if !dirty { + t.Error("expected dirty=true from expired node cache") + } +} + +func TestExpiredNodeInvalidLength(t *testing.T) { + invalidCases := [][]byte{ + {0x00}, + {0x00, 0x01}, + {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}, + {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}, + {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11}, + } + + for _, buf := range invalidCases { + _, err := decodeNodeUnsafe(nil, buf) + if err == nil { + t.Errorf("expected error for buffer length %d, got nil", len(buf)) + } + } +} + +func TestExpiredNodeNoArchiveFile(t *testing.T) { + // When no archive file exists, Get should return an error + tmpDir := t.TempDir() + gethDir := filepath.Join(tmpDir, "geth") + if err := os.MkdirAll(gethDir, 0755); err != nil { + t.Fatal(err) + } + + oldDir := archive.ArchiveDataDir + archive.ArchiveDataDir = tmpDir + defer func() { archive.ArchiveDataDir = oldDir }() + + tr := NewEmpty(nil) + tr.root = &expiredNode{offset: 100, size: 50} + + _, err := tr.Get([]byte("key")) + if err == nil { + t.Error("expected error when archive file doesn't exist") + } +} + +func TestExpiredNodeWithResolver(t *testing.T) { + records := []*archive.Record{ + {Path: []byte{0x01, 0x02, 16}, Value: []byte("testvalue")}, + } + offset, size, cleanup := setupTestArchive(t, records) + defer cleanup() + + tr := NewEmpty(nil) + tr.root = &expiredNode{offset: offset, size: size} + + val, err := tr.Get([]byte{0x12}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(val) != "testvalue" { + t.Errorf("value mismatch: got %q, want %q", val, "testvalue") + } +} + +func TestExpiredNodeCopy(t *testing.T) { + original := &expiredNode{ + offset: 12345, + size: 6789, + archiveResolver: archive.ArchivedNodeResolver, + } + + copied := copyNode(original) + copiedExp, ok := copied.(*expiredNode) + if !ok { + t.Fatalf("copied node is not an expired node, got %T", copied) + } + + if copiedExp.offset != original.offset { + t.Errorf("offset mismatch: got %d, want %d", copiedExp.offset, original.offset) + } + + if copiedExp.size != original.size { + t.Errorf("size mismatch: got %d, want %d", copiedExp.size, original.size) + } + + if copiedExp.archiveResolver == nil { + t.Error("archive resolver was not copied") + } +} + +func TestArchiveRecordsToNodeEmpty(t *testing.T) { + _, err := archiveRecordsToNode([]*archive.Record{}) + if !errors.Is(err, archive.EmptyArchiveRecord) { + t.Errorf("expected EmptyArchiveRecord error, got %v", err) + } + + _, err = archiveRecordsToNode(nil) + if !errors.Is(err, archive.EmptyArchiveRecord) { + t.Errorf("expected EmptyArchiveRecord error for nil slice, got %v", err) + } +} + +func TestArchiveRecordsToNodeMultiple(t *testing.T) { + records := []*archive.Record{ + {Path: []byte{0x01, 16}, Value: []byte("value1")}, + {Path: []byte{0x02, 16}, Value: []byte("value2")}, + } + + node, err := archiveRecordsToNode(records) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + fn, ok := node.(*fullNode) + if !ok { + t.Fatalf("expected fullNode, got %T", node) + } + + if fn.Children[0x01] == nil { + t.Error("expected child at index 0x01") + } + if fn.Children[0x02] == nil { + t.Error("expected child at index 0x02") + } +} + +func TestExpiredNodeGetMultipleRecords(t *testing.T) { + records := []*archive.Record{ + {Path: []byte{0x01, 0x02, 16}, Value: []byte("value1")}, + {Path: []byte{0x04, 0x05, 16}, Value: []byte("value2")}, + } + offset, size, cleanup := setupTestArchive(t, records) + defer cleanup() + + tr := NewEmpty(nil) + tr.root = &expiredNode{offset: offset, size: size} + + val, err := tr.Get([]byte{0x12}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(val) != "value1" { + t.Errorf("value mismatch: got %q, want %q", val, "value1") + } + + tr2 := NewEmpty(nil) + tr2.root = &expiredNode{offset: offset, size: size} + + val2, err := tr2.Get([]byte{0x45}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(val2) != "value2" { + t.Errorf("value mismatch: got %q, want %q", val2, "value2") + } +} + +func TestExpiredNodeGetKeyNotFound(t *testing.T) { + records := []*archive.Record{ + {Path: []byte{0x01, 0x02, 16}, Value: []byte("value1")}, + } + offset, size, cleanup := setupTestArchive(t, records) + defer cleanup() + + tr := NewEmpty(nil) + tr.root = &expiredNode{offset: offset, size: size} + + val, err := tr.Get([]byte{0xff, 0xff}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if val != nil { + t.Errorf("expected nil value for non-existent key, got %q", val) + } +} + +func TestExpiredNodeGetPathMismatch(t *testing.T) { + records := []*archive.Record{ + {Path: []byte{0x01, 0x02, 16}, Value: []byte("testvalue")}, + } + offset, size, cleanup := setupTestArchive(t, records) + defer cleanup() + + tr := NewEmpty(nil) + tr.root = &expiredNode{offset: offset, size: size} + + val, err := tr.Get([]byte{0x19}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if val != nil { + t.Errorf("expected nil value when leaf key doesn't match, got %q", val) + } +} + +func TestExpiredNodeInsert(t *testing.T) { + records := []*archive.Record{ + {Path: []byte{0x01, 0x02, 16}, Value: []byte("existing")}, + } + offset, size, cleanup := setupTestArchive(t, records) + defer cleanup() + + tr := NewEmpty(nil) + tr.root = &expiredNode{offset: offset, size: size} + + err := tr.Update([]byte{0x45}, []byte("newvalue")) + if err != nil { + t.Fatalf("unexpected error on insert: %v", err) + } + + val, err := tr.Get([]byte{0x45}) + if err != nil { + t.Fatalf("unexpected error on get: %v", err) + } + if string(val) != "newvalue" { + t.Errorf("value mismatch: got %q, want %q", val, "newvalue") + } +} + +func TestExpiredNodeUpdate(t *testing.T) { + records := []*archive.Record{ + {Path: []byte{0x01, 0x02, 16}, Value: []byte("oldvalue")}, + } + offset, size, cleanup := setupTestArchive(t, records) + defer cleanup() + + tr := NewEmpty(nil) + tr.root = &expiredNode{offset: offset, size: size} + + err := tr.Update([]byte{0x12}, []byte("newvalue")) + if err != nil { + t.Fatalf("unexpected error on update: %v", err) + } + + val, err := tr.Get([]byte{0x12}) + if err != nil { + t.Fatalf("unexpected error on get: %v", err) + } + if string(val) != "newvalue" { + t.Errorf("value mismatch: got %q, want %q", val, "newvalue") + } +} + +func TestExpiredNodeDelete(t *testing.T) { + records := []*archive.Record{ + {Path: []byte{0x01, 0x02, 16}, Value: []byte("value1")}, + {Path: []byte{0x04, 0x05, 16}, Value: []byte("value2")}, + } + offset, size, cleanup := setupTestArchive(t, records) + defer cleanup() + + tr := NewEmpty(nil) + tr.root = &expiredNode{offset: offset, size: size} + + err := tr.Delete([]byte{0x12}) + if err != nil { + t.Fatalf("unexpected error on delete: %v", err) + } + + val, err := tr.Get([]byte{0x12}) + if err != nil { + t.Fatalf("unexpected error on get after delete: %v", err) + } + if val != nil { + t.Errorf("expected nil after delete, got %q", val) + } + + val2, err := tr.Get([]byte{0x45}) + if err != nil { + t.Fatalf("unexpected error getting other key: %v", err) + } + if string(val2) != "value2" { + t.Errorf("other value should still exist: got %q, want %q", val2, "value2") + } +} + +func TestTrieCopyPreservesArchiveResolver(t *testing.T) { + records := []*archive.Record{ + {Path: []byte{0x01, 0x02, 16}, Value: []byte("testvalue")}, + } + offset, size, cleanup := setupTestArchive(t, records) + defer cleanup() + + tr := NewEmpty(nil) + tr.root = &expiredNode{offset: offset, size: size} + + trCopy := tr.Copy() + + val, err := trCopy.Get([]byte{0x12}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(val) != "testvalue" { + t.Errorf("value mismatch: got %q, want %q", val, "testvalue") + } +} + +func TestWalkWithExpiredNodes(t *testing.T) { + records := []*archive.Record{ + {Path: []byte{0x01, 0x02, 16}, Value: []byte("value1")}, + {Path: []byte{0x04, 0x05, 16}, Value: []byte("value2")}, + {Path: []byte{0x07, 0x08, 16}, Value: []byte("value3")}, + } + offset, size, cleanup := setupTestArchive(t, records) + defer cleanup() + + tr := NewEmpty(nil) + tr.root = &expiredNode{offset: offset, size: size} + + var leaves []string + stats, err := tr.Walk(func(path []byte, value []byte) error { + leaves = append(leaves, string(value)) + return nil + }) + if err != nil { + t.Fatalf("Walk failed: %v", err) + } + if stats.Leaves != 3 { + t.Errorf("expected 3 leaves, got %d", stats.Leaves) + } + if stats.ExpiredResolved != 1 { + t.Errorf("expected 1 expired resolved, got %d", stats.ExpiredResolved) + } + // Verify all values were visited + expected := map[string]bool{"value1": true, "value2": true, "value3": true} + for _, leaf := range leaves { + if !expected[leaf] { + t.Errorf("unexpected leaf value: %q", leaf) + } + delete(expected, leaf) + } + if len(expected) > 0 { + t.Errorf("missing leaves: %v", expected) + } +} + +func TestWalkEmptyTrie(t *testing.T) { + tr := NewEmpty(nil) + stats, err := tr.Walk(func(path []byte, value []byte) error { + t.Error("callback should not be called for empty trie") + return nil + }) + if err != nil { + t.Fatalf("Walk failed: %v", err) + } + if stats.Leaves != 0 || stats.ExpiredResolved != 0 { + t.Errorf("expected zero stats for empty trie, got leaves=%d expired=%d", stats.Leaves, stats.ExpiredResolved) + } +} + +func TestWalkCallbackError(t *testing.T) { + records := []*archive.Record{ + {Path: []byte{0x01, 0x02, 16}, Value: []byte("value1")}, + } + offset, size, cleanup := setupTestArchive(t, records) + defer cleanup() + + tr := NewEmpty(nil) + tr.root = &expiredNode{offset: offset, size: size} + + testErr := errors.New("test error") + _, err := tr.Walk(func(path []byte, value []byte) error { + return testErr + }) + if !errors.Is(err, testErr) { + t.Fatalf("expected test error, got %v", err) + } +} + +// TestExpiredNodeResolvedSubtreeDirty verifies that when an expired node is +// resolved and a sibling leaf is modified, the commit captures ALL resolved +// nodes (not just the modified path). Without this fix, resolved-but-unmodified +// nodes would be lost: not in the diff layer (clean) and not in the raw DB +// (deleted by archiver). +func TestExpiredNodeResolvedSubtreeDirty(t *testing.T) { + // Use large values (>32 bytes) so leaf nodes are NOT embedded in + // their parent. This matches production storage tries where + // intermediate nodes are large enough to be stored independently. + bigVal1 := bytes.Repeat([]byte("A"), 40) + bigVal2 := bytes.Repeat([]byte("B"), 40) + + // Create an archive with records under different branches. + records := []*archive.Record{ + {Path: []byte{0x01, 0x02, 16}, Value: bigVal1}, + {Path: []byte{0x04, 0x05, 16}, Value: bigVal2}, + } + offset, size, cleanup := setupTestArchive(t, records) + defer cleanup() + + tr := NewEmpty(nil) + tr.root = &expiredNode{offset: offset, size: size} + + // Insert a value that goes through one branch of the resolved subtree. + // This modifies path [1, ...] but leaves path [4, ...] unmodified. + if err := tr.Update([]byte{0x12}, bytes.Repeat([]byte("C"), 40)); err != nil { + t.Fatalf("Update failed: %v", err) + } + + // Commit the trie. The NodeSet should be non-nil because we modified data. + _, nodes := tr.Commit(false) + if nodes == nil { + t.Fatal("expected non-nil NodeSet after modifying expired subtree") + } + + // The resolved-but-unmodified sibling (path [4, 5]) should also be + // captured in the NodeSet, because markSubtreeDirty ensures all resolved + // nodes are dirty. Count the nodes to verify. + nodeCount := len(nodes.Nodes) + // We expect at least 3 nodes: the root, the modified branch, and the + // sibling branch. The exact count depends on trie structure. + if nodeCount < 3 { + t.Errorf("expected at least 3 nodes in NodeSet (root + modified + sibling), got %d", nodeCount) + } +} + +// TestMarkSubtreeDirty verifies that markSubtreeDirty correctly sets the dirty +// flag on all nodes in a subtree while preserving cached hashes. +func TestMarkSubtreeDirty(t *testing.T) { + // Build a small trie structure + leaf1 := &shortNode{Key: []byte{1, 16}, Val: valueNode("v1")} + leaf2 := &shortNode{Key: []byte{2, 16}, Val: valueNode("v2")} + branch := &fullNode{} + branch.Children[1] = leaf1 + branch.Children[2] = leaf2 + + // Set hash but not dirty (as if loaded from DB) + branch.flags = nodeFlag{hash: hashNode("testhash"), dirty: false} + leaf1.flags = nodeFlag{hash: hashNode("hash1"), dirty: false} + leaf2.flags = nodeFlag{hash: hashNode("hash2"), dirty: false} + + markSubtreeDirty(branch) + + // All nodes should be dirty + if !branch.flags.dirty { + t.Error("branch should be dirty") + } + if !leaf1.flags.dirty { + t.Error("leaf1 should be dirty") + } + if !leaf2.flags.dirty { + t.Error("leaf2 should be dirty") + } + + // Hashes should be preserved + if !bytes.Equal(branch.flags.hash, hashNode("testhash")) { + t.Error("branch hash should be preserved") + } + if !bytes.Equal(leaf1.flags.hash, hashNode("hash1")) { + t.Error("leaf1 hash should be preserved") + } + if !bytes.Equal(leaf2.flags.hash, hashNode("hash2")) { + t.Error("leaf2 hash should be preserved") + } +} + +func TestExpiredNodeGetNode(t *testing.T) { + records := []*archive.Record{ + {Path: []byte{0x01, 0x02, 16}, Value: []byte("testvalue")}, + } + offset, size, cleanup := setupTestArchive(t, records) + defer cleanup() + + tr := NewEmpty(nil) + tr.root = &expiredNode{offset: offset, size: size} + + _, _, err := tr.GetNode(hexToCompact([]byte{0x01, 0x02})) + if err != nil && err.Error() != "non-consensus node" { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/trie/hasher.go b/trie/hasher.go index a2a1f5b662c2..d4376e12e26e 100644 --- a/trie/hasher.go +++ b/trie/hasher.go @@ -18,6 +18,7 @@ package trie import ( "bytes" + "encoding/binary" "fmt" "sync" @@ -97,6 +98,22 @@ func (h *hasher) hash(n node, force bool) []byte { // hash nodes don't have children, so they're left as were return n + case *expiredNode: + // Return the original subtree hash that was cached when the + // expired node was decoded. The parent node references this + // hash, so we must return the same value to keep the Merkle + // root consistent. + if n.cachedHash != nil { + return n.cachedHash + } + // Fallback: hash the marker blob (should not happen in practice + // because decodeNodeUnsafe always provides the hash). + var buf [1 + 2*8]byte // 17 bytes + buf[0] = expiredNodeMarker + binary.BigEndian.PutUint64(buf[1:], n.offset) + binary.BigEndian.PutUint64(buf[9:], n.size) + return h.hashData(buf[:]) + default: panic(fmt.Errorf("unexpected node type, %T", n)) } @@ -214,6 +231,12 @@ func (h *hasher) proofHash(original node) []byte { return bytes.Clone(h.encodeShortNode(n)) case *fullNode: return bytes.Clone(h.encodeFullNode(n)) + case *expiredNode: + var buf [1 + 2*8]byte + buf[0] = expiredNodeMarker + binary.BigEndian.PutUint64(buf[1:], n.offset) + binary.BigEndian.PutUint64(buf[9:], n.size) + return buf[:] default: panic(fmt.Errorf("unexpected node type, %T", original)) } diff --git a/trie/node.go b/trie/node.go index 70221160488b..0034521d78f8 100644 --- a/trie/node.go +++ b/trie/node.go @@ -18,12 +18,15 @@ package trie import ( "bytes" + "encoding/binary" "fmt" "io" "strings" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/rlp" + "github.com/ethereum/go-ethereum/trie/archive" ) var indices = []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f", "[17]"} @@ -157,6 +160,15 @@ func decodeNodeUnsafe(hash, buf []byte) (node, error) { if len(buf) == 0 { return nil, io.ErrUnexpectedEOF } + if buf[0] == expiredNodeMarker { + if len(buf) != 1+2*archive.OffsetSize { + return nil, fmt.Errorf("invalid expired node length: %d", len(buf)) + } + offset := binary.BigEndian.Uint64(buf[1:]) + size := binary.BigEndian.Uint64(buf[1+archive.OffsetSize:]) + log.Debug("Decoded expired node", "offset", offset, "size", size, "hash", common.BytesToHash(hash)) + return &expiredNode{offset: offset, size: size, cachedHash: hashNode(hash), archiveResolver: archive.ArchivedNodeResolver}, nil + } elems, _, err := rlp.SplitList(buf) if err != nil { return nil, fmt.Errorf("decode error: %v", err) diff --git a/trie/proof.go b/trie/proof.go index 58075daf9b11..5be05c6f8132 100644 --- a/trie/proof.go +++ b/trie/proof.go @@ -25,6 +25,7 @@ import ( "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/ethdb" "github.com/ethereum/go-ethereum/log" + "github.com/ethereum/go-ethereum/trie/archive" ) // Prove constructs a merkle proof for key. The result contains all encoded nodes @@ -78,6 +79,16 @@ func (t *Trie) Prove(key []byte, proofDb ethdb.KeyValueWriter) error { // clean cache or the database, they are all in their own // copy and safe to use unsafe decoder. tn = mustDecodeNodeUnsafe(n, blob) + case *expiredNode: + records, err := archive.ArchivedNodeResolver(n.offset, n.size) + if err != nil { + return fmt.Errorf("failed to resolve expired node in proof: %w", err) + } + resolved, err := archiveRecordsToNode(records) + if err != nil { + return fmt.Errorf("failed to rebuild expired node in proof: %w", err) + } + tn = resolved default: panic(fmt.Sprintf("%T: invalid node: %v", tn, tn)) } @@ -617,6 +628,8 @@ func get(tn node, key []byte, skipResolved bool) ([]byte, node) { } case hashNode: return key, n + case *expiredNode: + return key, n case nil: return key, nil case valueNode: diff --git a/trie/trie.go b/trie/trie.go index 7e69a90823fb..9cc6e9157e4a 100644 --- a/trie/trie.go +++ b/trie/trie.go @@ -26,6 +26,7 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/log" + "github.com/ethereum/go-ethereum/trie/archive" "github.com/ethereum/go-ethereum/trie/trienode" "github.com/ethereum/go-ethereum/triedb/database" "golang.org/x/sync/errgroup" @@ -57,6 +58,10 @@ type Trie struct { // reader is the handler trie can retrieve nodes from. reader *Reader + // archiveResolver is an optional callback to resolve expired nodes from + // an archive file. + archiveResolver archive.ResolverFn + // Various tracers for capturing the modifications to trie opTracer *opTracer prevalueTracer *PrevalueTracer @@ -70,17 +75,23 @@ func (t *Trie) newFlag() nodeFlag { // Copy returns a copy of Trie. func (t *Trie) Copy() *Trie { return &Trie{ - root: copyNode(t.root), - owner: t.owner, - committed: t.committed, - unhashed: t.unhashed, - uncommitted: t.uncommitted, - reader: t.reader, - opTracer: t.opTracer.copy(), - prevalueTracer: t.prevalueTracer.Copy(), + root: copyNode(t.root), + owner: t.owner, + committed: t.committed, + unhashed: t.unhashed, + uncommitted: t.uncommitted, + reader: t.reader, + archiveResolver: t.archiveResolver, + opTracer: t.opTracer.copy(), + prevalueTracer: t.prevalueTracer.Copy(), } } +// SetArchiveResolver sets the archive resolver callback for expired nodes. +func (t *Trie) SetArchiveResolver(resolver archive.ResolverFn) { + t.archiveResolver = resolver +} + // New creates the trie instance with provided trie id and the read-only // database. The state specified by trie id must be available, otherwise // an error will be returned. The trie root specified by trie id can be @@ -218,6 +229,14 @@ func (t *Trie) get(origNode node, key []byte, pos int) (value []byte, newnode no } value, newnode, _, err := t.get(child, key, pos) return value, newnode, true, err + case *expiredNode: + log.Debug("Resolving expired node in get()", "owner", t.owner, "offset", n.offset, "size", n.size, "pos", pos) + newnode, err := resolveExpiredNodeData(n) + if err != nil { + return nil, n, false, err + } + value, _, _, err = t.get(newnode, key, pos) + return value, newnode, true, err default: panic(fmt.Sprintf("%T: invalid node: %v", origNode, origNode)) } @@ -352,6 +371,14 @@ func (t *Trie) getNode(origNode node, path []byte, pos int) (item []byte, newnod item, newnode, resolved, err := t.getNode(child, path, pos) return item, newnode, resolved + 1, err + case *expiredNode: + rn, err := resolveExpiredNodeData(n) + if err != nil { + return nil, n, 0, err + } + item, newnode, resolvedCount, err := t.getNode(rn, path, pos) + return item, newnode, resolvedCount + 1, err + default: panic(fmt.Sprintf("%T: invalid node: %v", origNode, origNode)) } @@ -475,6 +502,18 @@ func (t *Trie) insert(n node, prefix, key []byte, value node) (bool, node, error } return true, nn, nil + case *expiredNode: + log.Debug("Resolving expired node in insert()", "owner", t.owner, "offset", n.offset, "size", n.size) + rn, err := resolveExpiredNodeData(n) + if err != nil { + return false, nil, err + } + dirty, nn, err := t.insert(rn, prefix, key, value) + if !dirty || err != nil { + return false, rn, err + } + return true, nn, nil + default: panic(fmt.Sprintf("%T: invalid node: %v", n, n)) } @@ -699,6 +738,18 @@ func (t *Trie) delete(n node, prefix, key []byte) (bool, node, error) { } return true, nn, nil + case *expiredNode: + log.Debug("Resolving expired node in delete()", "owner", t.owner, "offset", n.offset, "size", n.size) + rn, err := resolveExpiredNodeData(n) + if err != nil { + return false, nil, err + } + dirty, nn, err := t.delete(rn, prefix, key) + if !dirty || err != nil { + return false, rn, err + } + return true, nn, nil + default: panic(fmt.Sprintf("%T: invalid node: %v (%v)", n, n, key)) } @@ -729,14 +780,24 @@ func copyNode(n node) node { } case hashNode: return n + case *expiredNode: + return &expiredNode{ + offset: n.offset, + size: n.size, + cachedHash: common.CopyBytes(n.cachedHash), + archiveResolver: n.archiveResolver, + } default: panic(fmt.Sprintf("%T: unknown node type", n)) } } func (t *Trie) resolve(n node, prefix []byte) (node, error) { - if n, ok := n.(hashNode); ok { + switch n := n.(type) { + case hashNode: return t.resolveAndTrack(n, prefix) + case *expiredNode: + return resolveExpiredNodeData(n) } return n, nil } @@ -847,6 +908,58 @@ func (t *Trie) Witness() map[string][]byte { return t.prevalueTracer.Values() } +// WalkStats holds statistics from a Walk traversal. +type WalkStats struct { + Leaves int // Number of leaf nodes visited + ExpiredResolved int // Number of expired nodes resolved from archive +} + +// Walk recursively traverses the trie, resolving all nodes including +// hashNodes and expiredNodes. It calls fn for each leaf found. +// This triggers hash verification for expired nodes via cachedHash. +func (t *Trie) Walk(fn func(path []byte, value []byte) error) (WalkStats, error) { + return t.walk(t.root, nil, fn) +} + +func (t *Trie) walk(n node, path []byte, fn func([]byte, []byte) error) (WalkStats, error) { + switch n := n.(type) { + case *shortNode: + return t.walk(n.Val, append(append([]byte{}, path...), n.Key...), fn) + case *fullNode: + var stats WalkStats + for i, child := range n.Children[:16] { + if child != nil { + childStats, err := t.walk(child, append(append([]byte{}, path...), byte(i)), fn) + if err != nil { + return stats, err + } + stats.Leaves += childStats.Leaves + stats.ExpiredResolved += childStats.ExpiredResolved + } + } + return stats, nil + case hashNode: + resolved, err := t.resolveAndTrack(n, path) + if err != nil { + return WalkStats{}, err + } + return t.walk(resolved, path, fn) + case *expiredNode: + resolved, err := resolveExpiredNodeData(n) + if err != nil { + return WalkStats{}, err + } + childStats, err := t.walk(resolved, path, fn) + childStats.ExpiredResolved++ + return childStats, err + case valueNode: + return WalkStats{Leaves: 1}, fn(path, []byte(n)) + case nil: + return WalkStats{}, nil + } + return WalkStats{}, nil +} + // reset drops the referenced root node and cleans all internal state. func (t *Trie) reset() { t.root = nil diff --git a/triedb/database.go b/triedb/database.go index ef95169df1ae..71b578367bba 100644 --- a/triedb/database.go +++ b/triedb/database.go @@ -399,6 +399,28 @@ func (db *Database) Disk() ethdb.Database { return db.disk } +// DiffHead returns the root hash of the topmost diff layer in pathdb. +// If there are no diff layers or the backend is not pathdb, it returns +// the zero hash and false. +func (db *Database) DiffHead() (common.Hash, bool) { + pdb, ok := db.backend.(*pathdb.Database) + if !ok { + return common.Hash{}, false + } + return pdb.DiffHead() +} + +// DisableStateHistory closes and disables the state history freezer. +// This is used by the archiver to bypass state history writes during +// diff layer flushing when state history may have gaps. +func (db *Database) DisableStateHistory() { + pdb, ok := db.backend.(*pathdb.Database) + if !ok { + return + } + pdb.DisableStateHistory() +} + // SnapshotCompleted returns the indicator if the snapshot is completed. func (db *Database) SnapshotCompleted() bool { pdb, ok := db.backend.(*pathdb.Database) diff --git a/triedb/pathdb/database.go b/triedb/pathdb/database.go index e52949c93e87..ba606552df1e 100644 --- a/triedb/pathdb/database.go +++ b/triedb/pathdb/database.go @@ -318,6 +318,30 @@ func (db *Database) Update(root common.Hash, parentRoot common.Hash, block uint6 return db.tree.cap(root, maxDiffLayers) } +// DiffHead returns the root hash of the topmost diff layer. If there are no +// diff layers (only the disk layer), it returns the disk layer root and false. +func (db *Database) DiffHead() (common.Hash, bool) { + db.lock.RLock() + defer db.lock.RUnlock() + + return db.tree.diffHead() +} + +// DisableStateHistory closes and disables the state history freezer. This is +// used by the archiver to bypass state history writes during diff layer flushing, +// since the archiver only needs trie nodes committed to disk and state history +// may have gaps from unclean shutdowns that prevent sequential appends. +func (db *Database) DisableStateHistory() { + db.lock.Lock() + defer db.lock.Unlock() + + if db.stateFreezer != nil { + db.stateFreezer.Close() + db.stateFreezer = nil + log.Info("Disabled state history freezer") + } +} + // Commit traverses downwards the layer tree from a specified layer with the // provided state root and all the layers below are flattened downwards. It // can be used alone and mostly for test purposes. diff --git a/triedb/pathdb/history.go b/triedb/pathdb/history.go index 7f5b0e35bac6..7751bbe29dcd 100644 --- a/triedb/pathdb/history.go +++ b/triedb/pathdb/history.go @@ -278,9 +278,17 @@ func truncateFromHead(store ethdb.AncientStore, typ historyType, nhead uint64) ( return 0, err } // Ensure that the truncation target falls within the valid range. - if ohead < nhead || nhead < otail { + if nhead < otail { return 0, fmt.Errorf("%w, %s, tail: %d, head: %d, target: %d", errHeadTruncationOutOfRange, typ, otail, ohead, nhead) } + // If the target is ahead of the current head, there's nothing to truncate. + // This can happen after unclean shutdowns where the state history was not + // fully written. + if ohead < nhead { + log.Warn("State history shorter than target, nothing to truncate", + "type", typ.String(), "head", ohead, "target", nhead) + return 0, nil + } // Short circuit if nothing to truncate. if ohead == nhead { return 0, nil diff --git a/triedb/pathdb/history_state_test.go b/triedb/pathdb/history_state_test.go index 4046fb96400a..845d58b256fb 100644 --- a/triedb/pathdb/history_state_test.go +++ b/triedb/pathdb/history_state_test.go @@ -244,8 +244,8 @@ func TestTruncateOutOfRange(t *testing.T) { target uint64 expErr error }{ - {0, head, nil}, // nothing to delete - {0, head + 1, errHeadTruncationOutOfRange}, + {0, head, nil}, // nothing to delete + {0, head + 1, nil}, // gracefully handled after unclean shutdown {0, tail - 1, errHeadTruncationOutOfRange}, {1, tail, nil}, // nothing to delete {1, head + 1, errTailTruncationOutOfRange}, diff --git a/triedb/pathdb/layertree.go b/triedb/pathdb/layertree.go index b20e40bd0516..99fd23a2a1a1 100644 --- a/triedb/pathdb/layertree.go +++ b/triedb/pathdb/layertree.go @@ -31,6 +31,7 @@ import ( // of the referenced layer by themselves. type layerTree struct { base *diskLayer + head common.Hash // Root hash of the topmost layer (diff or disk) layers map[common.Hash]layer // descendants is a two-dimensional map where the keys represent @@ -59,6 +60,7 @@ func (tree *layerTree) init(head layer) { defer tree.lock.Unlock() current := head + tree.head = head.rootHash() tree.layers = make(map[common.Hash]layer) tree.descendants = make(map[common.Hash]map[common.Hash]struct{}) @@ -76,6 +78,18 @@ func (tree *layerTree) init(head layer) { tree.lookup = newLookup(head, tree.isDescendant) } +// diffHead returns the root hash of the topmost diff layer. If there are no +// diff layers, returns the disk layer root and false. +func (tree *layerTree) diffHead() (common.Hash, bool) { + tree.lock.RLock() + defer tree.lock.RUnlock() + + if _, ok := tree.layers[tree.head].(*diffLayer); ok { + return tree.head, true + } + return tree.base.rootHash(), false +} + // get retrieves a layer belonging to the given state root. func (tree *layerTree) get(root common.Hash) layer { tree.lock.RLock() diff --git a/triedb/pathdb/reader.go b/triedb/pathdb/reader.go index e087ef26edbd..845667b578f7 100644 --- a/triedb/pathdb/reader.go +++ b/triedb/pathdb/reader.go @@ -69,7 +69,7 @@ func (r *reader) Node(owner common.Hash, path []byte, hash common.Hash) ([]byte, return nil, err } // Error out if the local one is inconsistent with the target. - if !r.noHashCheck && got != hash { + if !r.noHashCheck && (len(blob) > 0 && blob[0] != 0) && got != hash { // Location is always available even if the node // is not found. switch loc.loc { From 69b330d98e87bf0a8dca990f42c5604315a9fa79 Mon Sep 17 00:00:00 2001 From: CPerezz <37264926+CPerezz@users.noreply.github.com> Date: Fri, 19 Jun 2026 10:43:54 +0200 Subject: [PATCH 17/17] trie: fix nodeHeight over-estimate so height-3 branches are archived (#577) The nodeHeight *fullNode case returned maxHeight+1 once the running max height reached maxHeight via one child: the `if maxH+1 > maxHeight` guard fired on the next hashNode child, reporting a genuine height-3 branch as height 4. With the `height == 3` archival predicate this made dense, branch-heavy tries (notably the account trie, whose height-3 nodes are all multi-child branches) archive nothing, while only sparse, extension-heavy storage tries archived anything. On a jochemnet shadowfork (path scheme, ~379 GB live KV) a 15.5h `archive generate` archived only 122,340 subtrees / 16.6 MB, and the account trie archived nothing (count=0), with zero read/collection failures in the log -- confirming a height computation bug rather than a read-path issue. Replace the running-max guard with a depth-budget guard mirroring the already-correct *shortNode case; keep the post-update `maxH > maxHeight` exceeded check so recursion and raw-DB reads stay bounded. Add trie/archiver_test.go: a height-3 root branch probes as height 3 (it returned 4 before this change). Co-authored-by: CPerezz --- trie/archiver.go | 8 +++-- trie/archiver_test.go | 81 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 trie/archiver_test.go diff --git a/trie/archiver.go b/trie/archiver.go index 32b2eae71172..34d3bd89b8f4 100644 --- a/trie/archiver.go +++ b/trie/archiver.go @@ -283,6 +283,11 @@ func (a *Archiver) nodeHeight(n node, path []byte, owner common.Hash, maxHeight } case *fullNode: + if maxHeight <= 0 { + // No depth budget left: a fullNode always has at least one + // child, so its height is >= 1, i.e. already > maxHeight. + return maxHeight + 1 + } maxH := 0 for i, child := range n.Children[:16] { if child == nil { @@ -294,9 +299,6 @@ func (a *Archiver) nodeHeight(n node, path []byte, owner common.Hash, maxHeight case valueNode: childHeight = 0 case hashNode: - if maxH+1 > maxHeight { - return maxHeight + 1 - } childHeight = a.probeHeight(owner, childPath, common.BytesToHash(c), maxHeight-1) default: childHeight = a.nodeHeight(c, childPath, owner, maxHeight-1) diff --git a/trie/archiver_test.go b/trie/archiver_test.go new file mode 100644 index 000000000000..938081ae6d5c --- /dev/null +++ b/trie/archiver_test.go @@ -0,0 +1,81 @@ +// Copyright 2026 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 trie + +import ( + "bytes" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/rawdb" +) + +// TestProbeHeightFullNodeBranch is a regression test for an over-estimate bug in +// nodeHeight's *fullNode case. The previous guard +// +// if maxH+1 > maxHeight { return maxHeight + 1 } +// +// fired once the running max height reached maxHeight via one child, so the next +// hashNode child made a genuine height-3 branch report as height 4. Combined with +// the "archive only height == 3" predicate, dense branch-heavy tries (notably the +// account trie, whose height-3 nodes are all multi-child branches) archived +// nothing. Pre-fix probeHeight returns 4 here; post-fix it must return 3. +// +// The trie built below has a height-3 ROOT branch: +// +// root (branch @ nibble0, children at 0 and 1) height 3 +// ├─ child0 (branch @ nibble1) ├─ leafA ├─ leafB height 2 +// └─ child1 (branch @ nibble1) ├─ leafC ├─ leafD height 2 +// +// Values are 40 bytes so leaves are NOT embedded; root's children are hashNodes, +// which is exactly what exercises the buggy branch of nodeHeight. +func TestProbeHeightFullNodeBranch(t *testing.T) { + big := func(b byte) []byte { return bytes.Repeat([]byte{b}, 40) } + + tr := NewEmpty(nil) + keys := [][]byte{ + {0x00, 0x11, 0x11, 0x11}, // nibbles 0,0,... + {0x01, 0x22, 0x22, 0x22}, // nibbles 0,1,... + {0x10, 0x33, 0x33, 0x33}, // nibbles 1,0,... + {0x11, 0x44, 0x44, 0x44}, // nibbles 1,1,... + } + for i, k := range keys { + if err := tr.Update(k, big(byte('A'+i))); err != nil { + t.Fatalf("update %x: %v", k, err) + } + } + root, nodes := tr.Commit(false) + if nodes == nil { + t.Fatal("nil node set after commit") + } + + // Persist the committed nodes under account-trie path keys so the archiver's + // raw-DB reader (readNodeBlob -> ReadAccountTrieNode) can resolve them. + raw := rawdb.NewMemoryDatabase() + for path, n := range nodes.Nodes { + if n == nil || len(n.Blob) == 0 { // skip deletions + continue + } + rawdb.WriteAccountTrieNode(raw, []byte(path), n.Blob) + } + a := &Archiver{db: raw} + + if got := a.probeHeight(common.Hash{}, nil, root, 3); got != 3 { + t.Fatalf("probeHeight(height-3 root branch) = %d, want 3 "+ + "(a height-3 branch must be detected as height 3, not over-estimated)", got) + } +}