core/vm: implement eip-8279: Block Access List Byte Floor

This commit is contained in:
MariusVanDerWijden 2026-06-17 10:37:05 +02:00
parent 9e04883a85
commit 56408777f0
No known key found for this signature in database
9 changed files with 207 additions and 17 deletions

View file

@ -147,7 +147,7 @@ func Transaction(ctx *cli.Context) error {
}
// For Prague txs, validate the floor data gas.
if rules.IsPrague {
floorDataGas, err := core.FloorDataGas(rules, tx.Data(), tx.AccessList())
floorDataGas, err := core.FloorDataGas(rules, tx.Data(), tx.AccessList(), uint64(len(tx.SetCodeAuthorizations())))
if err != nil {
r.Error = err
results = append(results, r)

View file

@ -151,8 +151,11 @@ func IntrinsicGas(data []byte, accessList types.AccessList, authList []types.Set
return gas, nil
}
// FloorDataGas computes the minimum gas required for a transaction based on its data tokens (EIP-7623).
func FloorDataGas(rules params.Rules, data []byte, accessList types.AccessList) (uint64, error) {
// FloorDataGas computes the minimum gas required for a transaction based on its
// data tokens (EIP-7623). On Amsterdam it also includes the EIP-8131 per-auth
// tx-content floor and the EIP-8279 per-auth Block Access List floor, which
// together form the static floor seed extended at runtime by EIP-8279.
func FloorDataGas(rules params.Rules, data []byte, accessList types.AccessList, numAuths uint64) (uint64, error) {
var (
tokens uint64
tokenCost uint64
@ -203,7 +206,23 @@ func FloorDataGas(rules params.Rules, data []byte, accessList types.AccessList)
return 0, ErrGasUintOverflow
}
// Minimum gas required for a transaction based on its data tokens (EIP-7623).
return params.TxGas + tokens*tokenCost, nil
floor := params.TxGas + tokens*tokenCost
// EIP-8131 / EIP-8279: each EIP-7702 authorization contributes a static
// per-auth floor. EIP-8131 prices the 101-byte authorization tuple
// (FloorCostPerAuth) and EIP-8279 adds the worst-case BAL bytes the auth
// writes when applied (BALBytesPerAuthorization at FloorGasPerByte). The
// per-auth BAL term is folded into the static floor because set_delegation
// runs outside the EVM's out-of-gas handler and cannot extend the floor at
// runtime.
if rules.IsAmsterdam && numAuths > 0 {
const perAuth = params.FloorCostPerAuth + params.BALBytesPerAuthorization*params.FloorGasPerByte
if (math.MaxUint64-floor)/perAuth < numAuths {
return 0, ErrGasUintOverflow
}
floor += numAuths * perAuth
}
return floor, nil
}
// toWordSize returns the ceiled word size required for init code payment calculation.
@ -355,6 +374,11 @@ type stateTransition struct {
initReservoir uint64 // initial state-gas reservoir carved out of GasLimit (EIP-8037)
state vm.StateDB
evm *vm.EVM
// floorGas is the EIP-8279 Block Access List floor accumulator, seeded with
// the static floor and extended at runtime via the EVM. It is nil before
// Amsterdam. settleGas reads its final value to apply the receipt floor.
floorGas *vm.FloorGasAccumulator
}
// newStateTransition initialises and returns a new state transition object.
@ -645,7 +669,7 @@ func (st *stateTransition) execute() (*ExecutionResult, error) {
// 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)
floorDataGas, err = FloorDataGas(rules, msg.Data, msg.AccessList, uint64(len(msg.SetCodeAuthorizations)))
if err != nil {
return nil, err
}
@ -661,6 +685,15 @@ func (st *stateTransition) execute() (*ExecutionResult, error) {
}
}
// EIP-8279: seed the per-transaction Block Access List floor accumulator
// with the static floor and bound it by the transaction gas limit. The
// accumulator is extended at runtime as opcodes contribute BAL bytes; at
// settlement the receipt gas becomes max(execution_gas, floor_gas_used).
if rules.IsAmsterdam {
st.floorGas = vm.NewFloorGasAccumulator(floorDataGas, msg.GasLimit)
st.evm.SetFloorGas(st.floorGas)
}
if rules.IsEIP4762 {
st.evm.AccessEvents.AddTxOrigin(msg.From)
@ -837,25 +870,34 @@ func (st *stateTransition) settleGas(rules params.Rules, floorDataGas uint64) (g
gasLeft += refund
gasUsed = gasUsedBeforeRefund - refund
// EIP-7623: tx_gas_used = max(tx_gas_used_after_refund, calldata_floor).
// EIP-8279: the effective floor is the runtime accumulator (seeded with the
// static floorDataGas and extended by the BAL bytes opcodes contributed). It
// is always >= floorDataGas when the accumulator is active (Amsterdam); the
// max keeps the pre-Amsterdam / accumulator-less path on the static floor.
floorGas := floorDataGas
if st.floorGas != nil {
floorGas = max(floorGas, st.floorGas.FloorGasUsed())
}
// EIP-7623: tx_gas_used = max(tx_gas_used_after_refund, floor).
peakUsed = gasUsedBeforeRefund
if rules.IsPrague && gasUsed < floorDataGas {
diff := floorDataGas - gasUsed
if rules.IsPrague && gasUsed < floorGas {
diff := floorGas - gasUsed
if st.evm.Config.Tracer.HasGasHook() {
st.evm.Config.Tracer.EmitGasChange(tracing.Gas{Regular: gasLeft}, tracing.Gas{Regular: gasLeft - diff}, tracing.GasChangeTxDataFloor)
}
gasLeft -= diff
gasUsed = floorDataGas
peakUsed = max(peakUsed, floorDataGas)
gasUsed = floorGas
peakUsed = max(peakUsed, floorGas)
}
if rules.IsAmsterdam {
// EIP-7623/7976: the calldata floor applies to the block-level regular
// gas dimension as well, mirroring its effect on the receipt gas. The
// spec accumulates max(tx_regular_gas, calldata_floor) into
// block_gas_used, so the block must never count fewer regular units
// than the floor the sender was charged.
blockRegularGas := max(txRegularGas, floorDataGas)
// spec accumulates max(tx_regular_gas, floor) into block_gas_used, so the
// block must never count fewer regular units than the floor the sender
// was charged. EIP-8279 widens the floor to include BAL byte costs.
blockRegularGas := max(txRegularGas, floorGas)
if err = st.gp.ChargeGasAmsterdam(blockRegularGas, txStateGas, gasUsed); err != nil {
return 0, 0, err
}

View file

@ -123,7 +123,7 @@ func TestFloorDataGas(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rules := params.Rules{IsAmsterdam: tt.amsterdam}
got, err := FloorDataGas(rules, tt.data, tt.accessList)
got, err := FloorDataGas(rules, tt.data, tt.accessList, 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

View file

@ -134,7 +134,7 @@ func ValidateTransaction(tx *types.Transaction, head *types.Header, signer types
}
// Ensure the transaction can cover floor data gas.
if rules.IsPrague {
floorDataGas, err := core.FloorDataGas(rules, tx.Data(), tx.AccessList())
floorDataGas, err := core.FloorDataGas(rules, tx.Data(), tx.AccessList(), uint64(len(tx.SetCodeAuthorizations())))
if err != nil {
return err
}

View file

@ -131,6 +131,13 @@ type EVM struct {
returnData []byte // Last CALL's return data for subsequent reuse
arena *stackArena
// floorGas is the per-transaction EIP-8279 Block Access List byte-floor
// accumulator. It is set by the state transition at the start of each
// transaction and extended at runtime as opcodes contribute BAL bytes. It
// is nil before EIP-8279 (Amsterdam) or in contexts without BAL
// construction, in which case the runtime extensions are no-ops.
floorGas *FloorGasAccumulator
}
// NewEVM constructs an EVM instance with the supplied block context, state
@ -222,6 +229,26 @@ func (evm *EVM) SetTxContext(txCtx TxContext) {
evm.TxContext = txCtx
}
// SetFloorGas installs the per-transaction EIP-8279 floor accumulator. It is
// called by the state transition once the static floor seed and gas limit are
// known. Passing nil disables runtime floor extensions for the transaction.
func (evm *EVM) SetFloorGas(acc *FloorGasAccumulator) {
evm.floorGas = acc
}
// FloorGas returns the active EIP-8279 floor accumulator, or nil if none is set.
func (evm *EVM) FloorGas() *FloorGasAccumulator {
return evm.floorGas
}
// extendFloor extends the EIP-8279 floor accumulator by numBytes BAL bytes. It
// is a no-op when no accumulator is installed (pre-Amsterdam or BAL-less
// contexts). The returned error, when non-nil, is ErrOutOfGas and MUST abort
// the operation before the matching BAL byte is inserted.
func (evm *EVM) extendFloor(numBytes uint64) error {
return evm.floorGas.extendFloor(numBytes)
}
// Cancel cancels any running EVM operation. This may be called concurrently and
// it's safe to be called multiple times.
func (evm *EVM) Cancel() {
@ -524,6 +551,15 @@ func (evm *EVM) create(caller common.Address, code []byte, gas GasBudget, value
}
}
// EIP-8279: an opcode-level CREATE/CREATE2 records the deployed address in
// the BAL. The top-level creation transaction's contract address is part of
// the implicit per-tx bytes covered by TX_BASE headroom, so only nested
// creations extend the floor here.
if evm.depth > 0 {
if err = evm.extendFloor(params.BALBytesPerAddress); err != nil {
return nil, common.Address{}, gas.ExitHalt(), err
}
}
// We add this to the access list _before_ taking a snapshot. Even if the
// creation fails, the access-list change should not be rolled back.
if evm.chainRules.IsEIP2929 {
@ -562,6 +598,21 @@ func (evm *EVM) create(caller common.Address, code []byte, gas GasBudget, value
// acts inside that account.
evm.StateDB.CreateContract(address)
// EIP-8279: an opcode-level CREATE/CREATE2 sets the new contract's nonce
// (and, with a non-zero endowment, its balance), both recorded in the BAL.
// The floor is extended after the collision check, before the state
// mutation. The top-level creation transaction's nonce is covered by
// TX_BASE headroom, so only nested creations extend the floor here.
if evm.depth > 0 {
if err = evm.extendFloor(params.BALBytesPerNonce); err != nil {
return nil, common.Address{}, gas.ExitHalt(), err
}
if !value.IsZero() {
if err = evm.extendFloor(params.BALBytesPerBalance); err != nil {
return nil, common.Address{}, gas.ExitHalt(), err
}
}
}
if evm.chainRules.IsEIP158 {
evm.StateDB.SetNonce(address, 1, tracing.NonceChangeNewContract)
}
@ -668,6 +719,16 @@ func (evm *EVM) initNewContract(contract *Contract, address common.Address) (ret
return ret, true, err
}
}
// EIP-8279: a successful opcode-level CREATE/CREATE2 deploy records the
// deployed code in the BAL. Extend the floor by the code length before
// set_code. A top-level creation transaction's deployed code is bounded by
// the calldata floor on its init code, so only nested creations extend the
// floor here.
if evm.depth > 0 && len(ret) > 0 {
if err := evm.extendFloor(uint64(len(ret))); err != nil {
return ret, true, err
}
}
if len(ret) > 0 {
evm.StateDB.SetCode(address, ret, tracing.CodeChangeContractCreation)
}

View file

@ -504,6 +504,15 @@ func gasCallIntrinsic(evm *EVM, contract *Contract, stack *Stack, mem *Memory, m
}
// Stateful check
if evm.chainRules.IsAmsterdam {
// EIP-8279: a CALL transferring non-zero value to a different account
// records a balance change for the recipient in the BAL. Extend the
// floor by the balance bytes before the transfer. A self-call moves no
// value out of the executing account and adds no balance bytes.
if transfersValue && address != contract.Address() {
if err := evm.extendFloor(params.BALBytesPerBalance); err != nil {
return 0, err
}
}
// EIP-8037: the cost of creating a new account via a value-bearing
// CALL is metered as state gas (NEW_ACCOUNT * CostPerStateByte),
// not the legacy regular CallNewAccountGas. It drains the state
@ -604,6 +613,10 @@ func gasSelfdestruct8037(evm *EVM, contract *Contract, stack *Stack, mem *Memory
address = common.Address(stack.peek().Bytes20())
)
if !evm.StateDB.AddressInAccessList(address) {
// EIP-8279: a cold beneficiary access adds the address to the BAL.
if err := evm.extendFloor(params.BALBytesPerAddress); err != nil {
return GasCosts{}, err
}
// If the caller cannot afford the cost, this change will be rolled back
evm.StateDB.AddAddressToAccessList(address)
gas.RegularGas = params.ColdAccountAccessCostEIP2929
@ -612,6 +625,14 @@ func gasSelfdestruct8037(evm *EVM, contract *Contract, stack *Stack, mem *Memory
if contract.Gas.RegularGas < gas.RegularGas {
return gas, ErrOutOfGas
}
// EIP-8279: SELFDESTRUCT moving a non-zero balance to a different beneficiary
// records the beneficiary's balance change in the BAL. A self-targeted
// SELFDESTRUCT moves no value out and adds no balance bytes.
if address != contract.Address() && evm.StateDB.GetBalance(contract.Address()).Sign() != 0 {
if err := evm.extendFloor(params.BALBytesPerBalance); err != nil {
return GasCosts{}, err
}
}
// Important: use StateDB.Empty instead of !StateDB.Exist. An account may exist
// in the current state yet still be considered non-existent by EIP-161 if its
// nonce, balance, and code are all zero. Such accounts can appear temporarily
@ -641,12 +662,29 @@ func gasSStore8037(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memo
)
// Check slot presence in the access list
if _, slotPresent := evm.StateDB.SlotInAccessList(contract.Address(), slot); !slotPresent {
// EIP-8279: a cold SSTORE adds the storage key to the BAL. Extend the
// floor before the slot is recorded; an out-of-gas here aborts the
// opcode before the unpaid BAL byte exists.
if err := evm.extendFloor(params.BALBytesPerStorageKey); err != nil {
return GasCosts{}, err
}
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())
// EIP-8279: an SSTORE that changes the slot's current value contributes a
// post-value to the BAL. Charge the value bytes whenever the value differs,
// mirroring the BAL StorageWrite. This may over-charge when the same slot is
// written more than once in a transaction (the BAL records only one final
// post-value per slot), which is safe: it never under-charges.
if current != value {
if err := evm.extendFloor(params.BALBytesPerStorageValue); err != nil {
return GasCosts{}, err
}
}
if current == value { // noop (1)
// EIP 2200 original clause:
// return params.SloadGasEIP2200, nil

View file

@ -103,6 +103,12 @@ func gasSLoadEIP2929(evm *EVM, contract *Contract, stack *Stack, mem *Memory, me
slot := common.Hash(loc.Bytes32())
// Check slot presence in the access list
if _, slotPresent := evm.StateDB.SlotInAccessList(contract.Address(), slot); !slotPresent {
// EIP-8279: a cold SLOAD adds the storage key to the BAL. Extend the
// floor before the slot is recorded; an out-of-gas here aborts the
// opcode before the unpaid BAL byte exists.
if err := evm.extendFloor(params.BALBytesPerStorageKey); err != nil {
return GasCosts{}, err
}
// 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)
@ -126,6 +132,10 @@ func gasExtCodeCopyEIP2929(evm *EVM, contract *Contract, stack *Stack, mem *Memo
addr := common.Address(stack.peek().Bytes20())
// Check slot presence in the access list
if !evm.StateDB.AddressInAccessList(addr) {
// EIP-8279: a cold account access adds the address to the BAL.
if err := evm.extendFloor(params.BALBytesPerAddress); err != nil {
return GasCosts{}, err
}
evm.StateDB.AddAddressToAccessList(addr)
var overflow bool
// We charge (cold-warm), since 'warm' is already charged as constantGas
@ -148,6 +158,10 @@ func gasEip2929AccountCheck(evm *EVM, contract *Contract, stack *Stack, mem *Mem
addr := common.Address(stack.peek().Bytes20())
// Check slot presence in the access list
if !evm.StateDB.AddressInAccessList(addr) {
// EIP-8279: a cold account access adds the address to the BAL.
if err := evm.extendFloor(params.BALBytesPerAddress); err != nil {
return GasCosts{}, err
}
// If the caller cannot afford the cost, this change will be rolled back
evm.StateDB.AddAddressToAccessList(addr)
// The warm storage read cost is already charged as constantGas
@ -165,6 +179,10 @@ func makeCallVariantGasCallEIP2929(oldCalculator gasFunc, addressPosition int) g
// the cost to charge for cold access, if any, is Cold - Warm
coldCost := params.ColdAccountAccessCostEIP2929 - params.WarmStorageReadCostEIP2929
if !warmAccess {
// EIP-8279: a cold account access adds the address to the BAL.
if err := evm.extendFloor(params.BALBytesPerAddress); err != nil {
return GasCosts{}, err
}
evm.StateDB.AddAddressToAccessList(addr)
// Charge the remaining difference here already, to correctly calculate available
// gas for call
@ -286,6 +304,10 @@ func makeCallVariantGasCallEIP7702(intrinsicFunc intrinsicGasFunc) gasFunc {
// Perform EIP-2929 checks (stateless), checking address presence
// in the accessList and charge the cold access accordingly.
if !evm.StateDB.AddressInAccessList(addr) {
// EIP-8279: a cold account access adds the address to the BAL.
if err := evm.extendFloor(params.BALBytesPerAddress); err != nil {
return GasCosts{}, err
}
evm.StateDB.AddAddressToAccessList(addr)
// The WarmStorageReadCostEIP2929 (100) is already deducted in the form
@ -321,6 +343,11 @@ func makeCallVariantGasCallEIP7702(intrinsicFunc intrinsicGasFunc) gasFunc {
if evm.StateDB.AddressInAccessList(target) {
eip7702Cost = params.WarmStorageReadCostEIP2929
} else {
// EIP-8279: resolving a cold delegation target adds its address
// to the BAL.
if err := evm.extendFloor(params.BALBytesPerAddress); err != nil {
return GasCosts{}, err
}
evm.StateDB.AddAddressToAccessList(target)
eip7702Cost = params.ColdAccountAccessCostEIP2929
}

View file

@ -103,6 +103,28 @@ const (
TxAuthTupleGas uint64 = 12500 // Per auth tuple code specified in EIP-7702
TxAuthTupleRegularGas uint64 = 7500 // Per auth tuple regular gas specified in EIP-8037
// FloorCostPerAuth is the per-authorization tx-content floor contribution
// defined by EIP-8131: an EIP-7702 authorization tuple is 101 bytes, charged
// at the floor rate (101 * TxCostFloorPerToken7976 * TxTokenPerNonZeroByte).
FloorCostPerAuth uint64 = 101 * TxCostFloorPerToken7976 * TxTokenPerNonZeroByte // 6464
// EIP-8279: Block Access List Byte Floor. Each byte an opcode adds to the
// EIP-7928 Block Access List extends the transaction's floor accumulator by
// FloorGasPerByte gas, charged at runtime before the BAL grows.
FloorGasPerByte uint64 = TxCostFloorPerToken7976 * TxTokenPerNonZeroByte // 64: per-byte floor rate (EIP-7976)
BALBytesPerAddress uint64 = 20 // BAL bytes for an account address
BALBytesPerStorageKey uint64 = 32 // BAL bytes for a storage key
BALBytesPerStorageValue uint64 = 32 // BAL bytes for a storage post-value
BALBytesPerBalance uint64 = 32 // BAL bytes for a balance change
BALBytesPerNonce uint64 = 8 // BAL bytes for a nonce change
BALDelegationCodeBytes uint64 = 23 // EIP-7702 delegation marker length
// BALBytesPerAuthorization is the worst-case BAL contribution an EIP-7702
// authorization adds when it is applied: the authority address, the
// delegation marker written to its code, and its nonce change. It is folded
// into the static floor seed since set_delegation runs outside the EVM's
// out-of-gas handler.
BALBytesPerAuthorization uint64 = BALBytesPerAddress + BALDelegationCodeBytes + BALBytesPerNonce // 51
// These have been changed during the course of the chain
CallGasFrontier uint64 = 40 // Once per CALL operation & message call transaction.
CallGasEIP150 uint64 = 700 // Static portion of gas for CALL-derivates after EIP 150 (Tangerine)

View file

@ -92,7 +92,7 @@ func (tt *TransactionTest) Run() error {
if rules.IsPrague {
var floorDataGas uint64
floorDataGas, err = core.FloorDataGas(rules, tx.Data(), tx.AccessList())
floorDataGas, err = core.FloorDataGas(rules, tx.Data(), tx.AccessList(), uint64(len(tx.SetCodeAuthorizations())))
if err != nil {
return
}