Alert Source Discuss
⚠️ Draft Standards Track: Core

EIP-6404: SSZ transactions

Migration of RLP transactions to SSZ

Authors Etan Kissling (@etan-status), Gajinder Singh (@g11tech), Vitalik Buterin (@vbuterin)
Created 2023-01-30
Discussion Link https://ethereum-magicians.org/t/eip-6404-ssz-transactions/12783
Requires EIP-155, EIP-1559, EIP-2718, EIP-2930, EIP-4844, EIP-5793, EIP-7495, EIP-7702

Abstract

This EIP defines a migration process of EIP-2718 Recursive-Length Prefix (RLP) transactions to Simple Serialize (SSZ).

Motivation

RLP transactions have a number of shortcomings:

  1. Linear hashing: The signing hash (sig_hash) and unique identifier (tx_hash) of an RLP transaction are computed by linear keccak256 hashes across its serialization. Even if only partial data is of interest, linear hashes require the full transaction data to be present, including potentially large calldata or access lists. This also applies when computing the from address of a transaction based on the sig_hash.

  2. Inefficient inclusion proofs: The Merkle-Patricia Trie (MPT) backing the execution block header’s transactions_root is constructed from the serialized transactions, internally prepending a prefix to the transaction data before it is keccak256 hashed into the MPT. Due to this prefix, there is no on-chain commitment to the tx_hash and inclusion proofs require the full transaction data to be present.

  3. Incompatible representation: As part of the consensus ExecutionPayload, the RLP serialization of transactions is hashed using SSZ merkleization. These SSZ hashes are incompatible with both the tx_hash and the MPT transactions_root.

  4. No extensibility: Transaction types cannot be extended with optional features. Hypothetically, if EIP-4844 blob transactions existed from the start, new features such as EIP-2930 access lists and EIP-1559 priority fees would have required two new transacion types each to extend both the basic and blob transaction types.

  5. Technical debt: All client applications and smart contracts handling RLP transactions have to correctly deal with caveats such as LegacyTransaction lacking a prefix byte, the inconsistent chain_id and v / y_parity semantics, and the introduction of max_priority_fee_per_gas between other fields instead of at the end. As existing transaction types tend to remain valid perpetually, this technical debt builds up over time.

  6. Inappropriate opaqueness: The Consensus Layer treats RLP transaction data as opaque, but requires validation of consensus blob_kzg_commitments against transaction blob_versioned_hashes, resulting in a more complex than necessary engine API.

This EIP defines a lossless conversion mechanism to normalize transaction representation across both Consensus Layer and Execution Layer while retaining support for processing RLP transaction types.

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 Value
MAX_TRANSACTIONS_PER_PAYLOAD uint64(2**20) (= 1,048,576)
BYTES_PER_FIELD_ELEMENT uint64(32)
FIELD_ELEMENTS_PER_BLOB uint64(4096)
MAX_BLOB_COMMITMENTS_PER_BLOCK uint64(2**12) (= 4,096)
Name SSZ equivalent
Hash32 Bytes32
ExecutionAddress Bytes20
VersionedHash Bytes32
KZGCommitment Bytes48
KZGProof Bytes48
Blob ByteVector[BYTES_PER_FIELD_ELEMENT * FIELD_ELEMENTS_PER_BLOB]

ExecutionSignature container

Signatures use their native, opaque representation, and are extended with an on-chain commitment to the signing address.

Name Value Description
SECP256K1_SIGNATURE_SIZE 32 + 32 + 1 (= 65) Byte length of a secp256k1 ECDSA signature
MAX_EXECUTION_SIGNATURE_FIELDS uint64(2**3) (= 8) Maximum number of fields to which ExecutionSignature can ever grow in the future
class ExecutionSignature(StableContainer[MAX_EXECUTION_SIGNATURE_FIELDS]):
    secp256k1: Optional[ByteVector[SECP256K1_SIGNATURE_SIZE]]

class Secp256k1ExecutionSignature(Profile[ExecutionSignature]):
    secp256k1: ByteVector[SECP256K1_SIGNATURE_SIZE]

def secp256k1_pack(r: uint256, s: uint256, y_parity: uint8) -> ByteVector[SECP256K1_SIGNATURE_SIZE]:
    return r.to_bytes(32, 'big') + s.to_bytes(32, 'big') + bytes([y_parity])

