Alert Source Discuss
⚠️ Draft Standards Track: Core

EIP-6404: SSZ Transactions Root

Migration of transactions MPT commitment to SSZ

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

Abstract

This EIP defines a migration process of existing Merkle-Patricia Trie (MPT) commitments for transactions to Simple Serialize (SSZ).

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 Bytes32
ExecutionAddress Bytes20
VersionedHash Bytes32
Name Value
MAX_BYTES_PER_TRANSACTION uint64(2**30) (= 1,073,741,824)
MAX_TRANSACTIONS_PER_PAYLOAD uint64(2**20) (= 1,048,576)
MAX_CALLDATA_SIZE uint64(2**24) (= 16,777,216)
MAX_ACCESS_LIST_STORAGE_KEYS uint64(2**24) (= 16,777,216)
MAX_ACCESS_LIST_SIZE uint64(2**24) (= 16,777,216)
MAX_VERSIONED_HASHES_LIST_SIZE uint64(2**24) (= 16,777,216)

EIP-2718 transaction types

The value 0x00 is marked as a reserved EIP-2718 transaction type.

  • 0x00 represents an EIP-2718 LegacyTransaction in SSZ.
Name SSZ equivalent Description
TransactionType uint8 EIP-2718 transaction type, range [0x00, 0x7F]
Name Value Description
TRANSACTION_TYPE_LEGACY TransactionType(0x00) LegacyTransaction (only allowed in SSZ)
TRANSACTION_TYPE_EIP2930 TransactionType(0x01) EIP-2930 transaction
TRANSACTION_TYPE_EIP1559 TransactionType(0x02) EIP-1559 transaction
TRANSACTION_TYPE_EIP4844 TransactionType(0x05) EIP-4844 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 transaction type. The definition uses the SignedBlobTransaction container as defined in EIP-4844.

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)))
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)))
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)))
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)
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 transaction types, transaction signatures are based on ECDSA (secp256k1). The following helper functions convert between their split and opaque representations.

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.

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
class DestinationAddress(Container):
    destination_type: DestinationType
    address: ExecutionAddress

For DESTINATION_TYPE_CREATE, the ExecutionAddress can be determined with the following helper function.

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 container represents transactions as a list of opaque Transaction objects, each encoding an EIP-2718 typed transaction in the same format as in the devp2p BlockBodies 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.

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

class ExecutionConfig(Container):
    chain_id: uint256

When building a consensus ExecutionPayload, the bundled transactions are converted from their original representation to the normalized Transaction SSZ container. The definition uses the BlobTransactionNetworkWrapper container as defined in EIP-4844.

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 list is now based on the normalized Transaction SSZ container.

class ExecutionPayload(Container):
    ...
    transactions: List[Transaction, MAX_TRANSACTIONS_PER_PAYLOAD]
    ...
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 is updated for the new ExecutionPayload.transactions definition.

payload_header.transactions_root = payload.transactions.hash_tree_root()

Execution block header changes

The execution block header’s txs-root 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 from the normalized Transaction SSZ container. The definition uses the BlobTransaction, ECDSASignature, and SignedBlobTransaction containers as defined in EIP-4844. Note that the BlobTransactionNetworkWrapper as defined in EIP-4844 cannot be recovered.

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.

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 transaction type?

EIP-2718 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 transactions are not exchanged through the devp2p Transactions message, and have a special format as part of the devp2p PooledTransactions 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 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 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 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 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 RLP 108 bytes 210 bytes 272 bytes 111 bytes 139 bytes 195 bytes
EIP-2930 RLP 111 bytes 215 bytes 272 bytes 114 bytes 145 bytes 195 bytes
EIP-1559 RLP 117 bytes 247 bytes 272 bytes 117 bytes 148 bytes 195 bytes
EIP-4844 SSZ 315 bytes 315 bytes (*) 340 bytes 186 bytes 186 bytes 235 bytes

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 transactions and the address of newly deployed contracts.

  • (*) The EIP-4844 transaction’s SSZ Union representation differs in the first byte, where it encodes the SSZ Union selector (0x03) vs the EIP-2718 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-2930 EIP-1559 EIP-4844 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

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

For the SSZ Union representation, the verifier logic contains branching depending on the EIP-2718 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-2930 EIP-1559 EIP-4844 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

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 and related rights waived via CC0.

Citation

Please cite this document as:

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