DCIPs/EIPS/eip-6404.md

965 lines
46 KiB
Markdown

---
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).