Try   HackMD

Delayed execution

Goal: we want to go further than just delaying the state-root computation, which still requires immediately executing the block to check that it is valid. We want to allow attesting to a block without executing it, taking execution fully out of the critical path. It would only need to complete before the following slot (though in practice there are other constraints as well, e.g. sync speed).

Approach 1: statically verify payload with upfront builder payment

This is an EL-based approach, for which an EELS spec is available.

Static verification

We want to be able to statically verify the block, meaning do some checks that do not involve tx execution, and which are sufficient to guarantee block validity. The key problem in doing this is that a tx's sender might not be sufficiently funded when its tx should be executed, e.g. because the sender is a 7702 delegated EOA and a previous tx sent out its balance. Moreover, we are reluctant to simply skip such a tx, because even the tx's inclusion without execution consumes resources that should be paid for. In particular, we are concerned about builders always having an incentive to fill any unused blockspace with invalid txs full of calldata (the free DA problem).

To get around this problem and enable static verification, we charge the builder upfront for all inclusion costs, i.e., all costs that don't depend on whether the tx is executed or not:

  • the 21000 fixed fee, which accounts for signature verification and any other small validity check, and for all bytes other than calldata (e.g. signature, nonce etc)
  • calldata
  • blob data

Meaning, for each tx the builder (fee_recipient/coinbase) pays this amount upfront:


TX_BASE_COST = 21000
TOTAL_COST_FLOOR_PER_TOKEN = 10

tokens_in_calldata = (
    zero_bytes_in_calldata 
    + nonzero_bytes_in_calldata * 4
)
inclusion_gas = (
    TX_BASE_COST 
    + TOTAL_COST_FLOOR_PER_TOKEN * tokens_in_calldata
)

inclusion_cost = (
    inclusion_gas * base_fee_per_gas 
    + blob_gas * blob_basefee
)

Now, if at execution time a tx fails to be sufficiently funded, we can safely skip it without doing any execution, because the protocol is still made whole for the inclusion costs fronted by the builder. Skipped txs might still be used as a weird DA layer, for example by paying the builder out-of-band, but from the protocol's perspective what's important is that its resources are paid for.

Execution

Once we actually execute the block, and are preparing to execute a given transaction tx, we check that sender_balance >= tx.value + max_gas_fee, as we also currently do. Here, max_gas_fee is just the worst case fee declared by the tx:

if isinstance(tx, (FeeMarketTransaction, BlobTransaction)):
    max_gas_fee = tx.gas * tx.max_fee_per_gas
else:
    max_gas_fee = tx.gas * tx.gas_price

In other words, we are checking that the sender is sufficiently funded to execute this tx. This check cannot be done statically, without executing, and as just mentioned it's precisely why we are forced to require an upfront payment from the builder. If check fails at execution time, it's too late to invalidate the block, but we can just skip the tx (treat it like a no-op), because its inclusion costs have already been covered.

Since the builder could have prevented this by not including the tx in the first place, and since the transaction sender doesn't get any benefit from the tx being included but skipped, we do not refund the builder at all for the intrinsic costs. From a tx sender's perspective, this essentially preserves the guarantee that they don't pay for invalid txs.

If instead the check above succeeds, the tx is executed exactly as usual, including fee collection: basefee and blob base fee are burnt, while priority fees go to the builder. Separately, the builder is refunded ("by the protocol" rather than from the sender, i.e., by just increasing its balance) exactly what they have paid upfront for this tx, the inclusion_cost. In other words, when a tx is skipped the builder ends up paying exactly the inclusion_cost, whereas when a transaction executes the behavior is exactly the same as today, except for inclusion_cost being temporarily reserved from the builder's balance.

Variant: builder pays for all protocol fees

Separately from delayed execution, another feature (EELS spec here) involving a different way to deal with the sale of protocol resources is block-level base fees, the idea of requiring base fee payment not on a tx by tx basis, but at the level of the block. In other words, we want to loosen two constraints which the protocol currently requires from each tx, namely to be:

  1. Basefee paying: tx.max_fee >= base_fee_per_gas and tx.max_fee_per_blob_gas >= blob_gas_price
  2. Funded upfront: sender_balance >= tx.value + max_gas_fee, where max_gas_fee = tx.gas * tx.max_fee_per_gas + blob_gas * blob_gas_price

Block level base fees would instead require budget balance to hold only at the level of the block, potentially allowing underpriced txs, which are not willing to pay the current basefee, as well as underfunded txs, where the sender balance is too low to fund the worst case execution upfront. The classic use case here are sponsored txs, or txs where the sender starts without any eth but gets eth over the course of the execution, e.g. by swapping a token for it.