def secp256k1_unpack(signature: ByteVector[SECP256K1_SIGNATURE_SIZE]) -> tuple[uint256, uint256, uint8]:
    r = uint256.from_bytes(signature[0:32], 'big')
    s = uint256.from_bytes(signature[32:64], 'big')
    y_parity = signature[64]
    return (r, s, y_parity)

def secp256k1_validate(signature: ByteVector[SECP256K1_SIGNATURE_SIZE]):
    SECP256K1N = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141
    r, s, y_parity = secp256k1_unpack(signature)
    assert 0 < r < SECP256K1N
    assert 0 < s <= SECP256K1N // 2
    assert y_parity in (0, 1)

def secp256k1_recover_signer(signature: ByteVector[SECP256K1_SIGNATURE_SIZE],
                             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[1:])[12:])

Transaction container

All transactions are represented as a single, normalized SSZ container. The definition uses the StableContainer[N] SSZ type and Optional[T] as defined in EIP-7495.

Name Value Description
MAX_FEES_PER_GAS_FIELDS uint64(2**4) (= 16) Maximum number of fields to which FeesPerGas can ever grow in the future
MAX_CALLDATA_SIZE uint64(2**24) (= 16,777,216) Maximum input calldata byte length for a transaction
MAX_ACCESS_LIST_STORAGE_KEYS uint64(2**19) (= 524,288) Maximum number of storage keys within an access tuple
MAX_ACCESS_LIST_SIZE uint64(2**19) (= 524,288) Maximum number of access tuples within an access_list
MAX_AUTHORIZATION_PAYLOAD_FIELDS uint64(2**4) (= 16) Maximum number of fields to which AuthorizationPayload can ever grow in the future
MAX_AUTHORIZATION_LIST_SIZE uint64(2**16) (= 65,536) Maximum number of authorizations within an authorization_list
MAX_TRANSACTION_PAYLOAD_FIELDS uint64(2**5) (= 32) Maximum number of fields to which TransactionPayload can ever grow in the future
Name SSZ equivalent Description
TransactionType uint8 EIP-2718 transaction type, range [0x00, 0x7F]
ChainId uint64 EIP-155 chain ID
FeePerGas uint256 Fee per unit of gas
GasAmount uint64 Amount in units of gas
class FeesPerGas(StableContainer[MAX_FEES_PER_GAS_FIELDS]):
    regular: Optional[FeePerGas]

    # EIP-4844
    blob: Optional[FeePerGas]

class AccessTuple(Container):
    address: ExecutionAddress
    storage_keys: List[Hash32, MAX_ACCESS_LIST_STORAGE_KEYS]

class AuthorizationPayload(StableContainer[MAX_AUTHORIZATION_PAYLOAD_FIELDS]):
    magic: Optional[TransactionType]
    chain_id: Optional[ChainId]
    address: Optional[ExecutionAddress]
    nonce: Optional[uint64]

class Authorization(Container):
    payload: AuthorizationPayload
    signature: ExecutionSignature

class TransactionPayload(StableContainer[MAX_TRANSACTION_PAYLOAD_FIELDS]):
    # EIP-2718
    type_: Optional[TransactionType]

    # EIP-155
    chain_id: Optional[ChainId]

    nonce: Optional[uint64]
    max_fees_per_gas: Optional[FeesPerGas]
    gas: Optional[GasAmount]
    to: Optional[ExecutionAddress]
    value: Optional[uint256]
    input_: Optional[ByteList[MAX_CALLDATA_SIZE]]

    # EIP-2930
    access_list: Optional[List[AccessTuple, MAX_ACCESS_LIST_SIZE]]

    # EIP-1559
    max_priority_fees_per_gas: Optional[FeesPerGas]

    # EIP-4844
    blob_versioned_hashes: Optional[List[VersionedHash, MAX_BLOB_COMMITMENTS_PER_BLOCK]]

    # EIP-7702
    authorization_list: Optional[List[Authorization, MAX_AUTHORIZATION_LIST_SIZE]]

class Transaction(Container):
    payload: TransactionPayload
    signature: ExecutionSignature

Transaction profiles

EIP-7495 Profile definitions provide type safety for valid transactions. Their original RLP TransactionType is retained to enable recovery of their original RLP representation and associated sig_hash and tx_hash values where necessary.

class BasicFeesPerGas(Profile[FeesPerGas]):
    regular: FeePerGas

class BlobFeesPerGas(Profile[FeesPerGas]):
    regular: FeePerGas
    blob: FeePerGas

