AlertSourceDiscuss
Skip to content
On this page

EIP-6404: SSZ Transactions Root

Migration of transactions MPT commitment to SSZ

⚠️ DraftCore

Draft Notice

This EIP is in the process of being drafted. The content of this EIP is not final and can change at any time; this EIP is not yet suitable for use in production. Thank you!

AuthorsEtan Kissling (@etan-status), Vitalik Buterin (@vbuterin)
Created2023-02-08

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 a SSZ root.

Specification

The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174.

Existing definitions

Definitions from existing specifications that are used throughout this document are replicated here for reference.

NameSSZ equivalent
Hash32Bytes32
ExecutionAddressBytes20
VersionedHashBytes32
NameValue
MAX_BYTES_PER_TRANSACTIONuint64(2**30) (= 1,073,741,824)
MAX_TRANSACTIONS_PER_PAYLOADuint64(2**20) (= 1,048,576)
MAX_CALLDATA_SIZEuint64(2**24) (= 16,777,216)
MAX_ACCESS_LIST_STORAGE_KEYSuint64(2**24) (= 16,777,216)
MAX_ACCESS_LIST_SIZEuint64(2**24) (= 16,777,216)
MAX_VERSIONED_HASHES_LIST_SIZEuint64(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.
NameSSZ equivalentDescription
TransactionTypeuint8EIP-2718 transaction type, range [0x00, 0x7F]
NameValueDescription
TRANSACTION_TYPE_LEGACYTransactionType(0x00)LegacyTransaction (only allowed in SSZ)
TRANSACTION_TYPE_EIP2930TransactionType(0x01)EIP-2930 transaction
TRANSACTION_TYPE_EIP1559TransactionType(0x02)EIP-1559 transaction
TRANSACTION_TYPE_EIP4844TransactionType(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.

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.

NameValueNotes
MAX_TRANSACTION_SIGNATURE_SIZEuint64(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 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.

NameSSZ equivalentDescription
DestinationTypeuint8Context for the destination ExecutionAddress
NameValueDescription
DESTINATION_TYPE_REGULARDestinationType(0x00)Recipient ExecutionAddress
DESTINATION_TYPE_CREATEDestinationType(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 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.

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 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 to the normalized Transaction SSZ container. The definition uses the BlobTransactionNetworkWrapper container as defined in EIP-4844.

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 list is replaced with a Transactions container.

python
class Transactions(Container):
    tx_list: List[Transaction, MAX_TRANSACTIONS_PER_PAYLOAD]
    chain_id: uint256
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 = Transactions(
    tx_list=[
        normalize_signed_transaction(encoded_signed_tx, cfg)
        for encoded_signed_tx in encoded_signed_txs
    ],
    chain_id=cfg.chain_id,
)

Consensus ExecutionPayloadHeader changes

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

python
def recover_legacy_signed_tx(tx: Transaction, cfg: ExecutionConfig) -> LegacySignedTransaction:
    match tx.payload.tx_to.destination_type:
        case DESTINATION_TYPE_REGULAR:
            to = bytes(tx.payload.tx_to.address)
        case DESTINATION_TYPE_CREATE:
            to = bytes([])

    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:
    match tx.payload.tx_to.destination_type:
        case DESTINATION_TYPE_REGULAR:
            to = bytes(tx.payload.tx_to.address)
        case DESTINATION_TYPE_CREATE:
            to = bytes([])

    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:
    match tx.payload.tx_to.destination_type:
        case DESTINATION_TYPE_REGULAR:
            destination = bytes(tx.payload.tx_to.address)
        case DESTINATION_TYPE_CREATE:
            destination = bytes([])

    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:
    match tx.payload.tx_to.destination_type:
        case DESTINATION_TYPE_REGULAR:
            to = Union[None, ExecutionAddress](
                selector=1,
                value=tx.payload.tx_to.address,
            )
        case DESTINATION_TYPE_CREATE:
            to = Union[None, ExecutionAddress]()

    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:
    match tx.payload.sig_type.tx_type:
        case TRANSACTION_TYPE_EIP4844:
            assert False
        case TRANSACTION_TYPE_EIP1559:
            return bytes([0x02]) + encode(recover_eip1559_signed_tx(tx, cfg))
        case TRANSACTION_TYPE_EIP2930:
            return bytes([0x01]) + encode(recover_eip2930_signed_tx(tx, cfg))
        case TRANSACTION_TYPE_LEGACY:
            return encode(recover_legacy_signed_tx(tx, cfg))

Consensus ExecutionPayload validation

As part of the engine_newPayload duties, the transactions field of an ExecutionPayload is validated.

python
def validate_transactions(transactions: Transactions, cfg: ExecutionConfig):
    assert transactions.chain_id == cfg.chain_id

    for tx in transactions.tx_list:
        assert ecdsa_validate_signature(tx.payload.signature)
        match tx.payload.sig_type.tx_type:
            case 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)
            case 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)
            case 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)
            case 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)
        match tx.payload.tx_to.destination_type:
            case DESTINATION_TYPE_REGULAR:
                pass
            case DESTINATION_TYPE_CREATE:
                assert tx.payload.tx_to.address == compute_contract_address(
                    tx.payload.tx_from,
                    tx.payload.nonce,
                )

        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
            continue
        assert tx.payload.details.blob.get() is None

        if tx.payload.sig_type.tx_type == TRANSACTION_TYPE_EIP1559:
            continue
        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:
            continue
        assert len(tx.payload.details.access_list) == 0

        if tx.payload.sig_type.tx_type == TRANSACTION_TYPE_LEGACY:
            continue
        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 are converted to SSZ objects
  3. Normalized: Normalized Transactions container with extra commitments

