# Worse-case analysis for Block-level Access Lists
### 24 June 2025
:::info
I would like to thank [Toni Wahrstätter](https://x.com/nero_eth?ref_src=twsrc%5Egoogle%7Ctwcamp%5Eserp%7Ctwgr%5Eauthor) for the review and discussion.
:::
In this report, we perform a back-of-the-envelope calculation to estimate an upper bound on the size of Block-level Access Lists (BALs). In other words, we are considering specific blocks that would result in the largest possible BALs and estimating their size before compression.
This analysis is similar to the [one done by Toni Wahrstätter](https://ethresear.ch/t/block-level-access-lists-bals/22331). However, since his initial post, the design of BALs has changed slightly. Additionally, we are considering specific scenarios for each component of the BAL and computing the sizes under various block limits.
When estimating the gas costs of specific worse-case blocks, we ignore memory expansion costs, call data costs and any additonal overhead costs of the contract logic. This is an acceptable simplification as we aim to compute an upper bound. Recall also that we are ignoring any gains derived from compression.
For simplicity, we are also ignoring three balance changes in all scenarios. In every transaction, the balances of the `tx.from` account, the `tx.to` account, and the account receiving the coinbase payment change, and thus, they will appear in the BAL. This is an increase in BAL size of $3 \times 48 = 144$ bytes, which is negligible based on the order of magnitude of the worse-case BAL sizes.
For an overview of the BAL sizes for each scenario, you can jump the detailed analysis and check this [summary table](#Summary-table).
## BALs design
As of 24 June 2025, the [proposed EIP-7928](https://github.com/nerolation/EIPs/blob/1ab5cd15f3ac1e7f3415eb7b7c3611c4beeefc62/EIPS/eip-7928.md) defines the following structure for BALs:
```python
# Type aliases
Address = ByteVector(20)
StorageKey = ByteVector(32)
StorageValue = ByteVector(32)
CodeData = ByteList(MAX_CODE_SIZE)
TxIndex = uint16
BalanceDelta = ByteVector(12) # signed, two's complement encoding
Nonce = uint64
# Constants
MAX_TXS = 30_000
MAX_SLOTS = 300_000
MAX_ACCOUNTS = 300_000
MAX_CODE_SIZE = 24_576 # Maximum contract bytecode size in bytes
# Change structures
class StorageChange(Container):
tx_index: TxIndex
new_value: StorageValue
class BalanceChange(Container):
tx_index: TxIndex
delta: BalanceDelta
class NonceChange(Container):
tx_index: TxIndex
new_nonce: Nonce
class CodeChange(Container):
tx_index: TxIndex
new_code: CodeData
# Storage access structures
class SlotChanges(Container):
slot: StorageKey
changes: List[StorageChange, MAX_TXS]
class SlotRead(Container):
slot: StorageKey
# Account-level structure
class AccountChanges(Container):
address: Address
storage_changes: List[SlotChanges, MAX_SLOTS]
storage_reads: List[SlotRead, MAX_SLOTS]
balance_changes: List[BalanceChange, MAX_TXS]
nonce_changes: List[NonceChange, MAX_TXS]
code_changes: List[CodeChange, MAX_TXS]
# Block-level structure
class BlockAccessList(Container):
account_changes: List[AccountChanges, MAX_ACCOUNTS]
```
As we compute the size of specific BALs, we will assume the previous data structures.
## Worse-case BALs
### Storage changes
In this context, a storage change corresponds to writing a different value in a contract slot. This can be done with the `SSTORE` operation. There are different possibilities for this worst-case scenario.
The simplest option is to `SSTORE` to the same account multiple times. This has the benefit of being cheaper in terms of gas, as we only need to pay the account's cold access fee once. However, the final BAL is smaller as each change only adds a new slot to the list instead of an address-slot combination.
In terms of the gas costs, we are implementing multiple `SSTORE` in the same transaction. Thus, we need to pay the intrinsic gas cost of 21000 once. Then, we need to make a `CALL` to a contract that then performs as many `SSTORE` operations as possible to as many different slots as possible. The [cost of this call](https://www.evm.codes/?fork=cancun#f1) operation is just the cold access cost of 2600. Additionally, each [`SSTORE` operation](https://www.evm.codes/?fork=cancun#55) will incur a cost of 5000 gas units, assuming the slot was not empty and the access was cold. Thus, the worse-case block in this case would have $\lfloor \frac{L - 21000 - 2600}{5000} \rfloor = \lfloor \frac{L - 23600}{5000} \rfloor$ slot changes, where $L$ is the block limit in gas units.
With this strategy, each slot change introduces a new `SlotChanges` object to the same `AccountChanges` object. Assuming nothing else is changing in this account (no slot reads, code changes, etc.), this object will be:
```python
AccountChanges(
address=ByteVector(20),
storage_changes=[
SlotChanges(slot=ByteVector(32), changes=List[StorageChange(tx_index=uint16, new_value=ByteVector(32))]),
SlotChanges(slot=ByteVector(32), changes=List[StorageChange(tx_index=uint16, new_value=ByteVector(32))]),
...,
SlotChanges(slot=ByteVector(32), changes=List[StorageChange(tx_index=uint16, new_value=ByteVector(32))])
]
)
```
So, this new object contains 20 bytes for the address. Then, for each slot change, it has 32 bytes for the slot and 32 bytes for the new value. We are ignoring the cost of the transaction index since there is only on transaction. Therefore, in total, we obtain 64 bytes per slot change plus the fixed value of 20 bytes. This means that the total size of the BAL in this case would be $20 + 64 \times \lfloor \frac{L - 23600}{5000} \rfloor$.
The second option is to `SSTORE` to multiple accounts. This has the downside of being more expensive than the first option, as we need to pay for multiple cold accesses. However, the final BAL is larger as each change adds a new address-slot combination. The process is similar to the previous case. A single transaction does calls to multiple contracts, which then perform an `SSTORE` to a single non-empty slot. The cost of these operations would be the intrinsic cost of 21000 gas units, plus the cost of the CALL (2600) and the SSTORE (5000) for each slot change. Thus, the worst-case block, in this case, would have $\lfloor \frac{L - 21000}{7600} \rfloor$ slot changes, where $L$ is the block limit in gas units.
In terms of the BAL size, we would have a list of `AccountChanges` objects with the following format:
```python
AccountChanges(
address=ByteVector(20),
storage_changes=[
SlotChanges(slot=ByteVector(32), changes=List[StorageChange(tx_index=uint16, new_value=ByteVector(32))]),
]
)
```
This corresponds to a size of 20 bytes for the address, 32 bytes for the slot and 32 bytes for the new value, which totals 84 bytes per slot change. This means that the total size of the BAL in this case would be $84 \times \lfloor \frac{L - 21000}{7600} \rfloor$.
Besides the two previous strategies, there is another strategy we can use to boost the number of `SSTORE`'s that fit in a block - the gas refunds. When a non-empty slot is set to zero, the user receives a gas refund, which means they can now use this refund to perform additional operations. This refund is limited to 20% of the total gas spent on the transaction. Therefore, instead of performing `SSTORE` with a random value, the attacker could set the values to zero to fit more operations into a single block.
However, now that the new values in the BAL will be zero, the size of each slot update in the BAL is decreased to 32 bytes. Thus, the formula for the worst-case BAL size, assuming we are changing multiple slots in the same contract, would be $20 + 32 \times \lfloor \frac{L(1+0.2) - 23600}{5000} \rfloor$.
Finally, [EIP-2930](https://eips.ethereum.org/EIPS/eip-2930) introduces another boost we can use on top of the worse BAL. This EIP adds a new transaction type that allows users to specify beforehand with addresses and slots the transaction will access. In that case, the access costs for both accounts and slots get 100 units cheaper. With this, the formula for the worst-case BAL size, assuming we are changing multiple slots in the same contract, would be $20 + 64 \times \lfloor \frac{L - 23500}{4900} \rfloor$.
The final worse-case sizes for balance changes are:
| Gas limit (million gas units) | BAL size w/ single account (MiB) | BAL size w/ multiple accounts (MiB) | BAL size w/ gas refunds (MiB) | BAL size w/ EIP-2930 (MiB) |
|:---:|:---:|:---:|:---:|:---:|
| 36 | 0.44 | 0.38 | 0.26 | 0.45 |
| 45 | 0.55 | 0.47 | 0.33 | 0.56 |
| 60 | 0.73 | 0.63 | 0.44 | 0.75 |
| 100 | 1.22 | 1.05 | 0.73 | 1.25 |
### Storage reads
For storage reads, the opcode we can use is `SLOAD`. The strategies to make a block with as many `SLOAD` as possible are similar to the storage writes:
1. Read many slots from a single account.
2. Read one slot from many accounts.
3. Read many slots from a single account using EIP-2930.
The difference in gas costs mostly comes from using `SLOAD` (which [costs](https://www.evm.codes/?fork=cancun#54) 2100 per cold access) instead of `SSTORE` (which costs 5000 per cold access). Thus, the formulas for how many storage reads we can do with a block limit of $L$ are $\lfloor \frac{L - 23600}{2100} \rfloor$, $\lfloor \frac{L - 21000}{4700} \rfloor$, and $\lfloor \frac{L - 23500}{2000} \rfloor$, respectively for (1), (2) and (3).
In terms of BAL size, storage reads are lighter. The object has the following structure (assuming no other changes to the account):
```python
AccountChanges(
address=ByteVector(20),
storage_reads=[SlotRead(slot=ByteVector(32))]
)
```
Thus, the final formulas for the BAL size for each strategy are, respectively, $20 + 32 \times\lfloor \frac{L - 23600}{2100} \rfloor$, $52 \times\lfloor \frac{L - 21000}{4700} \rfloor$, and $20 + 32 \times\lfloor \frac{L - 23500}{2000} \rfloor$. With this, the final sizes under different block limits are the following:
| Gas limit (million gas units) | BAL size w/ single account (MiB) | BAL size w/ multiple accounts (MiB) | BAL size w/ EIP-2930 (MiB) |
|:---:|:---:|:---:|:---:|
| 36 | 0.52 | 0.38 | 0.55 |
| 45 | 0.65 | 0.47 | 0.69 |
| 60 | 0.87 | 0.63 | 0.92 |
| 100 | 1.45 | 1.05 | 1.53 |
### Balance changes
Every time a transaction results in a balance change in a new account, we append a new `AccountChanges` object to the list. Assuming nothing else is changing in this account (no slot writes, code changes, etc.), this object will be:
```python
AccountChanges(
address=ByteVector(20),
balance_changes=[
BalanceChange(tx_index=uint16, delta=ByteVector(12))
]
)
```
So, this new object contains 20 bytes for the address and 12 bytes for the balance delta. Once again, we are ignoring the 16 bytes for the transaction index. So, in total, we get 32 bytes per balance change.
The worse blocks for balance changes are when a transaction tries to use the entire gas limit to make one balance change per account, thus adding a new `AccountChanges` object per change to the BAL. There are two ways to achieve this.
The first is to make a transaction that makes calls sending 1 wei to different non-empty accounts. The [cost per call](https://www.evm.codes/?fork=cancun#f1) operation is 9300 gas units, from which 2600 correspond to the cold access cost and 6700 correspond to the positive value cost. In addition, we need to consider the intrinsic cost of 21000 gas units for the transaction.
With these costs, the worse-case block would have $\lfloor \frac{L - 21000}{9300} \rfloor$ balance changes, where $L$ is the block limit in gas units. And, the total size of the BAL in this case would be $32 \times \lfloor \frac{L - 21000}{9300} \rfloor$.
The second way to build a worse-case block is through the `SELFDESTRUCT` opcode. This opcode halts the execution of the current transaction and marks the account for deletion. In addition, the balance of the account is sent to a designated account.
Why use this? The SELFDESTRUCT opcode [only costs](https://www.evm.codes/?fork=cancun#ff) 5000 gas units, which is cheaper than the cost of a call. The idea is to deploy a chain of contracts that, whenever they receive ETH, a `SELFDESTRUCT` is activated that the balance of that contract goes to the next `SELFDESTRUCT` forwarding contract in the chain. However, this strategy would require someone to deploy these contracts and thus pay the respective transaction fees beforehand
With these costs, the worse-case block would have $\lfloor \frac{L - 21000}{5000} \rfloor$ balance changes, where $L$ is the block limit in gas units. And, the total size of the BAL in this case would be $32 \times \lfloor \frac{L - 21000}{5000} \rfloor$.
The final worse-case sizes for balance changes are:
| Gas limit (million gas units) | BAL size w/ CALL (MiB) | BAL size w/ `SELFDESTRUCT` (MiB) |
|:---:|:---:|:---:|
| 36 | 0.12 | 0.22 |
| 45 | 0.15 | 0.27 |
| 60 | 0.20 | 0.37 |
| 100 | 0.33 | 0.61 |
### Nonce and Code changes
BALs will include a non-empty code change whenever a new contract is deployed, which can be done with the operations [`CREATE`](https://www.evm.codes/?fork=cancun#f0) and [`CREATE2`](https://www.evm.codes/?fork=cancun#f5). In addition, the account that invoques the create operations will also have a nonce change. Because both nonce and code changes are only caused by the same opcodes, we will treat them together.
For both create operations, we consider the static costs of the opcode (32000 units) and the code deposit costs, assuming a maximum contract size of 24576 bytes (4915200 units).
Thus, the maximum number of contract deployments we can do in a block with a limit of $L$ is $\lfloor \frac{L - 21000}{4947200} \rfloor$. In terms of BAL size, each contract deployment will generate an `AccountChanges` object with the following format:
```python
AccountChanges(
address=ByteVector(20),
code_changes=[
CodeChange(tx_index=uint16, new_code=ByteList(24576))
]
)
```
In addition, the account deploying the contracts will also have its `AccountChanges` object with the format:
```python
AccountChanges(
address=ByteVector(20),
nonce_changes=[NonceChange(tx_index=uint16, new_nonce=uint64)]
]
)
```
Thus, each contract deployed increases the size of the BAL by $20+24576=24596$ bytes. On top of this, the nonce change will add a fix cost of $20+64=84$. The final formula for the BAL size, in this case, is $84 + 24596 \times \lfloor \frac{L - 21000}{4947200} \rfloor$ and the actual sizes per block limit are the following:
| Gas limit (million gas units) | BAL size (MiB) |
|:---:|:---:|
| 36 | 0.16 |
| 45 | 0.21 |
| 60 | 0.28 |
| 100 | 0.47 |
## Summary table
| | 36M | 45M | 60M | 100M | Formula (bytes) |
|:---:|:---:|:---:|:---:|:---:|:---:|
| **Storage changes** | | | | | |
| BAL size w/ single account (MiB) | 0.44 | 0.55 | 0.73 | 1.22 | $20 + 64 \times \lfloor \frac{L - 23600}{5000} \rfloor$ |
| BAL size w/ multiple accounts (MiB) | 0.38 | 0.47 | 0.63 | 1.05 | $84 \times \lfloor \frac{L - 23600}{7600} \rfloor$ |
| BAL size w/ gas refunds (MiB) | 0.26 | 0.33 | 0.44 | 0.73 | $20 + 32 \times \lfloor \frac{1.2L - 23600}{5000} \rfloor$ |
| BAL size w/ EIP-2930 (MiB) | 0.45 | 0.56 | 0.75 | 1.25 | $20 + 64 \times \lfloor \frac{L - 23500}{4900} \rfloor$ |
| **Storage reads** | | | | | |
| BAL size w/ single account (MiB) | 0.52 | 0.65 | 0.87 | 1.45 | $20 + 32 \times\lfloor \frac{L - 23600}{2100} \rfloor$ |
| BAL size w/ multiple accounts (MiB) | 0.38 | 0.47 | 0.63 | 1.05 | $52 \times\lfloor \frac{L - 21000}{4700} \rfloor$ |
| BAL size w/ EIP-2930 (MiB) | 0.55 | 0.69 | 0.92 | 1.53 | $20 + 32 \times\lfloor \frac{L - 23500}{2000} \rfloor$ |
| **Balance changes** | | | | | |
| BAL size w/ CALL (MiB) | 0.12 | 0.15 | 0.20 | 0.33 | $32 \times \lfloor \frac{L - 21000}{9300}\rfloor$ |
| BAL size w/ SELFDESTRUCT (MiB) | 0.22 | 0.27 | 0.37 | 0.61 | $32 \times \lfloor \frac{L - 21000}{5000} \rfloor$ |
| **Nonce & Code changes** | | | | | |
| BAL size (MiB) | 0.16 | 0.21 | 0.28 | 0.47 | $84 + 24596 \times \lfloor \frac{L - 21000}{4947200} \rfloor$ |
## Discussion
With the current pricing, storage reads are the operations that lead to the worst-case BAL size. At the current gas limit of 36 million units, this worst-case BAL has an uncompressed size of 0.55 MiB.
However, we can build a theoretical worst block using a combination of call data and the [EIP-2930](https://eips.ethereum.org/EIPS/eip-2930) access list. Specifically, this block utilizes 60% of its gas in an access list that contains one address and multiple storage slots, while the remaining gas is spent on call data. This strategy does not meet the [EIP-7623](https://eips.ethereum.org/EIPS/eip-7623) threshold for a DA transaction and can, therefore, use call data more cost-effectively.
If we define the available gas as $L^* = L-2100$, then the access list component of this block uses $20 + 32 \times \lfloor \frac{0.6L^*-2400}{1900} \rfloor$ bytes. In terms of call data, the worst combination of bytes occurs when we have 29% zero bytes and 71% non-zero bytes. In this case, the size of the call data component is $\lfloor \frac{0.4L^*}{4 \times 0.29 + 16 \times 0.71} \rfloor = \lfloor \frac{0.4L^*}{12.52} \rfloor$ bytes. Looking at the theoretical size of this block under different block limits, we see it is significantly larger than the worst-case BAL:
| Gas limit (million gas units) | Access list size (MiB) | Call data size (MiB) | Total (MiB) |
|:---:|:---:|:---:|:---:|
| 36 | 0.35 | 1.60 | 1.95 |
| 45 | 0.43 | 2.01 | 2.44 |
| 60 | 0.58 | 2.67 | 3.25 |
| 100 | 0.96 | 4.46 | 5.42 |
We should note, however, that since the worst-case BAL also uses an access list, this list will occupy space in the block payload. The added size in this case is $20 + 32 \times \lfloor \frac{L - 23500}{2000} \rfloor$ (the same formula as the storage read structures in the BAL). This increases the worst-case block with a BAL to the following:
| Gas limit (million gas units) | BAL size (MiB) | Access list size (MiB) | Total (MiB) |
|:---:|:---:|:---:|:---:|
| 36 | 0.55 | 0.55 | 1.10 |
| 45 | 0.69 | 0.69 | 1.37 |
| 60 | 0.92 | 0.92 | 1.83 |
| 100 | 1.53 | 1.53 | 3.05 |
This is still less than the worst-case block with call data and access lists. Additionally, as there is a significant amount of repetition between the access list and the BAL, snappy compression should be able to reduce this substantially. Something to test next.
#### Related EIPs and proposals
- [EIP-7825: Transaction Gas Limit Cap](https://eips.ethereum.org/EIPS/eip-7825) -- Transactions are capped at 30 million gas units. This means that the attacker can no longer create a single transaction that fills the entire block.
- [EIP-7976: Further increase calldata cost](https://github.com/ethereum/EIPs/pull/9909/files) -- call data gets even more expensive, thus reducing the worse-case block size obtained with call data.
- [Multidimensional metering](https://ethresear.ch/t/a-practical-proposal-for-multidimensional-gas-metering/22668) -- Depending on the final split of `SSTORE` and `SLOAD` into the various resources, there will more block space for these operations, thus allowing for larger worse-case BALs.