diff --git a/EIPS/eip-6404.md b/EIPS/eip-6404.md new file mode 100644 index 00000000000000..692adc8652b389 --- /dev/null +++ b/EIPS/eip-6404.md @@ -0,0 +1,1024 @@ +--- +eip: 6404 +title: Transition to SSZ +description: Unifies the CL and EL block representations +author: Etan Kissling (@etan-status) +discussions-to: https://ethereum-magicians.org/t/eip-6404-transition-to-ssz/12783 +status: Draft +type: Standards Track +category: Core +created: 2023-01-30 +requires: 155, 658, 1559, 2718, 2930, 4844, 4895 +--- + +## Abstract + +This EIP aligns the definition of the EL block with the CL `ExecutionPayload` structure. + +## Motivation + +While the CL `ExecutionPayload` and the EL block structure map to each other, they are differently encoded. This EIP aims to align the encoding between them, taking advantage of the more modern SSZ format used by the CL. This brings several advantages: + +1. **Reducing complexity:** Merkle-Patricia Tries (MPT) are hard to work with. Replacing them with SSZ leaves only the state trie in the legacy MPT format. + +2. **Better for smart contracts:** The SSZ format is optimized for production and verification of merkle proofs. It allows proving specific fields of containers and allows chunked processing, e.g., to support handling transactions that do not fit into calldata. + +3. **Better for light clients:** Light clients with access to the CL `ExecutionPayload` no longer need to obtain the matching EL block header to verify proofs rooted in `transactions_root` or `withdrawals_root`. + +4. **Reducing ambiguity:** The names `transactions_root` and `withdrawals_root` are currently used by both EL and CL to refer to different roots. The EL refers to a MPT root, the CL refers to a SSZ root. + +## 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. + +### SSZ + +#### `Optional[T]` + +A new [SSZ type](https://github.com/ethereum/consensus-specs/blob/67c2f9ee9eb562f7cc02b2ff90d92c56137944e1/ssz/simple-serialize.md) is introduced to represent `Optional[T]` values. + +- If value is `None`, serialize as `[]`, and merkleize as `List[T, 1]` with length `0`. +- If value is not `None`, serialize as `T`, and merkleize as `List[T, 1]` with length `1`. +- Serialize `Optional[T]` as variable-size object (create offset-table entry in enclosing containers). + +#### Fixed capacity `Container` (TBD) + +The merkleization for SSZ `Container` structures generates a tree of minimum depth to contain all leaves. This is problematic whenever the number of leaves changes to require a different power of 2, as all `GeneralizedIndex` values change. + +SSZ `Container` structures should be annotated with a maximum capacity, similar to SSZ `List`. Unused space would be zero-extended accordingly. + +TBD + +### Transactions + +This design proposes a single `Transaction` format. To include a transaction from an earlier format into a block, it needs to be upgraded to the proposed format using `upgrade_signed_rlp_transaction_to_ssz`. For the purpose of signature validation, the originally signed transaction hash can be recomputed using `get_transaction_sighash`. For applications that need a perpetually stable identifier, `get_transaction_id` can be used. + +#### New transaction type + +A new [EIP-2718](./eip-2718.md) transaction type `0x00` is defined to represent wrapped [EIP-155](./eip-155.md) transactions. This transaction type is only used as part of the new `Transaction` SSZ container. + +| Name | SSZ equivalent | Description | +| - | - | - | +| `TransactionType` | `uint8` | [EIP-2718](./eip-2718.md) transaction type (range `[0, 0x7f]`) + +| Name | Value | +| - | - | +| [`TRANSACTION_TYPE_EIP155`](./eip-155.md) | `TransactionType(0x00)` (only used in SSZ `Transaction`) | +| [`TRANSACTION_TYPE_EIP2930`](./eip-2930.md#definitions) | `TransactionType(0x01)` | +| [`TRANSACTION_TYPE_EIP1559`](./eip-1559.md#specification) | `TransactionType(0x02)` | +| [`TRANSACTION_TYPE_EIP4844`](./eip-4844.md#parameters) | `TransactionType(0x05)` | + +#### Execution block changes (Transactions) + +A new `Transaction` SSZ container is introduced to represent transactions. + +| Name | SSZ equivalent | +| - | - | +| [`BLSFieldElement`](./eip-4844.md#type-aliases) | `uint256` | +| [`VersionedHash`](./eip-4844.md#type-aliases) | `Bytes32` | +| [`KZGCommitment`](./eip-4844.md#type-aliases) | `Bytes48` | +| [`KZGProof`](./eip-4844.md#type-aliases) | `Bytes48` | + +| Name | Value | +| - | - | +| [`FIELD_ELEMENTS_PER_BLOB`](./eip-4844.md#parameters) | `uint64(2**12)` (= 4,096) | +| [`MAX_VERSIONED_HASHES_LIST_SIZE`](./eip-4844.md#parameters) | `uint64(2**24)` (= 16,777,216) | +| [`MAX_CALLDATA_SIZE`](./eip-4844.md#parameters) | `uint64(2**24)` (= 16,777,216) | +| [`MAX_ACCESS_LIST_SIZE`](./eip-4844.md#parameters) | `uint64(2**24)` (= 16,777,216) | +| [`MAX_ACCESS_LIST_STORAGE_KEYS`](./eip-4844.md#parameters) | `uint64(2**24)` (= 16,777,216) | +| [`MAX_TX_WRAP_KZG_COMMITMENTS`](./eip-4844.md#parameters) | `uint64(2**24)` (= 16,777,216) | +| [`LIMIT_BLOBS_PER_TX`](./eip-4844.md#parameters) | `uint64(2**24)` (= 16,777,216) | + +```python +class AccessTuple(Container): + address: Address + storage_keys: List[Hash, MAX_ACCESS_LIST_STORAGE_KEYS] + +class Transaction(Container): + tx_type: TransactionType # EIP-2718 + chain_id: uint64 # EIP-155 + nonce: uint64 + max_priority_fee_per_gas: uint256 # EIP-1559 + max_fee_per_gas: uint256 # aka `gasprice` + gas_limit: uint64 # aka `startgas` + to: Optional[Address] # None: deploy contract + value: uint256 + data: ByteList[MAX_CALLDATA_SIZE] + access_list: List[AccessTuple, MAX_ACCESS_LIST_SIZE] # EIP-2930 + blob_versioned_hashes: List[VersionedHash, MAX_VERSIONED_HASHES_LIST_SIZE] # EIP-4844 + +class ECDSASignature(Container): + y_parity: boolean # EIP-2930 + r: uint256 + s: uint256 + +class SignedTransaction(Container): + tx: Transaction + signature: ECDSASignature + +class BlobTransactionExtension(Container): + blob_kzgs: List[KZGCommitment, MAX_TX_WRAP_KZG_COMMITMENTS] + blobs: List[Vector[BLSFieldElement, FIELD_ELEMENTS_PER_BLOB], LIMIT_BLOBS_PER_TX] + kzg_aggregated_proof: KZGProof + +class TransactionPayload(Container): + signed_tx: SignedTransaction + blob: Optional[BlobTransactionExtension] # EIP-4844 +``` + +On the network, `TransactionPayload` is preceded by a single byte, `TRANSACTION_TYPE_EIP4844` to distinguish from other transaction formats according to [EIP-2718](./eip-2718.md), regardless of the inner `signed_tx.tx.tx_type`. + +The `blocks.transactions` list remains RLP encoded: + +```python +block.transactions = RLP([ + SSZ.encode(transaction_0), + SSZ.encode(transaction_1), + SSZ.encode(transaction_2), + ... +]) +``` + +#### Consensus `ExecutionPayload` changes (Transactions) + +The [CL `ExecutionPayload`](https://github.com/ethereum/consensus-specs/blob/67c2f9ee9eb562f7cc02b2ff90d92c56137944e1/specs/eip4844/beacon-chain.md#executionpayload) is updated to replace the existing [CL `Transaction`](https://github.com/ethereum/consensus-specs/blob/67c2f9ee9eb562f7cc02b2ff90d92c56137944e1/specs/bellatrix/beacon-chain.md#custom-types) container with the new `SignedTransaction` container. + +| Name | Value | Description | +| - | - | - | +| [`MAX_TRANSACTIONS_PER_PAYLOAD`](https://github.com/ethereum/consensus-specs/blob/67c2f9ee9eb562f7cc02b2ff90d92c56137944e1/specs/bellatrix/beacon-chain.md#execution) | `uint64(2**20)` (= 1,048,576) | Maximum amount of transactions allowed in each block | + +```python +class ExecutionPayload(Container): + ... + transactions: List[SignedTransaction, MAX_TRANSACTIONS_PER_PAYLOAD] # Formerly, `Transaction` + ... +``` + +#### Execution block header changes (Transactions) + +The execution block header's `transactions_root` is updated to the `transactions_root` definition of the [consensus-specs `ExecutionPayloadHeader`](https://github.com/ethereum/consensus-specs/blob/67c2f9ee9eb562f7cc02b2ff90d92c56137944e1/specs/eip4844/beacon-chain.md#executionpayloadheader). + +To compute the new `transactions_root`, the list of individual `SignedTransaction` containers is represented as a SSZ `List`. + +```python +block_header.transactions_root == hash_tree_root(List[SignedTransaction, MAX_TRANSACTIONS_PER_PAYLOAD]( + transaction_0, + transaction_1, + transaction_2, + ... +)) == hash_tree_root(List[Root, MAX_TRANSACTIONS_PER_PAYLOAD]( + hash_tree_root(transaction_0), + hash_tree_root(transaction_1), + hash_tree_root(transaction_2), + ... +)) +``` + +##### Helpers (Transactions) + +```python +def is_post_eip4844(tx_type: TransactionType) -> bool: + return tx_type in [ + TRANSACTION_TYPE_EIP4844, + ] +``` + +```python +def is_post_eip1559(tx_type: TransactionType) -> bool: + return tx_type in [ + TRANSACTION_TYPE_EIP4844, + TRANSACTION_TYPE_EIP1559, + ] +``` + +```python +def is_post_eip2930(tx_type: TransactionType) -> bool: + return tx_type in [ + TRANSACTION_TYPE_EIP4844, + TRANSACTION_TYPE_EIP1559, + TRANSACTION_TYPE_EIP2930, + ] +``` + +```python +def is_post_eip155(tx_type: TransactionType) -> bool: + return tx_type in [ + TRANSACTION_TYPE_EIP4844, + TRANSACTION_TYPE_EIP1559, + TRANSACTION_TYPE_EIP2930, + TRANSACTION_TYPE_EIP155, + ] +``` + +##### Transaction validation + +```python +def get_common_transaction_rlp_schema(tx: Transaction) -> bytes: + tx_type = tx.tx_type + assert not is_post_eip4844(tx_type) + assert len(tx.blob_versioned_hashes) == 0 + + schema = [] + if is_post_eip2930(tx_type): + schema.append((big_endian_int, tx.chain_id)) + schema.append((big_endian_int, tx.nonce)) + if is_post_eip1559(tx_type): + schema.append((big_endian_int, tx.max_priority_fee_per_gas)) + else: + assert tx.max_priority_fee_per_gas == uint256(0) + schema.append((big_endian_int, tx.max_fee_per_gas)) + schema.append((big_endian_int, tx.gas_limit)) + if tx.to is not None: + schema.append((binary, tx.to)) + else: + schema.append((binary, [])) + schema.append((big_endian_int, tx.value)) + schema.append((binary, tx.data)) + if is_post_eip2930(tx_type): + access_list = [] + for access_tuple in tx.access_list: + access_list.append((access_tuple.address, access_tuple.storage_keys)) + schema.append((List([Binary[20, 20], List([Binary[32, 32]])]), access_list)) + else: + assert len(tx.access_list) == 0 + return schema +``` + +```python +def get_transaction_sighash(tx: Transaction) -> bytes: + tx_type = tx.tx_type + + if is_post_eip4844(tx_type): + return hash_tree_root(tx) + + signed_data = [] + if is_post_eip2930(tx_type): + signed_data.append(tx_type) + + schema = get_common_transaction_rlp_schema(tx) + if not is_post_eip2930(tx_type) and is_post_eip155(tx_type): + schema.append((big_endian_int, tx.chain_id)) + schema.append((big_endian_int, 0)) + schema.append((big_endian_int, 0)) + else: + assert tx.chain_id == uint64(0) + + sedes = List([schema for schema, _ in schema]) + values = [value for _, value in schema] + signed_data.append(encode(values, sedes)) + return keccak(signed_data) +``` + +```python +def get_transaction_signature_v(signed_tx: SignedTransaction) -> uint256: + tx_type = signed_tx.tx.tx_type + + y_int = (1 if signed_tx.signature.y_parity else 0) + if is_post_eip2930(tx_type): + return y_int + if is_post_eip155(tx_type): + return y_int + signed_tx.tx.chain_id * 2 + 35 + assert signed_tx.tx.chain_id == uint64(0) + return y_int + 27 +``` + +```python +def get_transaction_id(signed_tx: SignedTransaction) -> bytes: + tx_type = signed_tx.tx.tx_type + + if is_post_eip4844(tx_type): + return hash_tree_root(signed_tx) + + schema = get_common_transaction_rlp_schema(signed_tx.tx) + schema.append((big_endian_int, get_transaction_signature_v(signed_tx))) + schema.append((big_endian_int, signed_tx.signature.r)) + schema.append((big_endian_int, signed_tx.signature.s)) + + sedes = List([schema for schema, _ in schema]) + values = [value for _, value in schema] + return keccak(encode(values, sedes)) +``` + +```python +def get_transaction_signer(signed_tx: SignedTransaction) -> Address: + return ecrecover( + get_transaction_sighash(signed_tx.tx), + get_transaction_signature_v(signed_tx), + signed_tx.signature.r, + signed_tx.signature.s, + ) +``` + +```python +def validate_transaction_payload(payload: TransactionPayload): + if payload.blob is None: + assert len(payload.signed_tx.tx.blob_versioned_hashes) == 0 + else: + # Based on EIP-4844 logic + versioned_hashes = payload.signed_tx.tx.blob_versioned_hashes + commitments = payload.blob.blob_kzgs + blobs = payload.blob.blobs + # note: assert blobs are not malformatted + assert len(versioned_hashes) == len(commitments) == len(blobs) + + # Verify that commitments match the blobs by checking the KZG proof + assert verify_aggregate_kzg_proof(blobs, commitments, payload.blob.kzg_aggregated_proof) + + # Now that all commitments have been verified, check that versioned_hashes matches the commitments + for versioned_hash, commitment in zip(versioned_hashes, commitments): + assert versioned_hash == kzg_to_versioned_hash(commitment) +``` + +##### Upgrading transactions to SSZ + +```python +def upgrade_signed_rlp_transaction_to_ssz(rlp_encoded_tx: bytes) -> SignedTransaction: + tx_type = rlp_encoded_tx[0] + + assert not is_post_eip4844(tx_type) + + schema = [] + if is_post_eip2930(tx_type): + schema.append(('chain_id', big_endian_int)) + schema.append(('nonce', big_endian_int)) + if is_post_eip1559(tx_type): + schema.append(('max_priority_fee_per_gas', big_endian_int)) + schema.append(('max_fee_per_gas', big_endian_int)) + schema.append(('gas_limit', big_endian_int)) + schema.append(('to', binary)) + schema.append(('value', big_endian_int)) + schema.append(('data', binary)) + if is_post_eip2930(tx_type): + schema.append(('access_list', List([Binary[20, 20], List([Binary[32, 32]])]))) + schema.append(('v', big_endian_int)) + schema.append(('r', big_endian_int)) + schema.append(('s', big_endian_int)) + + class RLPTransaction(rlp.Serializable): + fields = tuple(schema) + + if is_post_eip2930(tx_type): + payload_offset = 1 + else: + payload_offset = 0 + pre = RLPTransaction.deserialize(rlp_encoded_tx[payload_offset:]) + + if is_post_eip2930(tx_type): + chain_id = pre.chain_id + assert pre.v in (0, 1) + y_parity = (pre.v != 0) + else: + assert 0xc0 <= tx_type <= 0xfe + post_eip155 = pre.v not in (27, 28) + if post_eip155: + tx_type = TRANSACTION_TYPE_EIP155 + chain_id = ((pre.v - 35) // 2) + y_parity = ((pre.v - chain_id) != 0) + else: + chain_id = 0 + y_parity = ((pre.v - 27) != 0) + + if len(pre.to) == 20: + to = Address(pre.to) + else: + assert len(pre.to) == 0 + to = None + + tx = Transaction( + tx_type=tx_type, + chain_id=chain_id, + nonce=pre.nonce, + max_fee_per_gas=pre.max_fee_per_gas, + gas_limit=pre.gas_limit, + to=to, + value=pre.value, + data=pre.data, + ) + if is_post_eip2930(tx_type): + for access_tuple in pre.access_list: + address, storage_keys = access_tuple + tx.access_list.append(AccessTuple( + address=address, + storage_keys=storage_keys, + )) + if is_post_eip1559(tx_type): + tx.max_priority_fee_per_gas = pre.max_priority_fee_per_gas + + return SignedTransaction( + tx=tx, + signature=ECDSASignature( + y_parity=y_parity, + r=pre.r, + s=pre.s, + ), + ) +``` + +### Receipts + +| Name | SSZ equivalent | +| - | - | +| `Topic` | `Bytes32` | + +| Name | Value | +| - | - | +| [`BYTES_PER_LOGS_BLOOM`](https://github.com/ethereum/consensus-specs/blob/67c2f9ee9eb562f7cc02b2ff90d92c56137944e1/specs/bellatrix/beacon-chain.md#execution) | `uint64(2**8)` (= 256) | +| `MAX_TOPICS_PER_LOG` | `uint64(2**24)` (= 16,777,216) | +| `MAX_LOG_DATA_SIZE` | `uint64(2**24)` (= 16,777,216) | +| `MAX_LOGS_PER_RECEIPT` | `uint64(2**24)` (= 16,777,216) | +| `MAX_RECEIPTS_PER_PAYLOAD` | `uint64(2**20)` (= 1,048,576) | Maximum amount of receipts allowed in each block | + +```python +class Log(Container): + address: Address + topics: List[Topic, MAX_TOPICS_PER_LOG] + data: ByteVector[MAX_LOG_DATA_SIZE] + +class Receipt(Container): + tx_type: TransactionType # EIP-2718 + status: uint256 # EIP-658 + cumulative_transaction_gas_used: uint64 + logs_bloom: ByteVector[BYTES_PER_LOGS_BLOOM] + logs: List[Log, MAX_LOGS_PER_RECEIPT] +``` + +The `blocks.receipts_root` is updated to a SSZ root. To compute the new `receipts_root`, the list of individual `Receipt` containers is represented as a SSZ `List`. + +```python +block.receipts_root == hash_tree_root(List[Receipt, MAX_RECEIPTS_PER_PAYLOAD]( + receipt_0, + receipt_1, + receipt_2, + ... +)) == hash_tree_root(List[Root, MAX_RECEIPTS_PER_PAYLOAD]( + hash_tree_root(receipt_0), + hash_tree_root(receipt_1), + hash_tree_root(receipt_2), + ... +)) +``` + +#### Consensus `ExecutionPayload` changes (Receipts) + +The [CL `ExecutionPayload`](https://github.com/ethereum/consensus-specs/blob/67c2f9ee9eb562f7cc02b2ff90d92c56137944e1/specs/eip4844/beacon-chain.md#executionpayload) and [CL `ExecutionPayloadHeader`](https://github.com/ethereum/consensus-specs/blob/67c2f9ee9eb562f7cc02b2ff90d92c56137944e1/specs/eip4844/beacon-chain.md#executionpayloadheader) are updated to take account the new CL `receipts_root` type. + +```python +class ExecutionPayload(Container): + ... + receipts_root: Root # Formerly, `Bytes32` + ... + + +class ExecutionPayloadHeader(Container): + ... + receipts_root: Root # Formerly, `Bytes32` + ... +``` + +#### Execution block header changes (Receipts) + +The execution block header's `receipts_root` is updated to match the execution block's `receipts_root` definition. + +```python +block_header.receipts_root = block.receipts_root +``` + +##### Receipt validation + +```python +def get_common_receipt_rlp_schema(receipt: Receipt) -> bytes: + tx_type = receipt.tx_type + assert not is_post_eip4844(tx_type) + + schema = [] + schema.append((big_endian_int, receipt.status)) + schema.append((big_endian_int, receipt.cumulative_transaction_gas_used)) + schema.append((Binary[256, 256], receipt.logs_bloom)) + for log in receipt.logs: + schema.append((List([Binary[20, 20], List([Binary[32, 32]]), List([binary])]), log)) + return schema +``` + +```python +def get_receipt_id(receipt: Receipt) -> bytes: + tx_type = receipt.tx_type + + if is_post_eip4844(tx_type): + return hash_tree_root(receipt) + + schema = get_common_receipt_rlp_schema(receipt) + + sedes = List([schema for schema, _ in schema]) + values = [value for _, value in schema] + return keccak(encode(values, sedes)) +``` + +##### Upgrading receipts to SSZ + +```python +def upgrade_rlp_receipt_to_ssz(rlp_encoded_receipt: bytes) -> Receipt: + tx_type = rlp_encoded_receipt[0] + + assert not is_post_eip4844(tx_type) + + schema = [] + schema.append(('status', big_endian_int)) + schema.append(('cumulative_transaction_gas_used', big_endian_int)) + schema.append(('logs_bloom', Binary[256, 256])) + schema.append(('logs', List([Binary[20, 20], List([Binary[32, 32]]), List([binary])]))) + + class RLPReceipt(rlp.Serializable): + fields = tuple(schema) + + if is_post_eip2930(tx_type): + payload_offset = 1 + else: + payload_offset = 0 + pre = RLPReceipt.deserialize(rlp_encoded_receipt[payload_offset:]) + + receipt = Receipt( + tx_type=tx_type, + status=pre.status, + cumulative_transaction_gas_used=pre.cumulative_transaction_gas_used, + logs_bloom=pre.logs_bloom, + ) + for log in pre.logs: + address, topics, data = log + receipt.logs.append(Log( + address=address, + topics=topics, + data=data, + )) + + return receipt +``` + +### Withdrawals + +#### Execution block changes (Withdrawals) + +The individual [`Withdrawal` objects](./eip-4895.md) within the execution block's `withdrawals` list are now represented as SSZ `Container`s, matching the definition of the [consensus-specs `Withdrawal`](https://github.com/ethereum/consensus-specs/blob/67c2f9ee9eb562f7cc02b2ff90d92c56137944e1/specs/capella/beacon-chain.md#withdrawal). + +```python +class Withdrawal(Container): + index: WithdrawalIndex + validator_index: ValidatorIndex + address: ExecutionAddress + amount: Gwei +``` + +The `blocks.withdrawals` list remains RLP encoded: + +```python +block.withdrawals = RLP([ + SSZ.encode(withdrawal_0), + SSZ.encode(withdrawal_1), + SSZ.encode(withdrawal_2), + ... +]) +``` + +#### Execution block header changes (Withdrawals) + +The execution block header's `withdrawals_root` is updated to the `withdrawals_root` definition of the [consensus-specs `ExecutionPayloadHeader`](https://github.com/ethereum/consensus-specs/blob/67c2f9ee9eb562f7cc02b2ff90d92c56137944e1/specs/eip4844/beacon-chain.md#executionpayloadheader). + +To compute the new `withdrawals_root`, the list of individual `Withdrawal` containers is represented as a SSZ `List`. + +| Name | Value | Description | +| - | - | - | +| [`MAX_WITHDRAWALS_PER_PAYLOAD`](https://github.com/ethereum/consensus-specs/blob/67c2f9ee9eb562f7cc02b2ff90d92c56137944e1/specs/capella/beacon-chain.md#execution) | `uint64(2**4)` (= 16) | Maximum amount of withdrawals allowed in each block | + +```python +block_header.withdrawals_root == hash_tree_root(List[Withdrawal, MAX_WITHDRAWALS_PER_PAYLOAD]( + withdrawal_0, + withdrawal_1, + withdrawal_2, + ... +)) == hash_tree_root(List[Root, MAX_WITHDRAWALS_PER_PAYLOAD]( + hash_tree_root(withdrawal_0), + hash_tree_root(withdrawal_1), + hash_tree_root(withdrawal_2), + ... +)) +``` + +### Deposits + +Same as withdrawals, but ideally we have SSZ by the time deposits arrive. + +Also, if more MPT are introduced by other EIP, same story. + +### Block structure itself (TBD) + +Converting the execution block and the execution block header to SSZ would align their `hash_tree_root` value with `hash_tree_root(ExecutionPayload)` and `hash_tree_root(ExecutionPayloadHeader)` on the CL. This would allow forming end-to-end SSZ proofs from any execution data (except data rooted in `state_root`). Such proofs could share a generic SSZ multiproof API format across consensus and execution layers. + +- The CL `ExecutionPayloadHeader` would become equivalent to the EL block header. The `block_hash` field in the `ExecutionPayloadHeader` can be removed, shortening the merkle proof for applications interested in just the execution block hash. + +- The CL `ExecutionPayload` would become a subset of the EL block, with the `receipts` field replaced by `receipts_root`, as `Receipt` structures are only stored by the EL. + +TBD + +## Rationale + +### Why not multiple `Transaction` containers? + +- **Superset of all existing transaction types:** The new `Transaction` container supports all existing transaction types. + +- **Discourage legacy transaction types:** Legacy transactions incur a higher computational cost for inclusion in a block. A penalty fee could be charged to legacy transactions to promote timely upgrade to latest `Transaction` format. + +- **Static merkle tree shape:** Compared to approaches based on SSZ `Union`, it is not necessary to branch on `tx_type` to determine the `GeneralizedIndex` for common fields. For example, a proof for a `Transaction`'s `value` field always has the exact same structure. + +- **Shorter merkle proofs:** Compared to approaches based on SSZ `Onion` (merkleized as \[o]bject, serialized as u\[nion]), there is less nesting in the merkle tree. This makes proofs shorter (32 bytes per nesting level) + +- **Easy to deprecate legacy transactions:** If a transaction type is no longer allowed, the tree shape remains static. In non-archive settings, the `upgrade_signed_rlp_transaction_to_ssz`, `get_transaction_id` and `get_transaction_sighash` functions can be adjusted to drop support for legacy transaction types. + +- **Prior art:** Multiple modules of Ethereum already process common fields in a unified way. The CL pytests use `is_post_fork` to conditionally enable logic. The execution JSON-RPC reports transaction fields under the same name regardless of type. The CL light client protocol incorporates a very similar mechanism for upgrading CL `ExecutionPayloadHeader` to later formats: [`get_transaction_sighash` equivalent](https://github.com/ethereum/consensus-specs/blob/67c2f9ee9eb562f7cc02b2ff90d92c56137944e1/specs/eip4844/light-client/sync-protocol.md#modified-get_lc_execution_root) / [`upgrade_to_latest` equivalent](https://github.com/ethereum/consensus-specs/blob/67c2f9ee9eb562f7cc02b2ff90d92c56137944e1/specs/eip4844/light-client/full-node.md#modified-block_to_light_client_header). + +- **Smaller code size:** The design promotes single, compact functions for `get_transaction_id` and `get_transaction_sighash`, instead of multiple copies with slight differences. This is useful for resource-constrained environments such as smart contracts or IoT applications. + +## Backwards Compatibility + +Applications that solely rely on the legacy RLP encoding but do not rely on the MPT commitment in the block header can still be used through a re-encoding proxy. + +Applications that rely on the replaced MPT root commitments in the block header can no longer find that information. Analysis is required whether affected applications have a migration path available. If major applications can't migrate, can keep the legacy MPT commitment in the header for now, and introduce the new SSZ trees next to them. + +- **Transactions:** The txid is commonly used by block explorers. A helper function `get_transaction_id` is specified to replicate historic transaction IDs. + +- **Receipts:** The concept of a "receipt ID" is not commonly used. There is also no JSON-RPC endpoint to obtain a receipts proof. It is not expected that major applications rely on the legacy MPT for receipts. + +- **Withdrawals:** Withdrawals were introduced in Capella/Shanghai. It is not expected that major applications rely on the legacy MPT for withdrawals. + +## Test Cases + +### SSZ tests (Transactions) + +```python +payload = TransactionPayload() +payload.signed_tx.signature.y_parity = true +payload.signed_tx.signature.r = uint256(0x02) +payload.signed_tx.signature.s = uint256(0x03) + +# Legacy without optional fields +payload.signed_tx.tx.tx_type = uint8(0xc0) +payload.signed_tx.tx.nonce = uint64(0xc000000000000000) +payload.signed_tx.tx.max_fee_per_gas = uint256(0x04) +payload.signed_tx.tx.gas_limit = uint64(0x05) +payload.signed_tx.tx.value = uint256(0x06) +SSZ.encode(payload) == fromhex( + # offset(payload.signed_tx) + "08000000" + + # offset(payload.blob) (unused by legacy) + "d6000000" + + # offset(signed_tx.tx) + "45000000" + + # signed_tx.signature + "01" + + "0200000000000000000000000000000000000000000000000000000000000000" + + "0300000000000000000000000000000000000000000000000000000000000000" + + # tx.tx_type + "c0" + + # tx.chain_id (unused by legacy) + "0000000000000000" + + # tx.nonce + "00000000000000c0" + + # tx.max_priority_fee_per_gas (unused by legacy) + "0000000000000000000000000000000000000000000000000000000000000000" + + # tx.max_fee_per_gas + "0400000000000000000000000000000000000000000000000000000000000000" + + # tx.gas_limit + "0500000000000000" + + # offset(tx.to) + "89000000" + + # tx.value + "0600000000000000000000000000000000000000000000000000000000000000" + + # offset(tx.data) + "89000000" + + # offset(tx.access_list) (unused by legacy) + "89000000" + + # offset(tx.blob_versioned_hashes) (unused by legacy) + "89000000" +) + +# EIP-155 +payload.signed_tx.tx.tx_type = uint8(0x00) +payload.signed_tx.tx.chain_id = uint64(0x07) +payload.signed_tx.tx.to = fromhex("0000000000000000000000000000000000000008") +payload.signed_tx.tx.data = [0x09] +SSZ.encode(payload) == fromhex( + # offset(payload.signed_tx) + "08000000" + + # offset(payload.blob) (unused by EIP-155) + "eb000000" + + # offset(signed_tx.tx) + "45000000" + + # signed_tx.signature + "01" + + "0200000000000000000000000000000000000000000000000000000000000000" + + "0300000000000000000000000000000000000000000000000000000000000000" + + # tx.tx_type + "00" + + # tx.chain_id + "0700000000000000" + + # tx.nonce + "00000000000000c0" + + # tx.max_priority_fee_per_gas (unused by EIP-155) + "0000000000000000000000000000000000000000000000000000000000000000" + + # tx.max_fee_per_gas + "0400000000000000000000000000000000000000000000000000000000000000" + + # tx.gas_limit + "0500000000000000" + + # offset(tx.to) + "89000000" + + # tx.value + "0600000000000000000000000000000000000000000000000000000000000000" + + # offset(tx.data) + "9d000000" + + # offset(tx.access_list) (unused by EIP-155) + "9e000000" + + # offset(tx.blob_versioned_hashes) (unused by EIP-155) + "9e000000" + + # tx.to + "0000000000000000000000000000000000000008" + + # tx.data + "09" +) + +# EIP-2930 +payload.signed_tx.tx.tx_type = uint8(0x01) +payload.signed_tx.tx.nonce = uint64(0x0a) +payload.signed_tx.tx.access_list = [ + AccessTuple( + address=fromhex("000000000000000000000000000000000000000b"), + storage_keys=[ + fromhex("000000000000000000000000000000000000000000000000000000000000000c"), + fromhex("000000000000000000000000000000000000000000000000000000000000000d"), + ], + ), + AccessTuple( + address=fromhex("000000000000000000000000000000000000000e"), + ), +] +SSZ.encode(payload) == fromhex( + # offset(payload.signed_tx) + "08000000" + + # offset(payload.blob) (unused by EIP-2930) + "06301000" + + # offset(signed_tx.tx) + "45000000" + + # signed_tx.signature + "01" + + "0200000000000000000000000000000000000000000000000000000000000000" + + "0300000000000000000000000000000000000000000000000000000000000000" + + # tx.tx_type + "01" + + # tx.chain_id + "0700000000000000" + + # tx.nonce + "0a00000000000000" + + # tx.max_priority_fee_per_gas (unused by EIP-2930) + "0000000000000000000000000000000000000000000000000000000000000000" + + # tx.max_fee_per_gas + "0400000000000000000000000000000000000000000000000000000000000000" + + # tx.gas_limit + "0500000000000000" + + # offset(tx.to) + "89000000" + + # tx.value + "0600000000000000000000000000000000000000000000000000000000000000" + + # offset(tx.data) + "9d000000" + + # offset(tx.access_list) + "9e000000" + + # offset(tx.blob_versioned_hashes) (unused by EIP-2930) + "16010000" + + # tx.to + "0000000000000000000000000000000000000008" + + # tx.data + "09" + + # offset(tx.access_tuple[0]) + "08000000" + + # offset(tx.access_tuple[1]) + "60000000" + + # access_tuple[0].address + "000000000000000000000000000000000000000b" + + # offset(access_tuple[0].storage_keys) + "18000000" + + # access_tuple[0].storage_keys[0] + "000000000000000000000000000000000000000000000000000000000000000c" + + # access_tuple[0].storage_keys[1] + "000000000000000000000000000000000000000000000000000000000000000d" + + # access_tuple[1].address + "000000000000000000000000000000000000000e" + + # offset(access_tuple[1].storage_keys) + "18000000" +) + +# EIP-1559 +payload.signed_tx.tx.tx_type = uint8(0x02) +payload.signed_tx.tx.max_priority_fee_per_gas = uint256(0x0f) +SSZ.encode(payload) == fromhex( + # offset(payload.signed_tx) + "08000000" + + # offset(payload.blob) (unused by EIP-1559) + "06301000" + + # offset(signed_tx.tx) + "45000000" + + # signed_tx.signature + "01" + + "0200000000000000000000000000000000000000000000000000000000000000" + + "0300000000000000000000000000000000000000000000000000000000000000" + + # tx.tx_type + "02" + + # tx.chain_id + "0700000000000000" + + # tx.nonce + "0a00000000000000" + + # tx.max_priority_fee_per_gas + "0f00000000000000000000000000000000000000000000000000000000000000" + + # tx.max_fee_per_gas + "0400000000000000000000000000000000000000000000000000000000000000" + + # tx.gas_limit + "0500000000000000" + + # offset(tx.to) + "89000000" + + # tx.value + "0600000000000000000000000000000000000000000000000000000000000000" + + # offset(tx.data) + "9d000000" + + # offset(tx.access_list) + "9e000000" + + # offset(tx.blob_versioned_hashes) (unused by EIP-1559) + "16010000" + + # tx.to + "0000000000000000000000000000000000000008" + + # tx.data + "09" + + # offset(tx.access_tuple[0]) + "08000000" + + # offset(tx.access_tuple[1]) + "60000000" + + # access_tuple[0].address + "000000000000000000000000000000000000000b" + + # offset(access_tuple[0].storage_keys) + "18000000" + + # access_tuple[0].storage_keys[0] + "000000000000000000000000000000000000000000000000000000000000000c" + + # access_tuple[0].storage_keys[1] + "000000000000000000000000000000000000000000000000000000000000000d" + + # access_tuple[1].address + "000000000000000000000000000000000000000e" + + # offset(access_tuple[1].storage_keys) + "18000000" +) + +# EIP-4844 +payload.signed_tx.tx.tx_type = uint8(0x05) +payload.signed_tx.tx.blob_versioned_hashes = [ + fromhex("0000000000000000000000000000000000000000000000000000000000000010"), +] +payload.blob = BlobTransactionExtension( + blob_kzgs = [ + [0x11] + [0x00] * 47, + ], + blobs = [ + [[uint256(0x12)] + [uint256(0x00)] * 4_095], + ], + kzg_aggregated_proof = [0x13] + [0x00] * 47, +) +SSZ.encode(payload) == fromhex( + # offset(payload.signed_tx) + "08000000" + + # offset(payload.blob) + "83010000" + + # offset(signed_tx.tx) + "45000000" + + # signed_tx.signature + "01" + + "0200000000000000000000000000000000000000000000000000000000000000" + + "0300000000000000000000000000000000000000000000000000000000000000" + + # tx.tx_type + "05" + + # tx.chain_id + "0700000000000000" + + # tx.nonce + "0a00000000000000" + + # tx.max_priority_fee_per_gas + "0f00000000000000000000000000000000000000000000000000000000000000" + + # tx.max_fee_per_gas + "0400000000000000000000000000000000000000000000000000000000000000" + + # tx.gas_limit + "0500000000000000" + + # offset(tx.to) + "89000000" + + # tx.value + "0600000000000000000000000000000000000000000000000000000000000000" + + # offset(tx.data) + "9d000000" + + # offset(tx.access_list) + "9e000000" + + # offset(tx.blob_versioned_hashes) + "16010000" + + # tx.to + "0000000000000000000000000000000000000008" + + # tx.data + "09" + + # offset(tx.access_tuple[0]) + "08000000" + + # offset(tx.access_tuple[1]) + "60000000" + + # access_tuple[0].address + "000000000000000000000000000000000000000b" + + # offset(access_tuple[0].storage_keys) + "18000000" + + # access_tuple[0].storage_keys[0] + "000000000000000000000000000000000000000000000000000000000000000c" + + # access_tuple[0].storage_keys[1] + "000000000000000000000000000000000000000000000000000000000000000d" + + # access_tuple[1].address + "000000000000000000000000000000000000000e" + + # offset(access_tuple[1].storage_keys) + "18000000" + + # blob_versioned_hashes[0] + "0000000000000000000000000000000000000000000000000000000000000010" + + # offset(blob.blob_kzgs) + "38000000" + + # offset(blob.blobs) + "68000000" + + # blob.kzg_aggregated_proof + "130000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + + # blob.blob_kzgs[0] + "110000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + + # blob.blobs[0] + "1200000000000000000000000000000000000000000000000000000000000000" + + "0000000000000000000000000000000000000000000000000000000000000000" + + "0000000000000000000000000000000000000000000000000000000000000000" + + ... + "0000000000000000000000000000000000000000000000000000000000000000" +) +``` + +### SSZ tests (Receipts) + +TBD + +### SSZ tests (Withdrawals) + +```python +withdrawal = Withdrawal( + index=WithdrawalIndex(0x0123), + validator_index=ValidatorIndex(0x4567), + address=fromhex("0001020304050607080910111213141516171819"), + amount=Gwei(0x89ab), +) + +SSZ.encode(withdrawal) == fromhex( + "2301000000000000" + + "6745000000000000" + + "0001020304050607080910111213141516171819" + + "ab89000000000000" +) + +hash_tree_root(withdrawal) == sha256( + sha256(fromhex( + "2301000000000000000000000000000000000000000000000000000000000000" + + "6745000000000000000000000000000000000000000000000000000000000000")) + + sha256(fromhex( + "0001020304050607080910111213141516171819000000000000000000000000" + + "ab89000000000000000000000000000000000000000000000000000000000000")) +) == fromhex("af8247942122ba68d69d2784dfd9994fa16681ff6feb2680d7922c61a899a20d") +``` + +```python +withdrawals = List[Withdrawal, MAX_WITHDRAWALS_PER_PAYLOAD](withdrawal_0, withdrawal_1, withdrawal_2) + +block_header.withdrawals_root == hash_tree_root(withdrawals) == sha256( + sha256( + sha256( + sha256( + sha256(hash_tree_root(withdrawal_0) + hash_tree_root(withdrawal_1)) + + sha256(hash_tree_root(withdrawal_2) + hash_tree_root(Withdrawal()))) + + sha256( + sha256(hash_tree_root(Withdrawal()) + hash_tree_root(Withdrawal())) + + sha256(hash_tree_root(Withdrawal()) + hash_tree_root(Withdrawal())))) + + sha256( + sha256( + sha256(hash_tree_root(Withdrawal()) + hash_tree_root(Withdrawal())) + + sha256(hash_tree_root(Withdrawal()) + hash_tree_root(Withdrawal()))) + + sha256( + sha256(hash_tree_root(Withdrawal()) + hash_tree_root(Withdrawal())) + + sha256(hash_tree_root(Withdrawal()) + hash_tree_root(Withdrawal()))))) + + fromhex("0300000000000000000000000000000000000000000000000000000000000000") # len +) +``` + +## Reference Implementation + +TBD + +## Security Considerations + +Transaction hashes are keccak256, but SSZ roots are SHA256. We could end up in a situation where we would like to mix them in same database table, etc. Hash functions are normally only collision resistant with itself. Cryptoanalysis is required to determine whether keccak256 and SHA256 are safe to mix. + +## Copyright + +Copyright and related rights waived via [CC0](../LICENSE.md).