class RlpLegacyTransactionPayload(Profile[TransactionPayload]):
    type_: TransactionType
    chain_id: Optional[ChainId]
    nonce: uint64
    max_fees_per_gas: BasicFeesPerGas
    gas: GasAmount
    to: Optional[ExecutionAddress]
    value: uint256
    input_: ByteList[MAX_CALLDATA_SIZE]

class RlpLegacyTransaction(Container):
    payload: RlpLegacyTransactionPayload
    signature: Secp256k1ExecutionSignature

class RlpAccessListTransactionPayload(Profile[TransactionPayload]):
    type_: TransactionType
    chain_id: ChainId
    nonce: uint64
    max_fees_per_gas: BasicFeesPerGas
    gas: GasAmount
    to: Optional[ExecutionAddress]
    value: uint256
    input_: ByteList[MAX_CALLDATA_SIZE]
    access_list: List[AccessTuple, MAX_ACCESS_LIST_SIZE]

class RlpAccessListTransaction(Container):
    payload: RlpAccessListTransactionPayload
    signature: Secp256k1ExecutionSignature

class RlpFeeMarketTransactionPayload(Profile[TransactionPayload]):
    type_: TransactionType
    chain_id: ChainId
    nonce: uint64
    max_fees_per_gas: BasicFeesPerGas
    gas: GasAmount
    to: Optional[ExecutionAddress]
    value: uint256
    input_: ByteList[MAX_CALLDATA_SIZE]
    access_list: List[AccessTuple, MAX_ACCESS_LIST_SIZE]
    max_priority_fees_per_gas: BasicFeesPerGas

class RlpFeeMarketTransaction(Container):
    payload: RlpFeeMarketTransactionPayload
    signature: Secp256k1ExecutionSignature

class RlpBlobTransactionPayload(Profile[TransactionPayload]):
    type_: TransactionType
    chain_id: ChainId
    nonce: uint64
    max_fees_per_gas: BlobFeesPerGas
    gas: GasAmount
    to: ExecutionAddress
    value: uint256
    input_: ByteList[MAX_CALLDATA_SIZE]
    access_list: List[AccessTuple, MAX_ACCESS_LIST_SIZE]
    max_priority_fees_per_gas: BlobFeesPerGas
    blob_versioned_hashes: List[VersionedHash, MAX_BLOB_COMMITMENTS_PER_BLOCK]

class RlpBlobTransaction(Container):
    payload: RlpBlobTransactionPayload
    signature: Secp256k1ExecutionSignature

class RlpSetCodeAuthorizationPayload(Profile[AuthorizationPayload]):
    magic: TransactionType
    chain_id: Optional[ChainId]
    address: ExecutionAddress
    nonce: uint64

class RlpSetCodeAuthorization(Container):
    payload: RlpSetCodeAuthorizationPayload
    signature: Secp256k1ExecutionSignature

class RlpSetCodeTransactionPayload(Profile[TransactionPayload]):
    type_: TransactionType
    chain_id: ChainId
    nonce: uint64
    max_fees_per_gas: BasicFeesPerGas
    gas: GasAmount
    to: ExecutionAddress
    value: uint256
    input_: ByteList[MAX_CALLDATA_SIZE]
    access_list: List[AccessTuple, MAX_ACCESS_LIST_SIZE]
    max_priority_fees_per_gas: BasicFeesPerGas
    authorization_list: List[RlpSetCodeAuthorization, MAX_AUTHORIZATION_LIST_SIZE]

class RlpSetCodeTransaction(Container):
    payload: RlpSetCodeTransactionPayload
    signature: Secp256k1ExecutionSignature

Helpers are provided to identify the EIP-7495 Profile of a normalized Transaction. The type system ensures that all required fields of the Profile are present and that excluded fields are absent.

LEGACY_TX_TYPE = TransactionType(0x00)
ACCESS_LIST_TX_TYPE = TransactionType(0x01)
FEE_MARKET_TX_TYPE = TransactionType(0x02)
BLOB_TX_TYPE = TransactionType(0x03)
SET_CODE_TX_TYPE = TransactionType(0x04)
SET_CODE_TX_MAGIC = TransactionType(0x05)

def identify_authorization_profile(auth: Authorization) -> Type[Profile]:
    if auth.payload.magic == SET_CODE_TX_MAGIC:
        if auth.payload.chain_id == 0:
            raise Exception(f'Unsupported chain ID in Set Code RLP authorization: {auth}')
        return RlpSetCodeAuthorization

    raise Exception(f'Unsupported authorization: {auth}')