ExecutionPayload transaction size

TransactionNativeBaselineSSZ UnionNormalizedBase + SnappyUnion + SnappyNorm + Snappy
LegacyRLP106 bytes210 bytes272 bytes109 bytes138 bytes195 bytes
EIP-155RLP108 bytes210 bytes272 bytes111 bytes139 bytes194 bytes
EIP-2930RLP111 bytes215 bytes272 bytes114 bytes145 bytes194 bytes
EIP-1559RLP117 bytes247 bytes272 bytes117 bytes148 bytes194 bytes
EIP-4844SSZ315 bytes315 bytes (*)340 bytes186 bytes186 bytes234 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.

ProofLegacyEIP-155EIP-2930EIP-1559EIP-4844Normalized
Transaction709 bytes (*)709 bytes (*)709 bytes (*)709 bytes (*)709 bytes740 bytes
Amount842 bytes842 bytes834 bytes874 bytes874 bytes853 bytes
Sender906 bytes (**)906 bytes (**)867 bytes (**)907 bytes (**)907 bytes853 bytes
Info914 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.

ProofSSZ UnionNormalized
TransactionSHA256SHA256
AmountSHA256SHA256
SenderSHA256, secp256k1 (*), keccak256 (*)SHA256
InfoSHA256, 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.

ProofSSZ Union (*)Normalized (**)
Transaction22 SHA25623 SHA256
Amount23 SHA256 + {6, 4, 6, 6} SHA25629 SHA256
Sender23 SHA256 + {9, 7, 9, 9} SHA256 + 1 secp256k1 + 1 keccak25629 SHA256
Info23 SHA256 + {10, 10, 12, 12} SHA256 + 1 secp256k1 + 1 keccak256 + {0, 1} keccak25634 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.
  • (**) The Transactions SSZ container contains the chain_id, requiring one extra SHA256 compared to the SSZ Union to mix in.

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.

ProofSSZ UnionNormalized
Transaction2,635 bytes2,668 bytes
Amount3,598 bytes3,011 bytes
Sender30,065 bytes (*)3,071 bytes
Info31,294 bytes (*)3,651 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.

ProofLegacyEIP-155EIP-2930EIP-1559EIP-4844Normalized (*)
Transaction2.083203 ms2.083046 ms2.083046 ms2.082890 ms2.083046 ms2.177125 ms
Amount2.784296 ms2.783875 ms2.591125 ms2.785203 ms2.783781 ms2.686578 ms
Sender26.333531 ms26.113796 ms25.836078 ms26.275093 ms26.099609 ms2.699390 ms
Info26.824125 ms26.603312 ms26.504875 ms26.951562 ms26.782187 ms3.291203 ms

For the secp256k1 librry, 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, Vitalik Buterin, "EIP-6404: SSZ Transactions Root[DRAFT]," Ethereum Improvement Proposals, no. 6404, 2023. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-6404.