In the delayed execution paradigm, we are slightly restricted in how we can go about allowing such use cases, because we always have to make sure that txs are sufficiently funded upfront. In particular, static verifiability of validity is incompatible with the approach of optimistically executing the block and invalidating it if by the end the protocol has not been made whole for all gas usage. What we can do instead is to still require upfront payment, but collect it from the builder whenever the sender does not want to or cannot pay. In other words, there are three possibilities at tx execution time:

  • Charge the sender upfront: if the transaction prices are higher than the basefees and the sender is funded (1. and 2. hold), we execute as usual, charging the sender upfront, refunding them the unused gas and sending priority fees to the builder. The builder is also refunded the inclusion costs that were reserved at the beginning of the block's execution.
  • Charge the builder upfront: if not, but the builder is funded upfront, meaning coinbase_balance >= (tx.gas - inclusion_gas) * base_fee_per_gas, and the sender has at least tx.value to send out, then the builder pays upfront, and at the end collects gas refunds and all fees from the sender (which might still be insufficient to recoup the costs)
  • Skip txs: if all else fails, the tx is skipped and the builder is not refunded the inclusion costs

Approach 2: optimistic attesting

This is a CL-based approach. We simply drop the requirement to validate beacon_block.execution_payload when attesting to a BeaconBlock in its proposal slot. In other words, the CL does not need to wait for the EL to finish executing and respond that the payload is valid: once the CL has done all validations on its side, it can attest. This is only the case until the end of the slot however: whenever beacon_block.slot < current_slot, attesting to beacon_block requires beacon_block.execution_payload to be valid.

If the payload turns out to be invalid, we mark it as such and never output it as part of the canonical chain, never attest to it or to descendants of it etc In practice, the easiest way to do so would be completely remove it from our fork-choice store, in particular from store.blocks, which is essentially our block tree. However, there's an issue: the initial attestations to this block would end up being wasted, because they point to a block that is not in the block tree anymore. This is ok when it comes to the block itself, but it's problematic when it comes to the block's ancestors, which might themselves be perfectly valid. To drive the point home, consider the following scenario:

  1. At slot N, invalid block B is proposed on top of valid block A
  2. Everyone attests to B, since they do not yet execute the payload. Both A and B gain one committee's worth of attestation weight
  3. After slot N, everyone has completed the execution of B and finds it invalid. B is discarded, and so are all attestations to B. Block A loses one committee's worth of attestation weight

When we look at the same scenario without optimistic attesting, what would happen is instead:

  1. At slot N, invalid block B is proposed on top of valid block A
  2. No one attests to B, since they find it invalid. Everyone attests to A instead. A gains one committee's worth of attestation weight (and doesn't later lose it)

Essentially, we break a monotonicity property of the fork-choice: while a block is winning in the fork-choice, i.e., while its subtree has more weight than the competing subtrees and thus it is in the canonical chain, its weight advantage only grows with each slot, because all honest validators attest to it (and assuming honest majority). Another way to look at it is that honest attesting weight can be wasted by the adversary, while adversarial weight from the same slot can in the meantime accumulate on a competing block.

In theory, there's a straightforward solution. When we find out that B is invalid, we don't completely throw it out of our fork-choice. Instead, we keep track of B's existence and of its place in the block tree, for example by storing a header, and of the fact that it is invalid. Then, latest_messages pointing to B can be counted for B's parent, while B itself can always be excluded from the canonical chain.

Block propagation

Note that block propagation already does not involve fully validating the block. In fact, it does not require even fully validating everything other than the execution payload. Mainly, DoS resistance relies on checking the proposer's signature, and whether the proposer is the expected one based on the current shuffle. Nothing about this changes in the slightest with optimistic attesting.

Syncing/requesting

When someone sees an attestation that points to a block they don't have, they request that block. If the block is invalid but still relevant to the fork-choice because of what I mentioned above (which won't be the case after a later block has been finalized), peers should respond with the header, and signal that the block is invalid. With the header, the requesting node can now correctly utilize all attestations to it. If a peer were to instead respond with the full block, it would be downscored or disconnected once the block fails to be validated.

Fully optimistic validation

We could go further than not requiring the execution payload to be valid, by not requiring the beacon block to be valid at all. Essentially, we'd delay the entire beacon block state transition function, rather than only the EL component of it. As for block propagation, validation for the initial attestation would be mainly centered on the proposer's signature, rather than on the content. It would essentially just be a way of saying "by the attestation deadline, I have seen a correctly signed message from the expected proposer, commiting to a block", without saying anything about whether or not this block is valid.