Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
228 changes: 228 additions & 0 deletions EIPS/eip-8279.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
---
eip: 8279
title: Block Access List Byte Floor
description: Charge EIP-7623 floor gas at runtime for items added to the EIP-7928 Block Access List.
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, 7928, 7976, 7981, 8131
---

## Abstract

Extend the transaction's floor accumulator by 64 gas for each byte an opcode adds to the [EIP-7928](./eip-7928.md) Block Access List, checked at runtime before the BAL grows. Today an attacker can pack ~1.55 MB 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 MB` (~42% reduction). Neither EIP alone closes the bypass: 8131 does not price BAL bytes; 8279 reuses 8131's floor. Typical transactions are unaffected: the runtime BAL floor never binds in isolation.

## Motivation

[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 MB (target) |
| Auth-tuple bypass under EIP-8131 alone | 1.067 MB |
| Cold-`SLOAD` bypass under EIP-7928 | **1.548 MB** |

`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 and RFC 8174.

### Constants

```
FLOOR_GAS_PER_BYTE = 64 # per-byte floor rate (EIP-7976)
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
```

### Floor accumulator

Clients keep an internal, per-transaction `floor_gas_used` counter on the execution environment for the duration of the transaction. It is not part of the signed transaction, RLP-encoded, gossiped, or persisted; no new transaction field or type is introduced. The counter is seeded with the static floor (below) and extended at runtime. No gas is reserved or deducted from the execution budget; the counter is checked against `tx.gas` only to ensure the user can pay the floor if it ends up binding.

```
def extend_floor(tx_env, num_bytes):
new_floor = tx_env.floor_gas_used + num_bytes * FLOOR_GAS_PER_BYTE
if new_floor > tx_env.gas_limit: # tx_env.gas_limit = tx.gas
raise OutOfGasError
tx_env.floor_gas_used = new_floor
```

`extend_floor` MUST be called BEFORE the matching BAL insertion or state mutation. An `OutOfGasError` aborts the operation before any unpaid BAL byte exists.

### 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.floor_gas_used` is initialised to `static_floor` 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 extends the floor at runtime.

Per-transaction accounting always adds further BAL entries that no opcode places there: sender, coinbase, and target (each with address plus the relevant balance/nonce changes); recipient balance for value transfers; contract nonce on creation; 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 floor extensions

| Trigger | Bytes added |
|---|---|
| Cold account access (`BALANCE`, `EXT*`, `CALL*`, `SELFDESTRUCT` beneficiary, `CREATE`/`CREATE2` deployed address) | 20 |
| Cold storage access (`SLOAD`, `SSTORE`) | 32 |
| `SSTORE` whose new value differs from the current value | +32 |
| `CALL` with non-zero value to a different account | 32 |
| `SELFDESTRUCT` with non-zero balance to a different beneficiary | 32 |
| `CREATE` / `CREATE2` after the collision check (new contract's nonce) | 8 |
| `CREATE` / `CREATE2` with non-zero endowment (new contract's balance) | +32 |
| Successful `CREATE` / `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.

### Final charge

```
tx.gasUsed = max(execution_gas_used, tx_env.floor_gas_used)
```

Transaction validation continues to require `tx.gas >= 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 opcode intrinsic costs 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 cannot make the floor bind on their own

Every runtime trigger pairs its `extend_floor` call with an execution charge larger than the floor extension itself:

| Trigger | Floor extension | Min execution charge |
|---|---:|---:|
| Cold account access | 1,280 | 2,600 |
| Cold `SLOAD` | 2,048 | 2,100 |
| `SSTORE` (cold, zero → non-zero) | 4,096 | 22,100 |
| `CALL` with non-zero value | 2,048 | 9,000 |
| `SELFDESTRUCT` with non-zero balance | 2,048 | 5,000 |
| `CREATE` / `CREATE2` (nonce only) | 512 | 32,000 |
| Deployed code (per byte) | 64 | 200 |

For every BAL-contributing opcode, `floor_extension < execution_gas`. Executing one more such opcode raises `execution_gas_used` by strictly more than it raises `floor_gas_used`. A transaction whose execution gas already exceeds its static floor therefore stays execution-dominated no matter how many BAL-contributing opcodes it runs: the gap between `execution_gas_used` and `floor_gas_used` can only widen. Floor-side `OutOfGasError` (`new_floor > tx.gas` inside `extend_floor`) requires the floor accumulator to climb past `tx.gas`; from an execution-dominated state, execution-side OOG fires first.

The floor only binds when the static seed defined by [EIP-8131](./eip-8131.md) (calldata, access-list entries, auths, blob hashes) already pushes `static_floor` close to `tx.gas` before any opcode runs. That is precisely the calldata + cold-`SLOAD` bypass this EIP targets. Transactions whose static seed is small, the common case, cannot reach a binding floor through opcode execution alone.

### 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`.

### Extend before, not refund after

EIP-7928 keeps accessed addresses and storage keys in the BAL even when their call frame reverts. Extending the floor accumulator before the insertion guarantees every BAL byte is accounted for, and an out-of-gas at the extension aborts before the matching insertion. A refund-on-revert design would have to interact with [EIP-3529](./eip-3529.md) storage refunds and the existing OOG semantics; up-front accounting avoids both.

### Per-auth coverage is static

`set_delegation` runs outside the EVM's exception handler. A runtime `extend_floor` 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 `tx.gas` 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. `extend_floor` only increments a parallel accumulator (`floor_gas_used`) and checks that it remains `≤ tx.gas`. 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.

### `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 that opcodes contribute during execution. The combined floor per auth is `(108 + 51) × 64 = 10,176` gas, still below the `AUTH_PER_EMPTY_ACCOUNT = 25,000` intrinsic per auth. The minimum `tx.gas` for a transaction only changes when the floor side already dominated, i.e. for calldata-heavy or BAL-heavy transactions. 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%. The BAL piece never bites 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 job is to close the calldata + cold-`SLOAD` bypass when paired with EIP-8131, not to add a standalone floor.

Wallets and `eth_estimateGas` MUST compute the new static floor (including the per-auth term) and, when tracing execution, accumulate the runtime floor extensions for cold accesses, 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

Pre-EIP optimum at `gas_limit = 60M`: 937,500 non-zero calldata bytes plus 21,429 cold `SLOAD`s in one transaction.

- Static seed: `21,000 + 64 × 937,500 = 60,021,000`.
- Per cold `SLOAD` floor extension: `32 × 64 = 2,048`.
- Cumulative floor after 21,429 `SLOAD`s: `60,021,000 + 21,429 × 2,048 ≈ 103.9M`, exceeding `gas_limit`. The transaction fails validation or hits `OutOfGasError` mid-execution.
- The largest feasible mix yields `B = gas_limit / 64 = 937,500` block bytes regardless of the calldata / `SLOAD` split.

### Cold `SSTORE` (zero → non-zero)

Single cold `SSTORE` writing a non-zero value to an empty slot.

- Floor extension: `32 (key) + 32 (value) = 4,096` gas added to `floor_gas_used`.
- Intrinsic `SSTORE` cost: 22,100. `max(intrinsic, floor) = intrinsic`. The floor accumulator is dominated by execution gas.

### Reverted `CALL` with value

`CALL` with `value > 0` to a recipient that reverts.

- 32-byte balance extension is added to `floor_gas_used` before `generic_call`.
- The recipient's balance change is discarded on revert (per EIP-7928); the accumulator is not rewound.
- Over-accounting by 32 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. The floor accumulator is not rewound for discarded entries. Safe: the accounting over-charges, never under-charges.

### Repeated writes to the same slot

`SSTORE` adds another 32 bytes to the floor accumulator each time it changes the current value, even though the BAL records only one final post-value per slot per transaction. The accounting over-charges; the bound still holds.

### Gas estimation

Wallets and `eth_estimateGas` MUST simulate `floor_gas_used` alongside intrinsic gas. Without it, estimates for floor-bound transactions are too low and submissions will be rejected at validation.

## Copyright

Copyright and related rights waived via [CC0](../LICENSE.md).
Loading