--- eip: 8279 title: Block Access List Byte Floor description: Meter EIP-7928 Block Access List bytes at runtime and fold them into the EIP-7623 transaction floor, capping worst-case block size. author: Toni Wahrstätter (@nerolation) discussions-to: https://ethereum-magicians.org/t/eip-8279-block-access-list-byte-floor/28662 status: Draft type: Standards Track category: Core created: 2026-05-23 requires: 7623, 7702, 7825, 7928, 7976, 7981, 8131 --- ## Abstract Meter the bytes execution adds to the [EIP-7928](./eip-7928.md) Block Access List in an explicit per-transaction counter, and fold that count, at 64 gas/byte, into the transaction's floor accumulator — checked at runtime before the BAL grows. Today an attacker can pack ~1.55 MiB into a 60M-gas block: 75% of gas on cold `SLOAD`s (32 BAL bytes per 2,100 gas) + 25% on calldata at 16 gas/byte. On top of [EIP-8131](./eip-8131.md)'s tx-content floor (same rate), block content is capped at `block_gas_limit / 64 ≈ 0.89 MiB`, a ~42% cut in worst-case block size at an unchanged gas limit. Neither EIP alone closes the bypass: 8131 does not price BAL bytes; 8279 reuses 8131's floor. In the sampled transactions, the runtime BAL component did not bind on its own; it matters mainly when the static floor is already high. ## Motivation Every node must be able to handle the largest block an attacker can build, not the average one. Lowering that worst case buys headroom, which can either be spent on throughput by raising the gas limit or kept as a buffer. Left unspent, it is a direct gain in robustness. [EIP-7623](./eip-7623.md) caps worst-case block size by charging at least 64 gas per non-zero calldata byte. [EIP-7981](./eip-7981.md) extended that to access-list entries, and [EIP-8131](./eip-8131.md) generalises the floor to a uniform per-byte rule over all tx-content fields, including [EIP-7702](./eip-7702.md) authorization tuples and [EIP-4844](./eip-4844.md) blob versioned hashes. [EIP-7928](./eip-7928.md) introduces a new source of block bytes, the BAL, populated by runtime opcodes. None of those static floors cover it. The cheapest BAL contributor is a cold `SLOAD`: 32 bytes for 2,100 gas. Combined with all-non-zero calldata, an attacker pushes intrinsic gas above the calldata floor, pays intrinsic, and gets calldata at 16 gas/byte while loading the rest of the block via `SLOAD` keys. At a 60M gas limit: | Attack | Worst-case block bytes | |---|---| | Pure calldata at floor | 0.894 MiB (target) | | Auth-tuple bypass under EIP-8131 alone | 1.067 MiB | | Cold-`SLOAD` bypass under EIP-7928 | **1.548 MiB** | `SSTORE`, address access, `CREATE`, and successful EIP-7702 delegations give further vectors. This EIP closes them with one uniform mechanism. ## Specification The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119) and [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174). ### Constants ``` FLOOR_GAS_PER_BYTE = 64 # per-byte floor rate (EIP-7976) TX_MAX_GAS_LIMIT = 16_777_216 # per-transaction execution-gas cap (EIP-7825) BAL_BYTES_PER_ADDRESS = 20 BAL_BYTES_PER_STORAGE_KEY = 32 BAL_BYTES_PER_STORAGE_VALUE = 32 BAL_BYTES_PER_BALANCE = 32 BAL_BYTES_PER_NONCE = 8 DELEGATION_CODE_BYTES = 23 # EIP-7702 delegation marker length ``` ### Data meter and floor accumulator Clients keep two internal, per-transaction counters and three sets on the execution environment for the duration of the transaction: - `bal_data_bytes` — an explicit count of the bytes execution contributes to the BAL, - `floor_gas_used` — the floor accumulator, derived from the static seed plus the metered bytes, and - `metered_addresses` — the addresses whose 20-byte address has already been metered, - `metered_storage_keys` — the `(storage_address, key)` pairs whose 32-byte storage key has already been metered, and - `metered_storage_values` — the `(storage_address, key)` pairs whose 32-byte storage value has already been metered. None is part of the signed transaction, RLP-encoded, gossiped, or persisted; no new transaction field or type is introduced. `floor_gas_used` is seeded with the static floor (below), `bal_data_bytes = 0`, and all three sets empty. The counters and sets are extended at runtime. No gas is reserved or deducted from the execution budget; the derived floor is checked against the transaction's execution-gas budget, `execution_budget = min(tx.gas, TX_MAX_GAS_LIMIT)`, only to ensure the user can pay it if it ends up binding. ``` def meter_bal_data(tx_env, num_bytes): new_bal_data_bytes = tx_env.bal_data_bytes + num_bytes new_floor = tx_env.static_floor + new_bal_data_bytes * FLOOR_GAS_PER_BYTE if new_floor > tx_env.execution_budget: # min(tx.gas, TX_MAX_GAS_LIMIT) raise OutOfGasError tx_env.bal_data_bytes = new_bal_data_bytes tx_env.floor_gas_used = new_floor def meter_bal_address(tx_env, address): if address in tx_env.metered_addresses: return meter_bal_data(tx_env, BAL_BYTES_PER_ADDRESS) tx_env.metered_addresses.add(address) def meter_bal_storage_key(tx_env, storage_address, key): slot = (storage_address, key) if slot in tx_env.metered_storage_keys: return meter_bal_data(tx_env, BAL_BYTES_PER_STORAGE_KEY) tx_env.metered_storage_keys.add(slot) def meter_bal_storage_value(tx_env, storage_address, key): slot = (storage_address, key) if slot in tx_env.metered_storage_values: return meter_bal_data(tx_env, BAL_BYTES_PER_STORAGE_VALUE) tx_env.metered_storage_values.add(slot) ``` `meter_bal_data` MUST be called BEFORE the matching BAL insertion or state mutation, and after the pre-state execution-gas validation that [EIP-7928](./eip-7928.md) requires for that access has passed. An access that fails its pre-state validation meters nothing, and an `OutOfGasError` at the metering step aborts before any unpaid BAL byte exists. The metered count is a deliberate upper bound on the transaction's actual BAL contribution (see [Reverts](#reverts) and [Repeated writes to the same slot](#repeated-writes-to-the-same-slot)); over-counting only raises the floor, never lowers it, so the bound below is never violated. ### Static floor seed The tx-content portion of the floor (calldata, access-list entries, authorization tuples, blob versioned hashes) is defined by [EIP-8131](./eip-8131.md) as `tx_floor`. This EIP adds the per-auth BAL contribution that arises when an authorization is processed: ``` auth_bal_bytes = BAL_BYTES_PER_ADDRESS # 20: authority address + DELEGATION_CODE_BYTES # 23: delegation marker + BAL_BYTES_PER_NONCE # 8: authority nonce static_floor = tx_floor + FLOOR_GAS_PER_BYTE * auth_bal_bytes * num_authorizations ``` `tx_env.static_floor` holds this value and `tx_env.floor_gas_used` is initialised to it (with `bal_data_bytes = 0`) at the start of execution. The per-auth term covers each authorization's worst-case BAL contribution statically, so `set_delegation` (which runs before the EVM's OOG handler) never meters at runtime. Per-transaction accounting always adds further BAL entries that no opcode places there: sender, coinbase, and top-level target addresses; sender and coinbase balances; sender nonce; recipient balance for a top-level value transfer; top-level creation target nonce; and an optional top-level EIP-7702 delegated target. These total at most 184 bytes, fully covered by the `TX_BASE / FLOOR_GAS_PER_BYTE = 328` bytes of headroom already in `TX_BASE`. ### Runtime data metering Each trigger meters the byte count below before the corresponding BAL entry is added. Address and storage-key touches use `meter_bal_address` and `meter_bal_storage_key`; storage values use `meter_bal_storage_value`; all other rows call `meter_bal_data` directly. These first-touch sets are independent of EVM warmness: an address or storage key pre-warmed by an [EIP-2930](./eip-2930.md) access list is still metered when execution first touches it, because EIP-7928 then adds it to the BAL. | Trigger | Bytes added | |---|---| | First account touch in the transaction (`BALANCE`, `EXT*`, `CALL*`, `SELFDESTRUCT` beneficiary, `CREATE`/`CREATE2` deployed address) | 20 | | First storage-key touch in the transaction (`SLOAD`, `SSTORE`) | 32 | | `SSTORE` with `current == original` and `new != original`, if the `(storage_address, key)` pair has not been metered | +32 | | `CALL` with non-zero value to a different account (caller and recipient balances) | 64 | | `SELFDESTRUCT` with non-zero balance to a different beneficiary (originator and beneficiary balances) | 64 | | `CREATE` / `CREATE2` before incrementing the creator's nonce | 8 | | `CREATE` / `CREATE2` after the collision check (new contract's nonce) | 8 | | `CREATE` / `CREATE2` with non-zero endowment (creator and new contract balances) | +64 | | Successful top-level contract creation, `CREATE`, or `CREATE2` deploy, just before `set_code` | `len(deployed_code)` | EIP-7702 delegations are covered by the static `auth_bytes` term. `CALLCODE`, `DELEGATECALL`, and `STATICCALL` transfer no value out of the executing account and therefore add no balance bytes. The 32 storage-value bytes are metered at most once per slot per transaction. Before mutating storage, every `SSTORE` with `current == original` and `new != original` MUST call `meter_bal_storage_value(tx_env, storage_address, key)`; the helper returns without charging if the pair was already metered. Here, `storage_address` is the account whose storage the current execution context modifies; for `CALLCODE` and `DELEGATECALL`, it is not the account whose code is executing. The `metered_storage_values` set is never rewound, and the charge is not refunded if a later `SSTORE` restores the original value. This can over-count a net-zero write, but keeps `bal_data_bytes` an upper bound without making it part of call-frame rollback: if the first changing write reverts, its retained charge covers any later committed change to the same slot; if a restoring write reverts, the charge still covers the earlier change that reappears in the BAL. ### Final charge ``` tx.gasUsed = max(execution_gas_used, tx_env.floor_gas_used) ``` Transaction validation continues to require `execution_budget >= max(intrinsic, static_floor)`. ### System transactions and withdrawals System-contract calls ([EIP-2935](./eip-2935.md), [EIP-4788](./eip-4788.md), [EIP-7002](./eip-7002.md), [EIP-7251](./eip-7251.md)) and withdrawals do NOT consume from a per-transaction floor. Their BAL bytes are absorbed by the existing EIP-7928 block-level buffer `bal_items <= block_gas_limit / ITEM_COST`. ## Rationale ### Floor side, not intrinsic side Raising execution charges would penalise every user, including ones who never bypass. Charging on the floor only bites when execution is too cheap to cover the floor, which is exactly the bypass case. A pure intrinsic surcharge also fails to cap the block at `gas_limit / 64`: for any added per-`SLOAD` cost `X`, the optimum bypass still yields `B = gas_limit / 64 + 24 · gas_limit / (2100 + X)` bytes, with the residual only vanishing as `X` grows large. Floor-side charging gives a closed bound directly. ### Runtime extensions can bind Runtime contributions are checked against the execution-gas budget because they can make the floor bind even when execution gas would not. Representative cases are: | Trigger | Floor extension | Min execution charge | |---|---:|---:| | First account touch, cold | 1,280 | 2,600 | | First account touch, pre-warmed by [EIP-2930](./eip-2930.md) | 1,280 | 100 | | First storage-key touch, cold `SLOAD` | 2,048 | 2,100 | | First storage-key touch, pre-warmed `SLOAD` | 2,048 | 100 | | `SSTORE` (cold, zero → non-zero) | 4,096 | 22,100 | | `CALL` with non-zero value | 4,096 | 9,000 | | `SELFDESTRUCT` with non-zero balance, beneficiary already metered | 4,096 | 5,000 | | `SELFDESTRUCT` with non-zero balance, warm beneficiary first touched | 5,376 | 5,000 | | Successful `CREATE` / `CREATE2`, no endowment/code (address and two nonces) | 2,304 | 32,000 | | Deployed code (per byte) | 64 | 200 | A pre-warmed [EIP-2930](./eip-2930.md) entry is charged once for its bytes in the transaction and again when execution first touches it and EIP-7928 copies it into the BAL. The warm execution charge can be lower than this second floor contribution. A positive-balance `SELFDESTRUCT` to a warm, not-yet-metered beneficiary likewise adds the 20-byte beneficiary address plus two 32-byte balances: `84 × 64 = 5,376` floor gas against a 5,000 execution-gas charge. The pre-mutation runtime check handles these cases directly. Under [EIP-8037](./eip-8037.md) the code-deposit execution charge drops to the hashing cost of 6 gas per word, under 0.2 gas per byte, while the floor extension stays at 64 per byte, so deployed code becomes the runtime trigger whose floor extension exceeds its execution charge by the widest margin. The main high-volume case remains a static seed defined by [EIP-8131](./eip-8131.md) (calldata, access-list entries, auths, blob hashes) that is already close to the execution-gas budget; that is the calldata + `SLOAD` bypass this EIP targets. ### One per-byte rate Pricing every BAL byte at the same `FLOOR_GAS_PER_BYTE` as non-zero calldata makes the worst case invariant to the attacker's choice: calldata, auths, storage keys, addresses, code, and balances all yield `1/64` bytes per gas at the floor. The optimum collapses to `B = gas_limit / 64`. ### Meter before; never refund EIP-7928 keeps accessed addresses and storage keys in the BAL even when their call frame reverts. Metering each contribution before the insertion guarantees every BAL byte is accounted for, and an out-of-gas at the metering step aborts before the matching insertion. The meter is otherwise a deliberate upper bound, not an exact mirror of the BAL: changes from reverted frames are never rewound (see [Reverts](#reverts)), and storage-value bytes are never refunded. A refund when a slot returns to its pre-transaction value would have to be journaled with call-frame state: if the restoring write later reverted, the earlier value change would reappear in the BAL while its bytes remained refunded. The monotonic `metered_storage_values` set instead charges each slot at most once without entering rollback machinery, preserving the [block-size bound](#block-size-bound) while avoiding repeated charges for the same possible post-value. Without the set, every additional `O → X → O` cycle would add another 2,048 floor gas even though EIP-7928 records at most one post-value for the slot. ### Per-auth coverage is static `set_delegation` runs outside the EVM's exception handler. A runtime `meter_bal_data` raising `OutOfGasError` there would propagate uncaught and crash block processing. Folding the worst-case 51 BAL bytes per auth (on top of the 108-byte tuple priced by EIP-8131, for 159 B total) into the static floor lets `set_delegation` mutate state freely: any transaction whose execution-gas budget cannot cover it has already been rejected at validation. ### Counter, not a reservation The runtime mechanism does not deduct gas from the execution budget or pre-commit any gas. `meter_bal_data` only increments the explicit byte counter (`bal_data_bytes`) and the derived accumulator (`floor_gas_used`), and checks that the floor remains `≤ execution_budget`. Execution gas accounting is untouched. At the end, `tx.gasUsed = max(execution_gas_used, floor_gas_used)`; the floor accumulator decides only how much of the upfront-debited `tx.gas × gas_price` gets kept versus refunded. ### Ceiling is the execution-gas budget, not `tx.gas` The floor is an execution-dimension quantity: block accounting compares it against the transaction's execution gas, and [EIP-7825](./eip-7825.md) caps a transaction's execution gas at `TX_MAX_GAS_LIMIT`. Under [EIP-8037](./eip-8037.md), `tx.gas` may exceed that cap, with the excess held in a state-gas reservoir that only state charges can draw on. Admitting the floor up to `tx.gas` would then let a deploy transaction with a large reservoir push the floor past `TX_MAX_GAS_LIMIT` through code-deposit bytes, exceeding the per-transaction execution cap that validation enforced statically and the execution capacity the block reserved for it. Checking against `min(tx.gas, TX_MAX_GAS_LIMIT)` keeps the floor inside the execution budget the transaction actually has. Without EIP-8037, `tx.gas` never exceeds `TX_MAX_GAS_LIMIT`, so the budget equals `tx.gas` and nothing changes. ### `max(execution, floor)` preserved The EIP-7623 / EIP-8131 charging shape is unchanged; only the floor side has a runtime tail. Transactions whose execution exceeds the floor pay exactly as today. ## Backwards Compatibility Hard fork. The static floor takes `tx_floor` from EIP-8131 and adds 51 BAL bytes per authorization, plus runtime extensions of the floor accumulator for BAL bytes contributed during execution. The combined floor per auth is `(108 + 51) × 64 = 10,176` gas, still below the `PER_EMPTY_ACCOUNT_COST = 25,000` intrinsic per auth. Static transaction validation changes only when this combined static floor dominates. During execution, a runtime BAL extension can also raise `gasUsed` or cause `OutOfGasError` when the new floor exceeds the execution-gas budget, including for pre-warmed first touches. A bare ETH transfer stays at 21,000. Mainnet sample (1,500 random blocks, 441,271 transactions, 20,000 of those traced for BAL bytes, May 2026): EIP-8131 alone makes 3.24% of transactions pay more (+3.29% gas). EIP-8279 adds another 0.54 pp and +0.77 pp, for a combined 3.79% / +4.06%. In the sampled transactions, the BAL piece did not bind on its own: per-tx BAL bytes average 120-200 B after `TX_BASE` absorption, under 13 k floor gas, well below typical execution gas. EIP-8279's main job is to close the calldata + `SLOAD` bypass when paired with EIP-8131, not to add a broadly binding standalone floor. Wallets and `eth_estimateGas` MUST compute the new static floor (including the per-auth term) and, when tracing execution, meter the runtime BAL bytes for first touches, value-bearing calls, and deployments alongside intrinsic gas. ## Test Cases ### Bare ETH transfer `tx.to = recipient`, `tx.value = 1 ether`, `tx.data = b""`. - `static_floor = TX_BASE = 21,000` gas. - Minimum `tx.gas = 21,000`, unchanged. ### Cold `SLOAD` bypass attempt Before this EIP, an idealized worst-case mix at `gas_limit = 60M` uses 937,171 non-zero calldata bytes plus 21,421 cold `SLOAD`s in one transaction, for 1,622,643 logical content bytes. - Static seed: `21,000 + 64 × 937,171 = 59,999,944`. - Per cold `SLOAD` floor extension: `32 × 64 = 2,048`. - With this EIP, the first cold `SLOAD` raises the floor above `gas_limit`; all 21,421 would raise it to `59,999,944 + 21,421 × 2,048 = 103,870,152`. - The largest feasible metered content is `floor((gas_limit - TX_BASE) / 64) = 937,171` bytes. Including the implicit per-transaction bytes covered by `TX_BASE`, the bound remains `gas_limit / 64 = 937,500` bytes. ### Pre-warmed storage-key touch The transaction's [EIP-2930](./eip-2930.md) access list contains `(contract, slot)`, and execution then performs `SLOAD(slot)`. - EIP-8131 meters the 20-byte address and 32-byte key in the transaction's static floor. - The key is EVM-warm, but its first actual `SLOAD` calls `meter_bal_storage_key`, adding another 32 bytes for the BAL copy. The account address is covered separately when first touched or by the implicit per-transaction accounting above. - Repeated touches in the transaction add nothing. ### Cold `SSTORE` (zero → non-zero) Single cold `SSTORE` writing a non-zero value to an empty slot. - Metered: `32 (key) + 32 (value) = 64` BAL bytes → `4,096` gas folded into `floor_gas_used`. - The `SSTORE` execution charge is 22,100, larger than the 4,096 floor extension, so this trigger remains execution-dominated. ### Net-zero `SSTORE` round-trip Slot starts at original value `O`; the transaction writes `X ≠ O` then writes `O` again. - First `SSTORE` (`O → X`): `32 (cold key) + 32 (value)` metered. - Second `SSTORE` (`X → O`): slot is back at its pre-transaction value, a no-op write under EIP-7928; the `32` value bytes stay metered. - Net metered: `64` (key and the upper-bound value charge). The BAL records the slot in `storage_reads`, with no `storage_changes` value entry, so the meter over-counts by `32` bytes. ### Repeated distinct writes Slot starts at `O`; the transaction writes `A`, then `B`, then `C` (all distinct, `C ≠ O`). - The `32` value bytes are metered once, on the `O → A` transition; `A → B` and `B → C` add nothing. - Net metered: `32 (key) + 32 (value) = 64`, matching the single post-value `C` the BAL records. ### Repeated departures from the original value Slot starts at `O`; the transaction writes `A`, restores `O`, then writes `B` (`A != O`, `B != O`). - `O → A` meters the `32` value bytes and adds the slot to `metered_storage_values`. - `A → O` refunds nothing; `O → B` sees the slot already in `metered_storage_values` and adds nothing. - Net metered: `32 (key) + 32 (value) = 64`, matching the single post-value `B` the BAL records. ### Reverted `CALL` with value `CALL` with `value > 0` to a recipient that reverts. - 64 balance bytes are metered (caller and recipient, folded into `floor_gas_used`) before `generic_call`. - Both balance changes are discarded on revert (per EIP-7928); neither `bal_data_bytes` nor the floor is rewound. - Over-accounting by 64 bytes; the bound still holds. ## Security Considerations ### Block-size bound Per transaction, every byte the tx contributes to the block (tx blob + BAL) is accounted for at the floor rate via either the static seed or a runtime extension, with implicit per-tx bytes covered by `TX_BASE`: ``` block_bytes_per_tx × FLOOR_GAS_PER_BYTE ≤ tx_env.floor_gas_used ≤ tx.gasUsed ``` Summing across user transactions: ``` sum(block_bytes) ≤ sum(tx.gasUsed) / 64 ≤ block_gas_limit / 64 ``` System-contract and withdrawal contributions are bounded separately by the EIP-7928 block-level `ITEM_COST` buffer (~1,428 items at a 60M gas limit), sized to absorb existing system contract use. ### Reverts Accessed addresses and storage keys persist in the BAL across reverts (per EIP-7928); balance, nonce, code, and storage-value changes from reverted frames are discarded. Neither `bal_data_bytes`, `floor_gas_used`, nor any of the three metering sets is rewound for discarded entries, and no storage-value charge is refunded. Safe: the meter over-counts, never under-counts. ### Repeated writes to the same slot The 32 storage-value bytes are metered at most once per slot per transaction. Repeated distinct writes (`O → A → B → C`) and repeated departures from the original value (`O → X → O → Y`) therefore meter 32 value bytes total, not 32 per write or departure. A net-zero round-trip (`O → X → O`) keeps that charge and over-counts the BAL by 32 bytes. Since `metered_storage_values` is not rewound, a charge from a reverted frame also covers any later committed change to the same slot. The meter can over-count, but never under-counts; the bound holds. ### Gas estimation Wallets and `eth_estimateGas` MUST simulate `floor_gas_used` alongside intrinsic and execution gas. Without it, estimates for floor-bound transactions are too low; a transaction may pass static validation and then halt with `OutOfGasError` when a runtime extension exceeds the execution-gas budget `min(tx.gas, TX_MAX_GAS_LIMIT)`. ## Copyright Copyright and related rights waived via [CC0](../LICENSE.md).