--- eip: 6404 title: SSZ Transactions Root description: Migration of transactions MPT commitment to SSZ author: Etan Kissling (@etan-status), Vitalik Buterin (@vbuterin) discussions-to: https://ethereum-magicians.org/t/eip-6404-ssz-transactions-root/12783 status: Draft type: Standards Track category: Core created: 2023-01-30 requires: 155, 658, 1559, 2718, 2930, 4844, 6475 --- ## Abstract This EIP defines a migration process of existing Merkle-Patricia Trie (MPT) commitments for transactions to [Simple Serialize (SSZ)](https://github.com/ethereum/consensus-specs/blob/67c2f9ee9eb562f7cc02b2ff90d92c56137944e1/ssz/simple-serialize.md). ## Motivation While the consensus `ExecutionPayloadHeader` and the execution block header map to each other conceptually, they are encoded differently. This EIP aims to align the encoding of the `transactions_root`, taking advantage of the more modern SSZ format. This brings several advantages: 1. **Better for light clients:** Light clients no longer need to obtain and decode entire transactions to verify transaction related fields provided by the execution JSON-RPC API, including information about the transaction's signer and the transaction hash. 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. **Reducing complexity:** The proposed design reduces the number of use cases that require support for Merkle-Patricia Trie (MPT), RLP encoding, keccak hashing, and secp256k1 public key recovery. 4. **Reducing ambiguity:** The name `transactions_root` is currently used to refer to different roots. The execution JSON-RPC API refers to a MPT root, the consensus `ExecutionPayloadHeader` refers to an 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. ### Existing definitions Definitions from existing specifications that are used throughout this document are replicated here for reference. | Name | SSZ equivalent | | - | - | | [`Hash32`](https://github.com/ethereum/consensus-specs/blob/67c2f9ee9eb562f7cc02b2ff90d92c56137944e1/specs/phase0/beacon-chain.md#custom-types) | `Bytes32` | | [`ExecutionAddress`](https://github.com/ethereum/consensus-specs/blob/67c2f9ee9eb562f7cc02b2ff90d92c56137944e1/specs/bellatrix/beacon-chain.md#custom-types) | `Bytes20` | | [`VersionedHash`](./eip-4844.md#type-aliases) | `Bytes32` | | Name | Value | | - | - | | [`MAX_BYTES_PER_TRANSACTION`](https://github.com/ethereum/consensus-specs/blob/67c2f9ee9eb562f7cc02b2ff90d92c56137944e1/specs/bellatrix/beacon-chain.md#execution) | `uint64(2**30)` (= 1,073,741,824) | | [`MAX_TRANSACTIONS_PER_PAYLOAD`](https://github.com/ethereum/consensus-specs/blob/67c2f9ee9eb562f7cc02b2ff90d92c56137944e1/specs/bellatrix/beacon-chain.md#execution) | `uint64(2**20)` (= 1,048,576) | | [`MAX_CALLDATA_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_ACCESS_LIST_SIZE`](./eip-4844.md#parameters) | `uint64(2**24)` (= 16,777,216) | | [`MAX_VERSIONED_HASHES_LIST_SIZE`](./eip-4844.md#parameters) | `uint64(2**24)` (= 16,777,216) | ### [EIP-2718](./eip-2718.md) transaction types The value `0x00` is marked as a reserved [EIP-2718](./eip-2718.md) transaction type. - `0x00` represents an [EIP-2718](./eip-2718.md) `LegacyTransaction` in SSZ. | Name | SSZ equivalent | Description | | - | - | - | | `TransactionType` | `uint8` | [EIP-2718](./eip-2718.md) transaction type, range `[0x00, 0x7F]` | | Name | Value | Description | | - | - | - | | `TRANSACTION_TYPE_LEGACY` | `TransactionType(0x00)` | [`LegacyTransaction`](./eip-2718.md#transactions) (only allowed in SSZ) | | `TRANSACTION_TYPE_EIP2930` | `TransactionType(0x01)` | [EIP-2930](./eip-2930.md#definitions) transaction | | `TRANSACTION_TYPE_EIP1559` | `TransactionType(0x02)` | [EIP-1559](./eip-1559.md#specification) transaction | | `TRANSACTION_TYPE_EIP4844` | `TransactionType(0x05)` | [EIP-4844](./eip-4844.md#parameters) transaction | ### Perpetual transaction hashes For each transaction, two perpetual hashes are derived. `sig_hash` is the unsigned transaction's hash that its signature is based on. `tx_hash` is the signed transaction's hash and is used as a unique identifier to refer to the transaction. Both of these hashes are derived from the transaction's original representation. The following helper functions compute the `sig_hash` and `tx_hash` for each [EIP-2718](./eip-2718.md) transaction type. The definition uses the `SignedBlobTransaction` container as defined in [EIP-4844](./eip-4844.md). ```python class LegacyTransaction(Serializable): fields = ( ('nonce', big_endian_int), ('gasprice', big_endian_int), ('startgas', big_endian_int), ('to', Binary(20, 20, allow_empty=True)), ('value', big_endian_int), ('data', binary), ) class LegacySignedTransaction(Serializable): fields = ( ('nonce', big_endian_int), ('gasprice', big_endian_int), ('startgas', big_endian_int), ('to', Binary(20, 20, allow_empty=True)), ('value', big_endian_int), ('data', binary), ('v', big_endian_int), ('r', big_endian_int), ('s', big_endian_int), ) def compute_legacy_sig_hash(signed_tx: LegacySignedTransaction) -> Hash32: if signed_tx.v not in (27, 28): # EIP-155 return Hash32(keccak(encode(LegacySignedTransaction( nonce=signed_tx.nonce, gasprice=signed_tx.gasprice, startgas=signed_tx.startgas, to=signed_tx.to, value=signed_tx.value, data=signed_tx.data, v=(uint256(signed_tx.v) - 35) >> 1, r=0, s=0, )))) else: return Hash32(keccak(encode(LegacyTransaction( nonce=signed_tx.nonce, gasprice=signed_tx.gasprice, startgas=signed_tx.startgas, to=signed_tx.to, value=signed_tx.value, data=signed_tx.data, )))) def compute_legacy_tx_hash(signed_tx: LegacySignedTransaction) -> Hash32: return Hash32(keccak(encode(signed_tx))) ``` ```python class EIP2930Transaction(Serializable): fields = ( ('chainId', big_endian_int), ('nonce', big_endian_int), ('gasPrice', big_endian_int), ('gasLimit', big_endian_int), ('to', Binary(20, 20, allow_empty=True)), ('value', big_endian_int), ('data', binary), ('accessList', CountableList(RLPList([ Binary(20, 20), CountableList(Binary(32, 32)), ]))), ) class EIP2930SignedTransaction(Serializable): fields = ( ('chainId', big_endian_int), ('nonce', big_endian_int), ('gasPrice', big_endian_int), ('gasLimit', big_endian_int), ('to', Binary(20, 20, allow_empty=True)), ('value', big_endian_int), ('data', binary), ('accessList', CountableList(RLPList([ Binary(20, 20), CountableList(Binary(32, 32)), ]))), ('signatureYParity', big_endian_int), ('signatureR', big_endian_int), ('signatureS', big_endian_int), ) def compute_eip2930_sig_hash(signed_tx: EIP2930SignedTransaction) -> Hash32: return Hash32(keccak(bytes([0x01]) + encode(EIP2930Transaction( chainId=signed_tx.chainId, nonce=signed_tx.nonce, gasPrice=signed_tx.gasPrice, gasLimit=signed_tx.gasLimit, to=signed_tx.to, value=signed_tx.value, data=signed_tx.data, accessList=signed_tx.accessList, )))) def compute_eip2930_tx_hash(signed_tx: EIP2930SignedTransaction) -> Hash32: return Hash32(keccak(bytes([0x01]) + encode(signed_tx))) ``` ```python class EIP1559Transaction(Serializable): fields = ( ('chain_id', big_endian_int), ('nonce', big_endian_int), ('max_priority_fee_per_gas', big_endian_int), ('max_fee_per_gas', big_endian_int), ('gas_limit', big_endian_int), ('destination', Binary(20, 20, allow_empty=True)), ('amount', big_endian_int), ('data', binary), ('access_list', CountableList(RLPList([ Binary(20, 20), CountableList(Binary(32, 32)), ]))), ) class EIP1559SignedTransaction(Serializable): fields = ( ('chain_id', big_endian_int), ('nonce', big_endian_int), ('max_priority_fee_per_gas', big_endian_int), ('max_fee_per_gas', big_endian_int), ('gas_limit', big_endian_int), ('destination', Binary(20, 20, allow_empty=True)), ('amount', big_endian_int), ('data', binary), ('access_list', CountableList(RLPList([ Binary(20, 20), CountableList(Binary(32, 32)), ]))), ('signature_y_parity', big_endian_int), ('signature_r', big_endian_int), ('signature_s', big_endian_int), ) def compute_eip1559_sig_hash(signed_tx: EIP1559SignedTransaction) -> Hash32: return Hash32(keccak(bytes([0x02]) + encode(EIP1559Transaction( chain_id=signed_tx.chain_id, nonce=signed_tx.nonce, max_priority_fee_per_gas=signed_tx.max_priority_fee_per_gas, max_fee_per_gas=signed_tx.max_fee_per_gas, gas_limit=signed_tx.gas_limit, destination=signed_tx.destination, amount=signed_tx.amount, data=signed_tx.data, access_list=signed_tx.access_list, )))) def compute_eip1559_tx_hash(signed_tx: EIP1559SignedTransaction) -> Hash32: return Hash32(keccak(bytes([0x02]) + encode(signed_tx))) ``` ```python def compute_eip4844_sig_hash(signed_tx: SignedBlobTransaction) -> Hash32: return Hash32(keccak(bytes([0x05]) + signed_tx.message.encode_bytes())) def compute_eip4844_tx_hash(signed_tx: SignedBlobTransaction) -> Hash32: return Hash32(keccak(bytes([0x05]) + signed_tx.encode_bytes())) ``` ### Opaque transaction signature A `TransactionSignature` type is introduced to represent an opaque transaction signature. | Name | Value | Notes | | - | - | - | | `MAX_TRANSACTION_SIGNATURE_SIZE` | `uint64(2**18)` (= 262,144) | Future-proof for post-quantum signatures (~50 KB) | ```python class TransactionSignatureType(Container): tx_type: TransactionType # EIP-2718 no_replay_protection: boolean # EIP-155; `TRANSACTION_TYPE_LEGACY` only class TransactionSignature(ByteList[MAX_TRANSACTION_SIGNATURE_SIZE]): pass ``` For all current [EIP-2718](./eip-2718.md) transaction types, transaction signatures are based on ECDSA (secp256k1). The following helper functions convert between their split and opaque representations. ```python def ecdsa_pack_signature(y_parity: bool, r: uint256, s: uint256) -> TransactionSignature: return r.to_bytes(32, 'big') + s.to_bytes(32, 'big') + bytes([0x01 if y_parity else 0]) def ecdsa_unpack_signature(signature: TransactionSignature) -> Tuple[boolean, uint256, uint256]: y_parity = signature[64] != 0 r = uint256.from_bytes(signature[0:32], 'big') s = uint256.from_bytes(signature[32:64], 'big') return (y_parity, r, s) def ecdsa_validate_signature(signature: TransactionSignature): SECP256K1N = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141 assert len(signature) == 65 assert signature[64] in (0, 1) _, r, s = ecdsa_unpack_signature(signature) assert 0 < r < SECP256K1N assert 0 < s < SECP256K1N ``` The `ExecutionAddress` of a transaction's signer can be recovered using the following helper function. ```python def ecdsa_recover_tx_from(signature: TransactionSignature, sig_hash: Hash32) -> ExecutionAddress: ecdsa = ECDSA() recover_sig = ecdsa.ecdsa_recoverable_deserialize(signature[0:64], signature[64]) public_key = PublicKey(ecdsa.ecdsa_recover(sig_hash, recover_sig, raw=True)) uncompressed = public_key.serialize(compressed=False) return ExecutionAddress(keccak(uncompressed)[12:32]) ``` ### Destination address A `DestinationAddress` container is introduced to encapsulate information about a transaction's destination. | Name | SSZ equivalent | Description | | - | - | - | | `DestinationType` | `uint8` | Context for the destination `ExecutionAddress` | | Name | Value | Description | | - | - | - | | `DESTINATION_TYPE_REGULAR` | `DestinationType(0x00)` | Recipient `ExecutionAddress` | | `DESTINATION_TYPE_CREATE` | `DestinationType(0x01)` | `ExecutionAddress` of newly deployed contract | ```python class DestinationAddress(Container): destination_type: DestinationType address: ExecutionAddress ``` For `DESTINATION_TYPE_CREATE`, the `ExecutionAddress` can be determined with the following helper function. ```python class ContractAddressData(Serializable): fields = ( ('tx_from', Binary(20, 20)), ('nonce', big_endian_int), ) def compute_contract_address(tx_from: ExecutionAddress, nonce: uint64) -> ExecutionAddress: return ExecutionAddress(keccak(encode(ContractAddressData( tx_from=tx_from, nonce=nonce, )))[12:32]) ``` ### Normalized `Transaction` representation The existing [consensus `ExecutionPayload`](https://github.com/ethereum/consensus-specs/blob/67c2f9ee9eb562f7cc02b2ff90d92c56137944e1/specs/capella/beacon-chain.md#executionpayload) container represents `transactions` as a list of [opaque `Transaction`](https://github.com/ethereum/consensus-specs/blob/67c2f9ee9eb562f7cc02b2ff90d92c56137944e1/specs/bellatrix/beacon-chain.md#custom-types) objects, each encoding an [EIP-2718](./eip-2718.md) typed transaction in the [same format](https://github.com/ethereum/devp2p/blob/bd17dac4228c69b6379644355f373669f74952cd/caps/eth.md#transaction-encoding-and-validity) as in the [devp2p `BlockBodies`](https://github.com/ethereum/devp2p/blob/bd17dac4228c69b6379644355f373669f74952cd/caps/eth.md#blockbodies-0x06) message. A `Transaction` SSZ container is introduced to represent transactions as part of the consensus `ExecutionPayload`. The definition uses the `Optional[T]` SSZ type as defined in [EIP-6475](./eip-6475.md). ```python class TransactionLimits(Container): max_priority_fee_per_gas: uint256 # EIP-1559 max_fee_per_gas: uint256 gas: uint64 class AccessTuple(Container): address: ExecutionAddress storage_keys: List[Hash32, MAX_ACCESS_LIST_STORAGE_KEYS] class BlobDetails(Container): max_fee_per_data_gas: uint256 blob_versioned_hashes: List[VersionedHash, MAX_VERSIONED_HASHES_LIST_SIZE] class TransactionPayload(Container): tx_from: ExecutionAddress nonce: uint64 tx_to: DestinationAddress tx_value: uint256 tx_input: ByteList[MAX_CALLDATA_SIZE] limits: TransactionLimits sig_type: TransactionSignatureType signature: TransactionSignature access_list: List[AccessTuple, MAX_ACCESS_LIST_SIZE] # EIP-2930 blob: Optional[BlobDetails] # EIP-4844 class Transaction(Container): payload: TransactionPayload tx_hash: Hash32 ``` ### Consensus `ExecutionPayload` building Each `ExecutionPayload` is locked to a single [EIP-155](./eip-155.md) chain ID that applies to all bundled transactions. Note that the chain ID is network-specific and could depend on the payload's timestamp or other parameters. ```python class ExecutionConfig(Container): chain_id: uint256 ``` When building a consensus `ExecutionPayload`, the bundled transactions are converted from their [original representation](https://github.com/ethereum/devp2p/blob/bd17dac4228c69b6379644355f373669f74952cd/caps/eth.md#pooledtransactions-0x0a) to the normalized `Transaction` SSZ container. The definition uses the `BlobTransactionNetworkWrapper` container as defined in [EIP-4844](./eip-4844.md). ```python def normalize_signed_transaction(encoded_signed_tx: bytes, cfg: ExecutionConfig) -> Transaction: eip2718_type = encoded_signed_tx[0] if eip2718_type == 0x05: # EIP-4844 signed_tx = BlobTransactionNetworkWrapper.decode_bytes(encoded_signed_tx[1:]).tx assert signed_tx.message.chain_id == cfg.chain_id signature = ecdsa_pack_signature( signed_tx.signature.y_parity, signed_tx.signature.r, signed_tx.signature.s, ) tx_from = ecdsa_recover_tx_from(signature, compute_eip4844_sig_hash(signed_tx)) match signed_tx.message.to.selector(): case 1: tx_to = DestinationAddress( destination_type=DESTINATION_TYPE_REGULAR, address=signed_tx.message.to.value(), ) case 0: tx_to = DestinationAddress( destination_type=DESTINATION_TYPE_CREATE, address=compute_contract_address(tx_from, signed_tx.message.nonce), ) return Transaction( payload=TransactionPayload( tx_from=tx_from, nonce=signed_tx.message.nonce, tx_to=tx_to, tx_value=signed_tx.message.value, tx_input=signed_tx.message.data, limits=TransactionLimits( max_priority_fee_per_gas=signed_tx.message.max_priority_fee_per_gas, max_fee_per_gas=signed_tx.message.max_fee_per_gas, gas=signed_tx.message.gas, ), sig_type=TransactionSignatureType( tx_type=TRANSACTION_TYPE_EIP4844, ), signature=signature, access_list=signed_tx.message.access_list, blob=Optional[BlobDetails](BlobDetails( max_fee_per_data_gas=signed_tx.message.max_fee_per_data_gas, blob_versioned_hashes=signed_tx.message.blob_versioned_hashes, )), ), tx_hash=compute_eip4844_tx_hash(signed_tx), ) if eip2718_type == 0x02: # EIP-1559 signed_tx = decode(encoded_signed_tx[1:], EIP1559SignedTransaction) assert signed_tx.chain_id == cfg.chain_id assert signed_tx.signature_y_parity in (0, 1) signature = ecdsa_pack_signature( signed_tx.signature_y_parity != 0, signed_tx.signature_r, signed_tx.signature_s, ) tx_from = ecdsa_recover_tx_from(signature, compute_eip1559_sig_hash(signed_tx)) if len(signed_tx.destination) != 0: tx_to = DestinationAddress( destination_type=DESTINATION_TYPE_REGULAR, address=ExecutionAddress(signed_tx.destination), ) else: tx_to = DestinationAddress( destination_type=DESTINATION_TYPE_CREATE, address=compute_contract_address(tx_from, signed_tx.nonce), ) return Transaction( payload=TransactionPayload( tx_from=tx_from, nonce=signed_tx.nonce, tx_to=tx_to, tx_value=signed_tx.amount, tx_input=signed_tx.data, limits=TransactionLimits( max_priority_fee_per_gas=signed_tx.max_priority_fee_per_gas, max_fee_per_gas=signed_tx.max_fee_per_gas, gas=signed_tx.gas_limit, ), sig_type=TransactionSignatureType( tx_type=TRANSACTION_TYPE_EIP1559, ), signature=signature, access_list=[AccessTuple( address=access_tuple[0], storage_keys=access_tuple[1], ) for access_tuple in signed_tx.access_list], ), tx_hash=compute_eip1559_tx_hash(signed_tx), ) if eip2718_type == 0x01: # EIP-2930 signed_tx = decode(encoded_signed_tx[1:], EIP2930SignedTransaction) assert signed_tx.chainId == cfg.chain_id assert signed_tx.signatureYParity in (0, 1) signature = ecdsa_pack_signature( signed_tx.signatureYParity != 0, signed_tx.signatureR, signed_tx.signatureS, ) tx_from = ecdsa_recover_tx_from(signature, compute_eip2930_sig_hash(signed_tx)) if len(signed_tx.to) != 0: tx_to = DestinationAddress( destination_type=DESTINATION_TYPE_REGULAR, address=ExecutionAddress(signed_tx.to), ) else: tx_to = DestinationAddress( destination_type=DESTINATION_TYPE_CREATE, address=compute_contract_address(tx_from, signed_tx.nonce), ) return Transaction( payload=TransactionPayload( tx_from=tx_from, nonce=signed_tx.nonce, tx_to=tx_to, tx_value=signed_tx.value, tx_input=signed_tx.data, limits=TransactionLimits( max_priority_fee_per_gas=signed_tx.gasPrice, max_fee_per_gas=signed_tx.gasPrice, gas=signed_tx.gasLimit, ), sig_type=TransactionSignatureType( tx_type=TRANSACTION_TYPE_EIP2930, ), signature=signature, access_list=[AccessTuple( address=access_tuple[0], storage_keys=access_tuple[1], ) for access_tuple in signed_tx.accessList], ), tx_hash=compute_eip2930_tx_hash(signed_tx), ) if 0xc0 <= eip2718_type <= 0xfe: # Legacy signed_tx = decode(encoded_signed_tx, LegacySignedTransaction) if signed_tx.v not in (27, 28): # EIP-155 assert signed_tx.v in (2 * cfg.chain_id + 35, 2 * cfg.chain_id + 36) signature = ecdsa_pack_signature( (signed_tx.v & 0x1) == 0, signed_tx.r, signed_tx.s, ) tx_from = ecdsa_recover_tx_from(signature, compute_legacy_sig_hash(signed_tx)) if len(signed_tx.to) != 0: tx_to = DestinationAddress( destination_type=DESTINATION_TYPE_REGULAR, address=ExecutionAddress(signed_tx.to), ) else: tx_to = DestinationAddress( destination_Type=DESTINATION_TYPE_CREATE, address=compute_contract_address(tx_from, signed_tx.nonce), ) return Transaction( payload=TransactionPayload( tx_from=tx_from, nonce=signed_tx.nonce, tx_to=tx_to, tx_value=signed_tx.value, tx_input=signed_tx.data, limits=TransactionLimits( max_priority_fee_per_gas=signed_tx.gasprice, max_fee_per_gas=signed_tx.gasprice, gas=signed_tx.startgas, ), sig_type=TransactionSignatureType( tx_type=TRANSACTION_TYPE_LEGACY, no_replay_protection=(signed_tx.v in (27, 28)), ), signature=signature, ), tx_hash=compute_legacy_tx_hash(signed_tx), ) assert False ``` ### Consensus `ExecutionPayload` changes The [consensus `ExecutionPayload`'s `transactions`](https://github.com/ethereum/consensus-specs/blob/67c2f9ee9eb562f7cc02b2ff90d92c56137944e1/specs/capella/beacon-chain.md#executionpayload) list is now based on the normalized `Transaction` SSZ container. ```python class ExecutionPayload(Container): ... transactions: List[Transaction, MAX_TRANSACTIONS_PER_PAYLOAD] ... ``` ```python cfg = ExecutionConfig(...) encoded_signed_txs = List[ByteList[MAX_BYTES_PER_TRANSACTION], MAX_TRANSACTIONS_PER_PAYLOAD]( encoded_signed_tx_0, encoded_signed_tx_1, encoded_signed_tx_2, ...) payload.transactions = List[Transaction, MAX_TRANSACTIONS_PER_PAYLOAD](*[ normalize_signed_transaction(encoded_signed_tx, cfg) for encoded_signed_tx in encoded_signed_txs ]) ``` ### Consensus `ExecutionPayloadHeader` changes The [consensus `ExecutionPayloadHeader`](https://github.com/ethereum/consensus-specs/blob/67c2f9ee9eb562f7cc02b2ff90d92c56137944e1/specs/capella/beacon-chain.md#executionpayloadheader) is updated for the new `ExecutionPayload.transactions` definition. ```python payload_header.transactions_root = payload.transactions.hash_tree_root() ``` ### Execution block header changes The [execution block header's `txs-root`](https://github.com/ethereum/devp2p/blob/bd17dac4228c69b6379644355f373669f74952cd/caps/eth.md#block-encoding-and-validity) is updated to match the consensus `ExecutionPayloadHeader.transactions_root`. ### Handling reorgs On a reorg, certain transactions are rebroadcasted. The following helper function recovers their [original representation](https://github.com/ethereum/devp2p/blob/bd17dac4228c69b6379644355f373669f74952cd/caps/eth.md#pooledtransactions-0x0a) from the normalized `Transaction` SSZ container. The definition uses the `BlobTransaction`, `ECDSASignature`, and `SignedBlobTransaction` containers as defined in [EIP-4844](./eip-4844.md). Note that the `BlobTransactionNetworkWrapper` as defined in [EIP-4844](./eip-4844.md) cannot be recovered. ```python def recover_legacy_signed_tx(tx: Transaction, cfg: ExecutionConfig) -> LegacySignedTransaction: destination_type = tx.payload.tx_to.destination_type if destination_type == DESTINATION_TYPE_REGULAR: to = bytes(tx.payload.tx_to.address) elif destination_type == DESTINATION_TYPE_CREATE: to = bytes([]) else: assert False y_parity, r, s = ecdsa_unpack_signature(tx.payload.signature) if not tx.payload.sig_type.no_replay_protection: # EIP-155 v = uint256(1 if y_parity or 0) + 35 + cfg.chain_id * 2 else: v = uint256(1 if y_parity or 0) + 27 return LegacySignedTransaction( nonce=tx.payload.nonce, gasprice=tx.payload.details.limits.max_fee_per_gas, startgas=tx.payload.details.limits.gas, to=to, value=tx.payload.tx_value, data=tx.payload.tx_input, v=v, r=r, s=s, ) def recover_eip2930_signed_tx(tx: Transaction, cfg: ExecutionConfig) -> EIP2930SignedTransaction: destination_type = tx.payload.tx_to.destination_type if destination_type == DESTINATION_TYPE_REGULAR: to = bytes(tx.payload.tx_to.address) elif destination_type == DESTINATION_TYPE_CREATE: to = bytes([]) else: assert False y_parity, r, s = ecdsa_unpack_signature(tx.payload.signature) return EIP2930SignedTransaction( chainId=cfg.chain_id, nonce=tx.payload.nonce, gasPrice=tx.payload.details.limits.max_fee_per_gas, gasLimit=tx.payload.details.limits.gas, to=to, value=tx.payload.tx_value, data=tx.payload.tx_input, accessList=[( access_tuple.address, access_tuple.storage_keys, ) for access_tuple in tx.payload.details.access_list], signatureYParity=y_parity, signatureR=r, signatureS=s, ) def recover_eip1559_signed_tx(tx: Transaction, cfg: ExecutionConfig) -> EIP1559SignedTransaction: destination_type = tx.payload.tx_to.destination_type if destination_type == DESTINATION_TYPE_REGULAR: destination = bytes(tx.payload.tx_to.address) elif destination_type == DESTINATION_TYPE_CREATE: destination = bytes([]) else: assert False y_parity, r, s = ecdsa_unpack_signature(tx.payload.signature) return EIP1559SignedTransaction( chain_id=cfg.chain_id, nonce=tx.payload.nonce, max_priority_fee_per_gas=tx.payload.details.limits.max_priority_fee_per_gas, max_fee_per_gas=tx.payload.details.limits.max_fee_per_gas, gas_limit=tx.payload.details.limits.gas, destination=destination, amount=tx.payload.tx_value, data=tx.payload.tx_input, access_list=[( access_tuple.address, access_tuple.storage_keys, ) for access_tuple in tx.payload.details.access_list], signature_y_parity=y_parity, signature_r=r, signature_s=s, ) def recover_eip4844_signed_tx(tx: Transaction, cfg: ExecutionConfig) -> SignedBlobTransaction: destination_type = tx.payload.tx_to.destination_type if destination_type == DESTINATION_TYPE_REGULAR: to = Union[None, ExecutionAddress]( selector=1, value=tx.payload.tx_to.address, ) elif destination_type == DESTINATION_TYPE_CREATE: to = Union[None, ExecutionAddress]() else: assert False y_parity, r, s = ecdsa_unpack_signature(tx.payload.signature) return SignedBlobTransaction( message=BlobTransaction( chain_id=cfg.chain_id, nonce=tx.payload.nonce, max_priority_fee_per_gas=tx.payload.details.limits.max_priority_fee_per_gas, max_fee_per_gas=tx.payload.details.limits.max_fee_per_gas, gas=tx.payload.details.limits.gas, to=to, value=tx.payload.tx_value, data=tx.payload.tx_input, access_list=[( access_tuple.address, access_tuple.storage_keys, ) for access_tuple in tx.payload.details.access_list], max_fee_per_data_gas=tx.payload.details.blob.get().max_fee_per_data_gas, blob_versioned_hashes=tx.payload.details.blob.get().blob_versioned_hashes, ), signature=ECDSASignature( y_parity=y_parity, r=r, s=s, ) ) def recover_encoded_signed_tx(tx: Transaction, cfg: ExecutionConfig) -> bytes: tx_type = tx.payload.sig_type.tx_type if tx_type == TRANSACTION_TYPE_EIP4844: assert False if tx_type == TRANSACTION_TYPE_EIP1559: return bytes([0x02]) + encode(recover_eip1559_signed_tx(tx, cfg)) if tx_type == TRANSACTION_TYPE_EIP2930: return bytes([0x01]) + encode(recover_eip2930_signed_tx(tx, cfg)) if tx_type == TRANSACTION_TYPE_LEGACY: return encode(recover_legacy_signed_tx(tx, cfg)) assert False ``` ### Consensus `ExecutionPayload` validation As part of the `engine_newPayload` duties, all `Transaction` SSZ containers within the `transactions` field of the `ExecutionPayload` are validated. ```python def validate_transaction(transaction: Transaction, cfg: ExecutionConfig): assert ecdsa_validate_signature(tx.payload.signature) tx_type = tx.payload.sig_type.tx_type if tx_type == TRANSACTION_TYPE_EIP4844: signed_tx = recover_eip4844_signed_tx(tx, cfg) assert tx.payload.tx_from == ecdsa_recover_tx_from( tx.payload.signature, compute_eip4844_sig_hash(signed_tx), ) assert tx.tx_hash == compute_eip4844_tx_hash(signed_tx) elif tx_type == TRANSACTION_TYPE_EIP1559: signed_tx = recover_eip1559_signed_tx(tx, cfg) assert tx.payload.tx_from == ecdsa_recover_tx_from( tx.payload.signature, compute_eip1559_sig_hash(signed_tx), ) assert tx.tx_hash == compute_eip1559_tx_hash(signed_tx) elif tx_type == TRANSACTION_TYPE_EIP2930: signed_tx = recover_eip2930_signed_tx(tx, cfg) assert tx.payload.tx_from == ecdsa_recover_tx_from( tx.payload.signature, compute_eip1559_sig_hash(signed_tx), ) assert tx.tx_hash == compute_eip2930_tx_hash(signed_tx) elif tx_type == TRANSACTION_TYPE_LEGACY: signed_tx = recover_legacy_signed_tx(tx, cfg) assert tx.payload.tx_from == ecdsa_recover_tx_from( tx.payload.signature, compute_eip1559_sig_hash(signed_tx), ) assert tx.tx_hash == compute_legacy_tx_hash(signed_tx) else: assert False destination_type = tx.payload.tx_to.destination_type if destination_type == DESTINATION_TYPE_REGULAR: pass elif destination_type == DESTINATION_TYPE_CREATE: assert tx.payload.tx_to.address == compute_contract_address( tx.payload.tx_from, tx.payload.nonce, ) else: assert False if tx.payload.sig_type.tx_type != TRANSACTION_TYPE_LEGACY: assert not tx.payload.sig_type.no_replay_protection if tx.payload.sig_type.tx_type == TRANSACTION_TYPE_EIP4844: assert tx.payload.details.blob.get() is not None return assert tx.payload.details.blob.get() is None if tx.payload.sig_type.tx_type == TRANSACTION_TYPE_EIP1559: return assert tx.payload.details.limits.max_priority_fee_per_gas == \ tx.payload.details.limits.max_fee_per_gas if tx.payload.sig_type.tx_type == TRANSACTION_TYPE_EIP2930: return assert len(tx.payload.details.access_list) == 0 if tx.payload.sig_type.tx_type == TRANSACTION_TYPE_LEGACY: return assert False ``` ## Rationale ### Why not use a format based on the [EIP-2718](./eip-2718.md) transaction type? [EIP-2718](./eip-2718.md) transaction types define a specific combination of fields and the derivation of the perpetual transaction hashes. They may also define ephemeral networking and mempool properties. For example, [EIP-4844](./eip-4844.md) transactions are not exchanged through the [devp2p `Transactions`](https://github.com/ethereum/devp2p/blob/bd17dac4228c69b6379644355f373669f74952cd/caps/eth.md#transactions-0x02) message, and have a special format as part of the [devp2p `PooledTransactions`](https://github.com/ethereum/devp2p/blob/bd17dac4228c69b6379644355f373669f74952cd/caps/eth.md#pooledtransactions-0x0a) message. While execution client implementations depend on these details for correct transaction processing, applications building on top of Ethereum typically have little need to know them. This is in line with the execution JSON-RPC API design, which provides hassle-free access about the transaction's signer, the transaction hash, and the address of newly deployed contracts through a normalized `GenericTransaction` representation. None of this information is explicitly included in the transaction's original representation, as it can be reconstructed from other fields. Likewise, after a transaction has been processed, execution client implementations only need to recover its original representation in special situations such as reorgs. Therefore, committing to a normalized, application-centric representation in the `transactions_root` of the consensus `ExecutionPayloadHeader` optimizes for their primary remaining access pattern. Updating the [devp2p `BlockBodies`](https://github.com/ethereum/devp2p/blob/bd17dac4228c69b6379644355f373669f74952cd/caps/eth.md#blockbodies-0x06) message to use the same normalized `Transaction` representation could further reduce the number of conversions back to the original transaction representation on the serving node for execution client syncing. The normalized `Transaction` representation provides applications with a unified interface across all [EIP-2718](./eip-2718.md) transaction types. While its schema can still change across spec forks, applications only need to support the schemas of the forks that they want to cover. For example, an application that only processes data from blocks within the previous 2 years only needs to implement 1-2 flavors of the normalized `Transaction` representation, even when old transaction types such as `LegacyTransaction` are still in circulation. The normalized `Transaction` representation includes the same information that applications can already request through the execution JSON-RPC API, using similar terminology. This makes it straight-forward to extend related response data with SSZ merkle proofs, improving security by allowing the application to cross-check consistency of the response data against their trusted `ExecutionPayloadHeader`. ### Why `DestinationAddress` / `tx_from`? Determining the `ExecutionAddress` of a newly deployed contract requires combining the transaction's signer `ExecutionAddress` and its nonce using RLP encoding and keccak hashing. The transaction's signer can only be recovered using the originally signed hash, which in turn may require obtaining the entire transaction. Execution client implementations already compute this information as part of transaction processing, so including it in the `Transaction` representation comes at low cost. This also enables access for applications without RLP, keccak, or secp256k1 public key recovery capabilities. ### Why opaque signatures? Representing signatures as an opaque `ByteList` supports introduction of future signature schemes (with different components than `y_parity`, `r`, or `s`) without having to change the SSZ schema, and allows reusing the serialization methods and byte orders native to a particular cryptographic signature scheme. Historically, [EIP-155](./eip-155.md) transactions encoded the chain ID as additional metadata into the signature's `v` value. This metadata is unpacked as part of `normalize_signed_transaction` and moved into the normalized `Transaction` container. Beside that, there is no strong use case for exposing the individual `y_parity`, `r`, and `s` components through the SSZ merkle tree. ## Backwards Compatibility Applications that solely rely on the `TypedTransaction` RLP encoding but do not rely on the `transactions_root` commitment in the block header can still be used through a re-encoding proxy. Applications that rely on the replaced MPT `transactions_root` in the block header can no longer find that information. Analysis is required whether affected applications have a migration path available to use the SSZ `transactions_root` instead. `TRANSACTION_TYPE_LEGACY` is already used similarly in the execution JSON-RPC API. It is unlikely to be used for other purposes. ## Test Cases The following representations of the consensus `ExecutionPayload`'s `transactions` field are compared: 1. **Baseline:** [Opaque `ByteList`](https://github.com/ethereum/consensus-specs/blob/67c2f9ee9eb562f7cc02b2ff90d92c56137944e1/specs/bellatrix/beacon-chain.md#custom-types) containing the transaction's original representation 2. **SSZ Union:** RLP encoded transactions converted to SSZ objects 3. **Normalized:** Normalized `Transaction` container (proposed design) ### `ExecutionPayload` transaction size | Transaction | Native | Baseline | SSZ Union | Normalized | Base + Snappy | Union + Snappy | Norm + Snappy | | - | :-: | :-: | :-: | :-: | :-: | :-: | :-: | | Legacy | RLP | 106 bytes | 210 bytes | 272 bytes | 109 bytes | 138 bytes | 196 bytes | | [EIP-155](./eip-155.md) | RLP | 108 bytes | 210 bytes | 272 bytes | 111 bytes | 139 bytes | 195 bytes | | [EIP-2930](./eip-2930.md) | RLP | 111 bytes | 215 bytes | 272 bytes | 114 bytes | 145 bytes | 195 bytes | | [EIP-1559](./eip-1559.md) | RLP | 117 bytes | 247 bytes | 272 bytes | 117 bytes | 148 bytes | 195 bytes | | [EIP-4844](./eip-4844.md) | SSZ | 315 bytes | 315 bytes (\*) | 340 bytes | 186 bytes | 186 bytes | 235 bytes | - [Baseline](../assets/eip-6404/tests/create_transactions.py) - [SSZ Union](../assets/eip-6404/tests/union/convert_transactions.py) - [Normalized](../assets/eip-6404/tests/normalized/convert_transactions.py) SSZ generally encodes less compact than RLP. The normalized `Transaction` is larger than the SSZ Union due to the extra `tx_hash` and `tx_from` commitments, as well as the inclusion of `max_priority_fee_per_gas` for pre-[EIP-1559](./eip-1559.md) transactions and the address of newly deployed contracts. - (\*) The [EIP-4844](./eip-4844.md) transaction's SSZ Union representation differs in the first byte, where it encodes the SSZ Union selector (`0x03`) vs the [EIP-2718](./eip-2718.md) transaction type (`0x05`). The meaning of the SSZ Union selector depends on the specific network's available transaction types. ### SSZ proof creation The following proofs are constructed: 1. **Transaction:** Obtain the sequential `tx_index` within an `ExecutionPayload` for a specific `tx_hash` 2. **Amount:** Proof that a transaction sends a certain minimum amount to a specific destination 3. **Sender:** Obtain sender addres who sent a certain minimum amount to a specific destination 4. **Info:** Obtain transaction info including fees, but no calldata, access lists, or blobs All columns except "Normalized" are measured using the SSZ Union approach. | Proof | Legacy | [EIP-155](./eip-155.md) | [EIP-2930](./eip-2930.md) | [EIP-1559](./eip-1559.md) | [EIP-4844](./eip-4844.md) | Normalized | | - | :-: | :-: | :-: | :-: | :-: | :-: | | Transaction | 709 bytes (\*) | 709 bytes (\*) | 709 bytes (\*) | 709 bytes (\*) | 709 bytes | 740 bytes | | Amount | 842 bytes | 842 bytes | 834 bytes | 874 bytes | 874 bytes | 853 bytes | | Sender | 906 bytes (\*\*) | 906 bytes (\*\*) | 867 bytes (\*\*) | 907 bytes (\*\*) | 907 bytes | 853 bytes | | Info | 914 bytes (\*\*) | 914 bytes (\*\*) | 883 bytes (\*\*) | 947 bytes (\*\*) | 947 bytes (\*\*\*) | 957 bytes | - [SSZ Union](../assets/eip-6404/tests/union/create_proofs.py) - [Normalized](../assets/eip-6404/tests/normalized/create_proofs.py) Several restrictions apply when using the SSZ Union representation with non-SSZ transaction types: - (\*) The SSZ Union representation does not commit to the transaction's `tx_hash`. Instead, proofs are based on an SSZ Union specific `tx_root`, which differs for non-SSZ transaction types. Applications that wish to verify transaction data against the `transactions_root` are required to migrate to `tx_root`. The `tx_root` is deteriministically computable from the full transaction data. For these measurements, it is assumed that the application verifies the proof using `tx_root` where necessary. - (\*\*) The SSZ Union representation does not commit to the transaction's `tx_from`. If needed, `tx_from` can be recovered from `signature` and `sig_hash` using secp256k1 public key recovery and keccak hashing. However, for non-SSZ transaction types, the SSZ Union representation also does not commit to the transaction's `sig_hash`, requiring the entire transaction to be fetched to compute `tx_from`. For these measurements, it is hypothetically assumed that non-SSZ transaction types actually were originally signed as if they were SSZ transaction types. This assumption is incorrect in practice. - (\*\*\*) The SSZ Union representation does not commit to the address of newly deployed contracts. If needed, the contract address can be recovered from `tx_from` and `nonce` using RLP encoding and keccak hashing. Note that for non-SSZ transaction types, the SSZ Union representation requires the entire transaction to be fetched to compute `tx_from`. ### SSZ proof verification requirements The following functionality is required to verify the proofs from above. | Proof | SSZ Union | Normalized | | - | - | - | | Transaction | SHA256 | SHA256 | | Amount | SHA256 | SHA256 | | Sender | SHA256, secp256k1 (\*), keccak256 (\*) | SHA256 | | Info | SHA256, secp256k1 (\*), keccak256 (\*, \*\*), RLP (\*\*) | SHA256 | The SSZ Union representation needs more functionality to obtain certain info: - (\*) `tx_from` is recovered from `signature` and `sig_hash` using secp256k1 public key recovery and keccak256 hashing. - (\*\*) `tx_to` for transactions that deploy a new smart contract is computed from `tx_from` and `nonce` using RLP encoding and keccak256 hashing. The RLP portion is minimal, but is only needed when using the SSZ Union representation. ### SSZ proof verification complexity The cost of cryptographic operations varies across environments. Therefore, the complexity of proof verification is analyzed in number of cryptographic operations and number of branches. | Proof | SSZ Union (\*) | Normalized | | - | - | - | | Transaction | 22 SHA256 | 22 SHA256 | | Amount | 23 SHA256 + {6, 4, 6, 6} SHA256 | 28 SHA256 | | Sender | 23 SHA256 + {9, 7, 9, 9} SHA256 + 1 secp256k1 + 1 keccak256 | 28 SHA256 | | Info | 23 SHA256 + {10, 10, 12, 12} SHA256 + 1 secp256k1 + 1 keccak256 + {0, 1} keccak256 | 33 SHA256 | - [SSZ Union](../assets/eip-6404/tests/union/verify_proofs.py) - [Normalized](../assets/eip-6404/tests/normalized/verify_proofs.py) For the SSZ Union representation, the verifier logic contains branching depending on the [EIP-2718](./eip-2718.md) transaction type, and for the "Info" proof, depending on whether or not the transaction deploys a new contract. The number per branch does not exploit coincidental similarity of branches to have a better understanding of the cost that an entirely new transaction type could add. - (\*) The SSZ Union estimates assume that `sig_hash` is equivalent to `payload.hash_tree_root()`. This is a simplified model and unviable in praxis due to signature malleability. A real signature scheme would require additional SHA256 hashes to mix in additional static data. ### SSZ proof verification on Embedded An industry-standard 64 MHz ARM Cortex-M4F core serves as the reference platform for this section. The following table lists the flash size required to deploy each verifier program, without proof data. The base test harness and RTOS require 19,778 bytes of flash and 5,504 bytes of RAM with 1 KB of stack memory for the main task. For these measurements, this base amount is already subtracted. The worst result was selected when there was minor jitter between test cases. | Proof | SSZ Union | Normalized | | - | :-: | :-: | | Transaction | 3,344 bytes | 3,360 bytes | | Amount | 4,468 bytes | 3,812 bytes | | Sender | 30,960 bytes (\*) | 3,872 bytes | | Info | 32,236 bytes (\*) | 4,560 bytes | - (\*) The secp256k1 library also requires increasing the stack memory for the main task from 1 KB to 6 KB. The CPU cycle count is measured while verifying sample data for each proofs. That cycle count is then divided by 64 MHz to obtain the elapsed time. All columns except "Normalized" are measured using the SSZ Union approach. | Proof | Legacy | [EIP-155](./eip-155.md) | [EIP-2930](./eip-2930.md) | [EIP-1559](./eip-1559.md) | [EIP-4844](./eip-4844.md) | Normalized (\*) | | - | :-: | :-: | :-: | :-: | :-: | :-: | | Transaction | 2.083203 ms | 2.083046 ms | 2.083046 ms | 2.082890 ms | 2.083046 ms | 2.080640 ms | | Amount | 2.784296 ms | 2.783875 ms | 2.591125 ms | 2.785203 ms | 2.783781 ms | 2.597109 ms | | Sender | 26.333531 ms | 26.113796 ms | 25.836078 ms | 26.275093 ms | 26.099609 ms | 2.640890 ms | | Info | 26.824125 ms | 26.603312 ms | 26.504875 ms | 26.951562 ms | 26.782187 ms | 3.197406 ms | - [SSZ Union](../assets/eip-6404/tests/union/verify_proofs.c) - [Normalized](../assets/eip-6404/tests/normalized/verify_proofs.c) For the secp256k1 library, the configuration `--with-asm=arm --with-ecmult-window=8` was used to balance flash size and runtime. - (\*) The worst result was selected when there was minor jitter between test cases. ## Reference Implementation TBD ## Security Considerations None ## Copyright Copyright and related rights waived via [CC0](../LICENSE.md).