def identify_transaction_profile(tx: Transaction) -> Type[Profile]:
    if tx.payload.type_ == SET_CODE_TX_TYPE:
        for auth in tx.payload.authorization_list or []:
            auth = identify_authorization_profile(auth).from_base(auth)
            if not isinstance(auth, RlpSetCodeAuthorization):
                raise Exception(f'Unsupported authorization in Set Code RLP transaction: {tx}')
        return RlpSetCodeTransaction

    if tx.payload.type_ == BLOB_TX_TYPE:
        if (tx.payload.max_priority_fees_per_gas or FeesPerGas()).blob != 0:
            raise Exception(f'Unsupported blob priority fee in Blob RLP transaction: {tx}')
        return RlpBlobTransaction

    if tx.payload.type_ == FEE_MARKET_TX_TYPE:
        return RlpFeeMarketTransaction

    if tx.payload.type_ == ACCESS_LIST_TX_TYPE:
        return RlpAccessListTransaction

    if tx.payload.type_ == LEGACY_TX_TYPE:
        return RlpLegacyTransaction

    raise Exception(f'Unsupported transaction: {tx}')

To obtain a transaction’s from address, its identifier, or an authorization’s authority address, see EIP assets for a definition of compute_sig_hash, compute_tx_hash, and compute_auth_hash that account for the various transaction types.

Execution block header changes

The execution block header’s txs-root is transitioned from MPT to SSZ.

transactions = List[Transaction, MAX_TRANSACTIONS_PER_PAYLOAD](
    tx_0, tx_1, tx_2, ...)

block_header.transactions_root = transactions.hash_tree_root()

Engine API

In the engine API, the structure of the transactions field in ExecutionPayload versions adopting this EIP is changed from Array of DATA to Array of TransactionV1.

TransactionV1 is defined to map onto the SSZ Transaction type, as follows:

  • payload: TransactionPayloadV1 - An OBJECT containing the fields of a TransactionPayloadV1 structure
  • signature: ExecutionSignatureV1 - An OBJECT containing the fields of an ExecutionSignatureV1 structure

TransactionPayloadV1 is defined to map onto the SSZ TransactionPayload StableContainer, as follows:

  • type: QUANTITY|null, 8 Bits or null
  • chainId: QUANTITY|null, 256 Bits or null
  • nonce: QUANTITY|null, 64 Bits or null
  • maxFeesPerGas: FeesPerGasV1|null - An OBJECT containing the fields of a FeesPerGasV1 structure or null
  • gas: QUANTITY|null, 64 Bits or null
  • to: DATA|null, 20 Bytes or null
  • value: QUANTITY|null, 256 Bits or null
  • input: DATA|null, 0 through MAX_CALLDATA_SIZE bytes or null
  • accessList: Array of AccessTupleV1 - 0 through MAX_ACCESS_LIST_SIZE OBJECT entries each containing the fields of an AccessTupleV1 structure, or null
  • maxPriorityFeesPerGas: FeesPerGasV1|null - An OBJECT containing the fields of a FeesPerGasV1 structure or null
  • blobVersionedHashes: Array of DATA|null - 0 through MAX_BLOB_COMMITMENTS_PER_BLOCK DATA entries each containing 32 Bytes, or null
  • authorizationList: Array of AuthorizationV1 - 0 through MAX_AUTHORIZATION_LIST_SIZE OBJECT entries each containing the fields of an AuthorizationV1 structure, or null

FeesPerGasV1 is defined to map onto the SSZ FeesPerGas StableContainer, as follows:

  • regular: QUANTITY|null, 256 Bits or null
  • blob: QUANTITY|null, 256 Bits or null

AccessTupleV1 is defined to map onto the SSZ AccessTuple Container, as follows:

  • address: DATA, 20 Bytes
  • storageKeys: Array of DATA - 0 through MAX_ACCESS_LIST_STORAGE_KEYS DATA entries each containing 32 Bytes

AuthorizationV1 is defined to map onto the SSZ Authorization Container, as follows:

  • payload: AuthorizationPayloadV1 - An OBJECT containing the fields of an AuthorizationPayloadV1 structure
  • signature: ExecutionSignatureV1 - An OBJECT containing the fields of an ExecutionSignatureV1 structure

AuthorizationPayloadV1 is defined to map onto the SSZ AuthorizationPayload StableContainer, as follows:

  • magic: QUANTITY|null, 8 Bits or null
  • chainId: QUANTITY|null, 256 Bits or null
  • address: DATA|null, 20 Bytes or null
  • nonce: QUANTITY|null, 64 Bits or null

