This proposal makes (execution) blocks statically verifiable through minimal checks that only require the previous state, but no execution of the block's transactions, allowing validators attest to a block's validity without completing its execution. We allow transactions to be skipped when invalid at execution time, without invalidating the whole block. To ensure that even skipped transactions pay for their resources, the COINBASE
pays for all inclusion costs upfront (base cost, calldata and blobs), and recovers the costs only when transactions are successfully executed.
A key advantage of this proposal is that it enables asynchronous block validation. Currently, blocks must be fully executed before validators can attest to their validity. This requirement creates a bottleneck in the consensus process, as attestors must wait for execution results before committing their votes.
By introducing a mechanism where invalid transactions are skipped rather than invalidating the entire block, execution is no longer an immediate requirement for validation. Instead, a block’s validity can be determined based on its structural correctness and the upfront payment of inclusion costs by the COINBASE
. This allows attestation to happen earlier, independent of execution, potentially enabling higher block gas limits.
In order to be verifiable before execution, the header cannot commit to any execution output. In particular, we need to defer these fields: state_root, receipt_root, bloom, gas_used, requests_hash
. We replace them with the equivalent execution outputs from the parent block.
We include a signature from COINBASE
over the rest of the header in the header , so that the COINBASE
address can authorize the upfront payment of inclusion costs.
The final header structure then is:
class Header:
parent_hash: Hash32
ommers_hash: Hash32
coinbase: Address
pre_state_root: Root # Deferred
transactions_root: Root
parent_receipt_root: Root # Deferred
parent_bloom: Bloom # Deferred
difficulty: Uint
number: Uint
gas_limit: Uint
parent_gas_used: Uint
timestamp: U256
extra_data: Bytes
prev_randao: Bytes32
nonce: Bytes8
base_fee_per_gas: Uint
withdrawals_root: Root
blob_gas_used: U64
excess_blob_gas: U64
parent_beacon_block_root: Root
parent_requests_hash: Hash32 # Deferred
# Signature over the header
y_parity: U256
r: U256
s: U256
The following function is used to recover the signer’s address, intended to be COINBASE
, from the signed block header:
def recover_header_signer(
chain_id: U64,
header: Header,
) -> Address:
signing_hash = keccak256(
b"0x06"
+ rlp.encode(
(
chain_id,
header.parent_hash,
header.ommers_hash,
header.coinbase,
header.pre_state_root,
header.transactions_root,
header.parent_receipt_root,
header.parent_bloom,
header.difficulty,
header.number,
header.gas_limit,
header.parent_gas_used,
header.timestamp,
header.extra_data,
header.prev_randao,
header.nonce,
header.base_fee_per_gas,
header.withdrawals_root,
header.blob_gas_used,
header.excess_blob_gas,
header.parent_beacon_block_root,
header.parent_requests_hash,
)
)
)
If COINBASE != header_signer
, the block MUST be considered invalid.
header_signer = recover_header_signer(
chain.chain_id,
block.header,
)
if coinbase != header_signer:
raise InvalidBlock
We split up a block's validation from its execution. In the ethereum/execution-specs, static validation is done in [validate_block](https://github.com/ethereum/execution-specs/blob/ae2c77989cb83e5d5e5eb1f51d9da840a337d5b0/src/ethereum/prague/fork.py#L480)
, after which a block is guaranteed to be valid and can be attested to, while execution remains within [apply_body](https://github.com/ethereum/execution-specs/blob/ae2c77989cb83e5d5e5eb1f51d9da840a337d5b0/src/ethereum/prague/fork.py#L696)
. In validate_block
, we do some formal checks, as well as:
pre_state_root
, parent_gas_used
, parent_receipts_root
, parent_bloom
, parent_receipts_hash
, parent_requests_hash
) against the output from execution of the parent block.transactions_root
and withdrawals_root
are correct, by building the respective tries, but without yet processing either the transactions or the withdrawalsblob_gas_used
is correctCOINBASE
.[check_transaction_static](https://github.com/ethereum/execution-specs/blob/ae2c77989cb83e5d5e5eb1f51d9da840a337d5b0/src/ethereum/prague/fork.py#L443)
:COINBASE
can pay for the total inclusion costIn other words, we validate everything that we can validate without doing any execution.
def check_transaction_static(
tx: Transaction,
chain_id: U64,
) -> Address:
...
if isinstance(tx, (FeeMarketTransaction, BlobTransaction, SetCodeTransaction)):
if tx.max_fee_per_gas < tx.max_priority_fee_per_gas:
raise InvalidBlock
if tx.max_fee_per_gas < base_fee_per_gas:
raise InvalidBlock
else:
if tx.gas_price < base_fee_per_gas:
raise InvalidBlock
if isinstance(tx, BlobTransaction):
...
if Uint(tx.max_fee_per_blob_gas) < blob_gas_price:
raise InvalidBlock
...
return recover_sender(chain_id, tx)
def validate_block(
chain: BlockChain,
block: Block,
) -> List[Address]:
parent_header = chain.blocks[-1].header
validate_header(block.header, parent_header)
# validate deferred execution outputs from the parent
if block.header.parent_gas_used != chain.last_block_gas_used:
raise InvalidBlock
if block.header.parent_receipt_root != chain.last_receipt_root:
raise InvalidBlock
if block.header.parent_bloom != chain.last_block_logs_bloom:
raise InvalidBlock
if block.header.parent_requests_hash != chain.last_requests_hash:
raise InvalidBlock
if block.header.pre_state_root != state_root(chain.state):
raise InvalidBlock
if block.ommers != ():
raise InvalidBlock
# Validate coinbase's signature over the header
coinbase = block.header.coinbase
header_signer = recover_header_signer(
chain.chain_id,
block.header,
)
if coinbase != header_signer:
raise InvalidBlock
sender_addresses = []
for i, tx in enumerate(map(decode_transaction, block.transactions)):
sender_address = check_transaction_static(tx, chain.chain_id)
sender_addresses.append(sender_address)
_, inclusion_gas = calculate_inclusion_gas_cost(tx)
blob_gas_used = calculate_total_blob_gas(tx)
total_inclusion_gas += inclusion_gas
total_blob_gas_used += blob_gas_used
trie_set(
transactions_trie, rlp.encode(Uint(i)), encode_transaction(tx)
)
# Check that inclusion resources are within the limits
if total_inclusion_gas > block.header.gas_limit:
raise InvalidBlock
if total_blob_gas_used > MAX_BLOB_GAS_PER_BLOCK:
raise InvalidBlock
blob_gas_price = calculate_blob_gas_price(block.header.excess_blob_gas)
inclusion_cost = (
total_inclusion_gas * block.header.base_fee_per_gas
+ total_blob_gas_used * blob_gas_price
)
# Check that coinbase can pay for inclusion costs
coinbase_account = get_account(chain.state, coinbase)
if Uint(coinbase_account.balance) < inclusion_cost:
raise InvalidBlock
for i, wd in enumerate(block.withdrawals):
trie_set(withdrawals_trie, rlp.encode(Uint(i)), rlp.encode(wd))
if block.header.transactions_root != root(transactions_trie):
raise InvalidBlock
if block.header.withdrawals_root != root(withdrawals_trie):
raise InvalidBlock
if block.header.blob_gas_used != blob_gas_used:
raise InvalidBlock
return sender_addresses
This logic is implemented into the ethereum/execution-specs, in [apply_body](https://github.com/ethereum/execution-specs/blob/ae2c77989cb83e5d5e5eb1f51d9da840a337d5b0/src/ethereum/prague/fork.py#L696)
.
COINBASE
is charged inclusion_cost = 21000 + max_calldata_fee + blob_fee
for each transaction at the start of block execution. The inclusion_cost
is determined by adding up the blob fee and the floor cost of EIP-7623, itself comprising the 21k base cost and the calldata cost charged at TOTAL_COST_FLOOR_PER_TOKEN
.COINBASE
loses these inclusion fees and thereby pays for DA and other base costs like signature verification.# The inclusion gas consists of the base cost + the calldata cost
def calculate_inclusion_gas_cost(tx: Transaction) -> Uint:
tokens_in_calldata = zero_bytes + (len(tx.data) - zero_bytes) * 4
calldata_floor_gas_cost = tokens_in_calldata * FLOOR_CALLDATA_COST
return TX_BASE_COST + calldata_floor_gas_cost
def apply_body(
...
) -> ApplyBodyOutput:
...
total_inclusion_gas = sum(calculate_inclusion_gas_cost(tx) for tx in decoded_transactions)
total_blob_gas_used = sum(calculate_total_blob_gas(tx) for tx in decoded_transactions)
inclusion_cost = (
total_inclusion_gas * base_fee_per_gas
+ total_blob_gas_used * blob_gas_price
)
coinbase_account = get_account(state, coinbase)
coinbase_balance_after_inclusion_cost = (
Uint(coinbase_account.balance) - inclusion_cost
)
# Charge coinbase for inclusion costs
set_account_balance(
env.state,
env.coinbase,
U256(coinbase_balance_after_inclusion_cost),
)
...
Besides deducting the inclusion costs from theCOINBASE
's balance, we deduct the inclusion_gas
from gas_available
upfront, since this gas is going to be consumed regardless of how execution goes. When executing a transaction, we do the following:
gas_available
, making it available for the transaction to consume.gas_available
gas_used
from gas_available
.def apply_body(
...
) -> ApplyBodyOutput:
...
total_inclusion_gas = sum(calculate_inclusion_gas_cost(tx) for tx in decoded_transactions)
...
gas_available = block_gas_limit - total_inclusion_gas
...
for i, tx in enumerate(txs):
...
inclusion_gas = calculate_inclusion_gas_cost(tx)
gas_available += inclusion_gas
(
is_transaction_skipped,
effective_gas_price,
blob_versioned_hashes,
) = check_transaction(
state,
tx,
sender_address,
gas_available,
)
if is_transaction_skipped:
gas_available -= inclusion_gas
else:
...
gas_used, logs, error = process_transaction(env, tx)
gas_available -= gas_used
...
...
Definition: A "skipped transaction" is a transaction that:
COINBASE
pays and does not get refunded if the transaction is ultimately skipped.Skipping might occur because:
tx.value
More precisely, this is how we determine if a transaction should be skipped:
is_sender_eoa = (
sender_account.code == bytearray()
or is_valid_delegation(sender_account.code)
)
is_transaction_skipped = (
tx.gas > gas_available
or Uint(sender_account.balance) < max_gas_fee + Uint(tx.value)
or sender_account.nonce != tx.nonce
or not is_sender_eoa
)
Note that signature verification and other checks that do not depend on execution are done in advance, when statically checking the block's validity. If those fail, the block is invalid. A transaction is skipped only when an execution-dependent check fails, in a block that's already been determined to be valid.
When a transaction is executed, rather than skipped, the only change from the previous behavior is that COINBASE
receives not only the priority fees, but also a refund of the inclusion costs. From the transaction sender's perspective, there is no change at all: the transaction executes in the same way, and the same exact fees are paid. From the protocol's perspective, there is also no difference, because the extra fees collected by COINBASE
are exactly those that it had paid upfront at the beginning of the block's execution.
def process_transaction(
...
inclusion_cost_refund = (
inclusion_gas * base_fee_per_gas
+ blob_gas_used * blob_gas_price
)
# transfer priority fees and refund inclusion cost
coinbase_balance_after_transaction = (
coinbase_account.balance
+ priority_fee
+ inclusion_cost_refund
)
...
Enabling delayed execution by making the block's validity statically verifiable requires two things:
state_root
and receipt_root
become pre_state_root
, parent_receipt_root
the root of the state and receipt trie obtained after executing the block's parent. The same applies to the General Purpose Execution Layer Requests from EIP-7685: the requests are deferred by one slot and the requests in the CL must correspond to the parent_requests_hash
in the EL header. However, this alone would only defer the computation of these execution outputs (mainly of the state root) rather than the actual execution, because verifying transaction validity would still require executing. COINBASE
: in addition, we need to be able to skip (no-op) invalid transactions without invalidating the whole block. Right now, this is not possible because of the free-DA problem: as soon as we include a transaction into a block, it must pay for its data footprint. By charging the inclusion cost of all transactions upfront from the block's COINBASE
, it is possible to skip transactions that are found to be invalid at execution time, because the protocol has already been compensated for the inclusion.By signing over the header, the COINBASE
address explicitly accepts responsibility for the upfront inclusion costs of this block. Therefore, the recovered address MUST equal the block's COINBASE
. The COINBASE
's commitment is protected from replay attacks, because the header is a commitment to the block, so the signature only serves as an authorization for the exact block for which the COINBASE
has agreed to take responsibility.
This change is not backward compatible and requires a hard fork activation.
At the time of block creation, the COINBASE
must be sufficiently funded to cover up to block.gas_limit * base_fee_per_gas
+ blob_gas_price * max_blob_gas_per_block
to be able to cover the maximum possible inclusion cost. For instance, with a base fee of 100 gwei and a 36 million gas limit, the COINBASE
would need to hold 3.6 ETH to front this cost (ignoring the blob fees) for a worst-case block. This requirement could introduce additional liquidity constraints for block proposers, especially under high base fee conditions. However, the inclusion costs under normal conditions (lower base fee, inclusion gas much below the gas limit) are significantly lower. Over a one year period of blocks from ~19.1M to ~21.7M, the average inclusion costs would have been ~5.5M gas per block, or ~0.55 ETH even at 100 gwei.
Copyright and related rights waived via CC0.