From a41c3ca25dfec0a2a691b5dfca5ed40d838b8344 Mon Sep 17 00:00:00 2001 From: Angel Valkov Date: Wed, 17 Dec 2025 16:55:13 +0200 Subject: [PATCH 1/2] SLOAD and SSTORE gas per trie size --- core/state/state_object.go | 72 +++++++++++++++++++++++++++++------- core/state/statedb.go | 12 ++++++ core/state/statedb_hooked.go | 4 ++ core/vm/evm.go | 57 +++++++++++++++++----------- core/vm/gas_table.go | 26 +++++++------ core/vm/interface.go | 5 +++ core/vm/operations_acl.go | 52 +++++++++++++++++++++++--- 7 files changed, 177 insertions(+), 51 deletions(-) diff --git a/core/state/state_object.go b/core/state/state_object.go index c71e8b2dc8..8ce3dbfd3d 100644 --- a/core/state/state_object.go +++ b/core/state/state_object.go @@ -85,6 +85,12 @@ type stateObject struct { // object was previously existent and is being deployed as a contract within // the current transaction. newContract bool + + // storageTrieSizeBytes holds an approximate storage trie "size" computed as + // the sum of RLP-encoded standalone trie node blobs (NodeBlob sizes) for the + // current storage trie root. + storageTrieSizeBytes uint64 + storageTrieSizeBytesInit bool } // empty returns whether the account is considered empty. @@ -114,6 +120,7 @@ func newObject(db *StateDB, address common.Address, acct *types.StateAccount) *s func (s *stateObject) markSelfdestructed() { s.selfDestructed = true + s.storageTrieSizeBytesInit = false } func (s *stateObject) touch() { @@ -155,6 +162,42 @@ func (s *stateObject) getPrefetchedTrie() Trie { return s.db.prefetcher.trie(s.addrHash, s.data.Root) } +// storageTrieSize walks the entire storage trie and returns an approximate size +// metric as the sum of RLP-encoded standalone trie node blobs (NodeBlob sizes). +// The result is cached per state object within the current execution scope to +// avoid redundant traversals. If the trie can't be loaded or iterated, the +// method returns false to signal that the size is unavailable. +func (s *stateObject) storageTrieSize() (uint64, bool) { + if s.storageTrieSizeBytesInit { + return s.storageTrieSizeBytes, true + } + tr, err := s.getTrie() + if err != nil { + log.Error("Failed to open storage trie", "address", s.address, "err", err) + return 0, false + } + it, err := tr.NodeIterator(nil) + if err != nil { + log.Error("Failed to create storage trie iterator", "address", s.address, "err", err) + return 0, false + } + var size uint64 + for it.Next(true) { + blob := it.NodeBlob() + if blob == nil { + continue + } + size += uint64(len(blob)) + } + if err := it.Error(); err != nil { + log.Error("Failed to iterate storage trie", "address", s.address, "err", err) + return 0, false + } + s.storageTrieSizeBytes = size + s.storageTrieSizeBytesInit = true + return size, true +} + // GetState retrieves a value associated with the given storage key. func (s *stateObject) GetState(key common.Hash) common.Hash { value, _ := s.getState(key) @@ -235,6 +278,7 @@ func (s *stateObject) SetState(key, value common.Hash) common.Hash { // setState updates a value in account dirty storage. The dirtiness will be // removed if the value being set equals to the original value. func (s *stateObject) setState(key common.Hash, value common.Hash, origin common.Hash) { + s.storageTrieSizeBytesInit = false // Storage slot is set back to its original value, undo the dirty marker if value == origin { delete(s.dirtyStorage, key) @@ -491,19 +535,21 @@ func (s *stateObject) setBalance(amount *uint256.Int) { func (s *stateObject) deepCopy(db *StateDB) *stateObject { obj := &stateObject{ - db: db, - address: s.address, - addrHash: s.addrHash, - origin: s.origin, - data: s.data, - code: s.code, - originStorage: s.originStorage.Copy(), - pendingStorage: s.pendingStorage.Copy(), - dirtyStorage: s.dirtyStorage.Copy(), - uncommittedStorage: s.uncommittedStorage.Copy(), - dirtyCode: s.dirtyCode, - selfDestructed: s.selfDestructed, - newContract: s.newContract, + db: db, + address: s.address, + addrHash: s.addrHash, + origin: s.origin, + data: s.data, + code: s.code, + originStorage: s.originStorage.Copy(), + pendingStorage: s.pendingStorage.Copy(), + dirtyStorage: s.dirtyStorage.Copy(), + uncommittedStorage: s.uncommittedStorage.Copy(), + dirtyCode: s.dirtyCode, + selfDestructed: s.selfDestructed, + newContract: s.newContract, + storageTrieSizeBytes: s.storageTrieSizeBytes, + storageTrieSizeBytesInit: s.storageTrieSizeBytesInit, } if s.trie != nil { obj.trie = mustCopyTrie(s.trie) diff --git a/core/state/statedb.go b/core/state/statedb.go index 607a479199..ba6e512717 100644 --- a/core/state/statedb.go +++ b/core/state/statedb.go @@ -742,6 +742,18 @@ func (s *StateDB) GetCommittedState(addr common.Address, hash common.Hash) commo }) } +// StorageTrieSize walks the storage trie of the provided account and returns an +// approximate size metric based on the sum of standalone trie node blobs +// (NodeBlob sizes). If the trie cannot be accessed (e.g. missing nodes), the +// size is reported as unavailable (false). +func (s *StateDB) StorageTrieSize(addr common.Address) (uint64, bool) { + stateObject := s.getStateObject(addr) + if stateObject == nil { + return 0, false + } + return stateObject.storageTrieSize() +} + // Database retrieves the low level database supporting the lower level trie ops. func (s *StateDB) Database() Database { return s.db diff --git a/core/state/statedb_hooked.go b/core/state/statedb_hooked.go index 7b5a44a08c..f4fd71af82 100644 --- a/core/state/statedb_hooked.go +++ b/core/state/statedb_hooked.go @@ -97,6 +97,10 @@ func (s *hookedStateDB) GetStorageRoot(addr common.Address) common.Hash { return s.inner.GetStorageRoot(addr) } +func (s *hookedStateDB) StorageTrieSize(addr common.Address) (uint64, bool) { + return s.inner.StorageTrieSize(addr) +} + func (s *hookedStateDB) GetTransientState(addr common.Address, key common.Hash) common.Hash { return s.inner.GetTransientState(addr, key) } diff --git a/core/vm/evm.go b/core/vm/evm.go index c526d4552c..9f0ef1e7e4 100644 --- a/core/vm/evm.go +++ b/core/vm/evm.go @@ -303,12 +303,17 @@ func (evm *EVM) CallCode(caller common.Address, addr common.Address, input []byt if p, isPrecompile := evm.precompile(addr); isPrecompile { ret, gas, err = RunPrecompiledContract(p, input, gas, evm.Config.Tracer) } 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, caller, value, gas, evm.jumpDests) - contract.SetCallCode(evm.resolveCodeHash(addr), evm.resolveCode(addr)) - ret, err = evm.interpreter.Run(contract, input, false, nil) - gas = contract.Gas + code := evm.resolveCode(addr) + if len(code) == 0 { + ret, err = nil, nil + } 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, caller, value, gas, evm.jumpDests) + contract.SetCallCode(evm.resolveCodeHash(addr), code) + ret, err = evm.interpreter.Run(contract, input, false, nil) + gas = contract.Gas + } } if err != nil { @@ -349,13 +354,18 @@ func (evm *EVM) DelegateCall(originCaller common.Address, caller common.Address, if p, isPrecompile := evm.precompile(addr); isPrecompile { ret, gas, err = RunPrecompiledContract(p, input, gas, evm.Config.Tracer) } 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.interpreter.Run(contract, input, false, nil) - gas = contract.Gas + code := evm.resolveCode(addr) + if len(code) == 0 { + ret, err = nil, nil + } 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), code) + ret, err = evm.interpreter.Run(contract, input, false, nil) + gas = contract.Gas + } } if err != nil { @@ -406,14 +416,19 @@ func (evm *EVM) StaticCall(caller common.Address, addr common.Address, input []b } 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.interpreter.Run(contract, input, true, nil) - gas = contract.Gas + code := evm.resolveCode(addr) + if len(code) == 0 { + ret, err = nil, nil + } else { + contract := NewContract(caller, addr, new(uint256.Int), gas, evm.jumpDests) + contract.SetCallCode(evm.resolveCodeHash(addr), code) + + // 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.interpreter.Run(contract, input, true, nil) + gas = contract.Gas + } } if err != nil { diff --git a/core/vm/gas_table.go b/core/vm/gas_table.go index f93af2ce35..32e576cb6e 100644 --- a/core/vm/gas_table.go +++ b/core/vm/gas_table.go @@ -104,6 +104,8 @@ func gasSStore(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySi y, x = stack.Back(1), stack.Back(0) current = evm.StateDB.GetState(contract.Address(), x.Bytes32()) ) + // Optional extra gas based on storage trie size (disabled by default). + extra := chargeStorageTrieGas(evm, contract.Address()) // The legacy gas metering only takes into consideration the current state // Legacy rules should be applied if we are in Petersburg (removal of EIP-1283) // OR Constantinople is not active @@ -115,12 +117,12 @@ func gasSStore(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySi // 3. From a non-zero to a non-zero (CHANGE) switch { case current == (common.Hash{}) && y.Sign() != 0: // 0 => non 0 - return params.SstoreSetGas, nil + return params.SstoreSetGas + extra, nil case current != (common.Hash{}) && y.Sign() == 0: // non 0 => 0 evm.StateDB.AddRefund(params.SstoreRefundGas) - return params.SstoreClearGas, nil + return params.SstoreClearGas + extra, nil default: // non 0 => non 0 (or 0 => 0) - return params.SstoreResetGas, nil + return params.SstoreResetGas + extra, nil } } @@ -140,20 +142,20 @@ func gasSStore(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySi // (2.2.2.2.) Otherwise, add 4800 gas to refund counter. value := common.Hash(y.Bytes32()) if current == value { // noop (1) - return params.NetSstoreNoopGas, nil + return params.NetSstoreNoopGas + extra, nil } original := evm.StateDB.GetCommittedState(contract.Address(), x.Bytes32()) if original == current { if original == (common.Hash{}) { // create slot (2.1.1) - return params.NetSstoreInitGas, nil + return params.NetSstoreInitGas + extra, nil } if value == (common.Hash{}) { // delete slot (2.1.2b) evm.StateDB.AddRefund(params.NetSstoreClearRefund) } - return params.NetSstoreCleanGas, nil // write existing slot (2.1.2) + return params.NetSstoreCleanGas + extra, nil // write existing slot (2.1.2) } if original != (common.Hash{}) { @@ -172,7 +174,7 @@ func gasSStore(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySi } } - return params.NetSstoreDirtyGas, nil + return params.NetSstoreDirtyGas + extra, nil } // Here come the EIP2200 rules: @@ -200,24 +202,26 @@ func gasSStoreEIP2200(evm *EVM, contract *Contract, stack *Stack, mem *Memory, m y, x = stack.Back(1), stack.Back(0) current = evm.StateDB.GetState(contract.Address(), x.Bytes32()) ) + // Optional extra gas based on storage trie size (disabled by default). + extra := chargeStorageTrieGas(evm, contract.Address()) value := common.Hash(y.Bytes32()) if current == value { // noop (1) - return params.SloadGasEIP2200, nil + return params.SloadGasEIP2200 + extra, nil } original := evm.StateDB.GetCommittedState(contract.Address(), x.Bytes32()) if original == current { if original == (common.Hash{}) { // create slot (2.1.1) - return params.SstoreSetGasEIP2200, nil + return params.SstoreSetGasEIP2200 + extra, nil } if value == (common.Hash{}) { // delete slot (2.1.2b) evm.StateDB.AddRefund(params.SstoreClearsScheduleRefundEIP2200) } - return params.SstoreResetGasEIP2200, nil // write existing slot (2.1.2) + return params.SstoreResetGasEIP2200 + extra, nil // write existing slot (2.1.2) } if original != (common.Hash{}) { @@ -236,7 +240,7 @@ func gasSStoreEIP2200(evm *EVM, contract *Contract, stack *Stack, mem *Memory, m } } - return params.SloadGasEIP2200, nil // dirty update (2.2) + return params.SloadGasEIP2200 + extra, nil // dirty update (2.2) } func makeGasLog(n uint64) gasFunc { diff --git a/core/vm/interface.go b/core/vm/interface.go index b068abaa0f..66ba9c559a 100644 --- a/core/vm/interface.go +++ b/core/vm/interface.go @@ -55,6 +55,11 @@ type StateDB interface { GetState(common.Address, common.Hash) common.Hash SetState(common.Address, common.Hash, common.Hash) common.Hash GetStorageRoot(addr common.Address) common.Hash + // StorageTrieSize returns an implementation-defined metric for the storage trie + // size of an account. When available, it should be based on stored trie node + // blobs (sum of NodeBlob sizes) to reflect database footprint rather than + // leaf payload size. + StorageTrieSize(addr common.Address) (uint64, bool) GetTransientState(addr common.Address, key common.Hash) common.Hash SetTransientState(addr common.Address, key, value common.Hash) diff --git a/core/vm/operations_acl.go b/core/vm/operations_acl.go index cc16784bfa..3fc013a110 100644 --- a/core/vm/operations_acl.go +++ b/core/vm/operations_acl.go @@ -18,6 +18,7 @@ package vm import ( "errors" + "math/bits" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/math" @@ -26,12 +27,49 @@ import ( "github.com/ethereum/go-ethereum/params" ) +// Storage-trie gas charging parameters. +// +// These are consensus-affecting; keep disabled (0) unless activated by fork. +// The metric used is StateDB.StorageTrieSize which is NodeBlob-based bytes. +const ( + // storageTrieLogStepGas is the gas charged per logarithmic step. + // 0 disables trie-size charging. + storageTrieLogStepGas uint64 = 1 + + // storageTrieFreeBytes is the size (in NodeBlob-bytes) below which no extra + // gas is charged. + storageTrieFreeBytes uint64 = 256 * 1024 +) + +// chargeStorageTrieGas returns additional gas to charge based on the storage +// trie's NodeBlob-byte size. +// +// The caller is expected to add the returned value to the opcode gas. +func chargeStorageTrieGas(evm *EVM, storageOwner common.Address) uint64 { + if storageTrieLogStepGas == 0 { + return 0 + } + size, ok := evm.StateDB.StorageTrieSize(storageOwner) + if !ok || size <= storageTrieFreeBytes { + return 0 + } + ratio := size / storageTrieFreeBytes + if ratio == 0 { + return 0 + } + // floor(log2(ratio)) via bit length. + steps := uint64(bits.Len64(ratio) - 1) + return steps * storageTrieLogStepGas +} + func makeGasSStoreFunc(clearingRefund uint64) gasFunc { return func(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (uint64, error) { // If we fail the minimum gas availability invariant, fail (0) if contract.Gas <= params.SstoreSentryGasEIP2200 { return 0, errors.New("not enough gas for reentrancy sentry") } + // Optional extra gas based on storage trie size (disabled by default). + extra := chargeStorageTrieGas(evm, contract.Address()) // Gas sentry honoured, do the actual gas calculation based on the stored value var ( y, x = stack.Back(1), stack.peek() @@ -51,13 +89,13 @@ func makeGasSStoreFunc(clearingRefund uint64) gasFunc { if current == value { // noop (1) // EIP 2200 original clause: // return params.SloadGasEIP2200, nil - return cost + params.WarmStorageReadCostEIP2929, nil // SLOAD_GAS + return cost + params.WarmStorageReadCostEIP2929 + extra, nil // SLOAD_GAS } original := evm.StateDB.GetCommittedState(contract.Address(), x.Bytes32()) if original == current { if original == (common.Hash{}) { // create slot (2.1.1) - return cost + params.SstoreSetGasEIP2200, nil + return cost + params.SstoreSetGasEIP2200 + extra, nil } if value == (common.Hash{}) { // delete slot (2.1.2b) @@ -65,7 +103,7 @@ func makeGasSStoreFunc(clearingRefund uint64) gasFunc { } // EIP-2200 original clause: // return params.SstoreResetGasEIP2200, nil // write existing slot (2.1.2) - return cost + (params.SstoreResetGasEIP2200 - params.ColdSloadCostEIP2929), nil // write existing slot (2.1.2) + return cost + (params.SstoreResetGasEIP2200 - params.ColdSloadCostEIP2929) + extra, nil // write existing slot (2.1.2) } if original != (common.Hash{}) { @@ -92,7 +130,7 @@ func makeGasSStoreFunc(clearingRefund uint64) gasFunc { } // EIP-2200 original clause: //return params.SloadGasEIP2200, nil // dirty update (2.2) - return cost + params.WarmStorageReadCostEIP2929, nil // dirty update (2.2) + return cost + params.WarmStorageReadCostEIP2929 + extra, nil // dirty update (2.2) } } @@ -104,15 +142,17 @@ func makeGasSStoreFunc(clearingRefund uint64) gasFunc { func gasSLoadEIP2929(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (uint64, error) { loc := stack.peek() slot := common.Hash(loc.Bytes32()) + // Optional extra gas based on storage trie size (disabled by default). + extra := chargeStorageTrieGas(evm, contract.Address()) // Check slot presence in the access list if _, slotPresent := evm.StateDB.SlotInAccessList(contract.Address(), slot); !slotPresent { // If the caller cannot afford the cost, this change will be rolled back // If he does afford it, we can skip checking the same thing later on, during execution evm.StateDB.AddSlotToAccessList(contract.Address(), slot) - return params.ColdSloadCostEIP2929, nil + return params.ColdSloadCostEIP2929 + extra, nil } - return params.WarmStorageReadCostEIP2929, nil + return params.WarmStorageReadCostEIP2929 + extra, nil } // gasExtCodeCopyEIP2929 implements extcodecopy according to EIP-2929 From 174235233f9eb976d44febdd5b08730990f88950 Mon Sep 17 00:00:00 2001 From: Angel Valkov Date: Wed, 7 Jan 2026 15:53:14 +0200 Subject: [PATCH 2/2] Refactor --- core/vm/evm.go | 59 ++++++++++++++++++-------------------------------- 1 file changed, 21 insertions(+), 38 deletions(-) diff --git a/core/vm/evm.go b/core/vm/evm.go index 9f0ef1e7e4..555d21967a 100644 --- a/core/vm/evm.go +++ b/core/vm/evm.go @@ -303,17 +303,12 @@ func (evm *EVM) CallCode(caller common.Address, addr common.Address, input []byt if p, isPrecompile := evm.precompile(addr); isPrecompile { ret, gas, err = RunPrecompiledContract(p, input, gas, evm.Config.Tracer) } else { - code := evm.resolveCode(addr) - if len(code) == 0 { - ret, err = nil, nil - } 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, caller, value, gas, evm.jumpDests) - contract.SetCallCode(evm.resolveCodeHash(addr), code) - ret, err = evm.interpreter.Run(contract, input, false, nil) - gas = contract.Gas - } + // 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, caller, value, gas, evm.jumpDests) + contract.SetCallCode(evm.resolveCodeHash(addr), evm.resolveCode(addr)) + ret, err = evm.interpreter.Run(contract, input, false, nil) + gas = contract.Gas } if err != nil { @@ -354,18 +349,13 @@ func (evm *EVM) DelegateCall(originCaller common.Address, caller common.Address, if p, isPrecompile := evm.precompile(addr); isPrecompile { ret, gas, err = RunPrecompiledContract(p, input, gas, evm.Config.Tracer) } else { - code := evm.resolveCode(addr) - if len(code) == 0 { - ret, err = nil, nil - } 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), code) - ret, err = evm.interpreter.Run(contract, input, false, nil) - gas = contract.Gas - } + // 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.interpreter.Run(contract, input, false, nil) + gas = contract.Gas } if err != nil { @@ -414,21 +404,14 @@ func (evm *EVM) StaticCall(caller common.Address, addr common.Address, input []b if p, isPrecompile := evm.precompile(addr); isPrecompile { ret, gas, err = RunPrecompiledContract(p, input, gas, evm.Config.Tracer) } 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. - code := evm.resolveCode(addr) - if len(code) == 0 { - ret, err = nil, nil - } else { - contract := NewContract(caller, addr, new(uint256.Int), gas, evm.jumpDests) - contract.SetCallCode(evm.resolveCodeHash(addr), code) - - // 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.interpreter.Run(contract, input, true, nil) - gas = contract.Gas - } + 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.interpreter.Run(contract, input, true, nil) + gas = contract.Gas } if err != nil {