ExecutionSignatureV1 is defined to map onto the SSZ ExecutionSignature StableContainer, as follows:

  • secp256k1: DATA|null, 65 Bytes or null

Consensus ExecutionPayload changes

When building a consensus ExecutionPayload, the transactions list is no longer opaque and uses the new Transaction type.

class ExecutionPayload(Container):
    ...
    transactions: List[Transaction, MAX_TRANSACTIONS_PER_PAYLOAD]
    ...

SSZ PooledTransaction container

During transaction gossip responses (PooledTransactions), each Transaction is wrapped into a PooledTransaction.

Name Value Description
MAX_POOLED_TRANSACTION_FIELDS uint64(2**3) (= 8) Maximum number of fields to which PooledTransaction can ever grow in the future
class BlobData(Container):
    blobs: List[Blob, MAX_BLOB_COMMITMENTS_PER_BLOCK]
    commitments: List[KZGCommitment, MAX_BLOB_COMMITMENTS_PER_BLOCK]
    proofs: List[KZGProof, MAX_BLOB_COMMITMENTS_PER_BLOCK]

class PooledTransaction(StableContainer[MAX_POOLED_TRANSACTION_FIELDS]):
    tx: Optional[Transaction]
    blob_data: Optional[BlobData]

The additional validation constraints defined in EIP-4844 also apply to transactions that define tx.payload.blob_versioned_hashes or blob_data.

Transaction gossip announcements

The semantics of the types element in transaction gossip announcements (NewPooledTransactionHashes) are changed to match ssz(PooledTransaction.active_fields()). The separate control flow for fetching blob transactions compared to basic transactions is retained.

Note that this change maps active_fields for PooledTransaction with blob_data to 0x03, which coincides with the previous BLOB_TX_TYPE prefix of blob RLP transactions.

Networking

When exchanging SSZ transactions via the Ethereum Wire Protocol, the following EIP-2718 compatible envelopes are used:

Name Value Description
SSZ_TX_TYPE TransactionType(0x1f) Endpoint specific SSZ object
  • Transaction: SSZ_TX_TYPE || snappyFramed(ssz(Transaction))
  • PooledTransaction: SSZ_TX_TYPE || snappyFramed(ssz(PooledTransaction))

Objects are encoded using SSZ and compressed using the Snappy framing format, matching the encoding of consensus objects as defined in the consensus networking specification. As part of the encoding, the uncompressed object length is emitted; the RECOMMENDED limit to enforce per object is MAX_CHUNK_SIZE bytes.

Implementations SHOULD continue to support accepting RLP transactions into their transaction pool. However, such transactions MUST be converted to SSZ for inclusion into an ExecutionPayload. See EIP assets for a reference implementation to convert from RLP to SSZ, as well as corresponding test cases. The original sig_hash and tx_hash are retained throughout the conversion process.

Rationale

Switching to a single, unified and forward compatible transaction format within execution blocks reduces implementation complexity for client applications and smart contracts. Future use cases such as transaction inclusion proofs or submitting individual verifiable chunks of calldata to a smart contract become easier to implement with SSZ.

Various protocol inefficiencies are also addressed. While the transaction data is hashed several times under the RLP system, including (1) sig_hash, (2) tx_hash, (3) MPT internal hash, and (4) SSZ internal hash, the normalized representation reduces the hash count. Furthermore, Consensus Layer implementations may drop invalid blocks early if consensus blob_kzg_commitments do not validate against transaction blob_versioned_hashes and no longer need to query the Execution Layer for block hash validation.

Backwards Compatibility

Applications that rely on the replaced MPT transactions_root in the block header require migration to the SSZ transactions_root.

While there is no on-chain commitment of the tx_hash, it is widely used in JSON-RPC and the Ethereum Wire Protocol to uniquely identify transactions. The tx_hash remains stable across the conversion from RLP to SSZ.

The conversion from RLP transactions to SSZ is lossless. The original RLP sig_hash and tx_hash can be recovered from the SSZ representation.

Security Considerations

None

Copyright and related rights waived via CC0.

Citation

Please cite this document as:

Etan Kissling (@etan-status), Gajinder Singh (@g11tech), Vitalik Buterin (@vbuterin), "EIP-6404: SSZ transactions [DRAFT]," Ethereum Improvement Proposals, no. 6404, January 2023. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-6404.