EIP-6404 - SSZ transactions

Created 2023-01-30
Status Draft
Category Core
Type Standards Track
Authors
Requires

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

  5. 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 addresses these by defining 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
BYTES_PER_FIELD_ELEMENT uint64(32)
FIELD_ELEMENTS_PER_BLOB uint64(4096)
Name SSZ equivalent
Hash32 Bytes32
ExecutionAddress Bytes20
VersionedHash Bytes32
KZGCommitment Bytes48
KZGProof Bytes48
Blob ByteVector[BYTES_PER_FIELD_ELEMENT * FIELD_ELEMENTS_PER_BLOB]

Signatures

Transaction signatures are represented by their native, opaque representation, prefixed by a value indicating their algorithm.

Name SSZ equivalent
ExecutionSignature ProgressiveByteList
ExecutionSignatureAlgorithm uint8

Registry

Helper functions for interacting with signatures are collected in a registry for each algorithm.

@dataclass
class ExecutionSignatureAttributes(object):
    validate: Callable[[ExecutionSignature], None]
    recover_signer: Callable[[ExecutionSignature, Hash32], ExecutionAddress]

execution_signature_registry: Dict[ExecutionSignatureAlgorithm, ExecutionSignatureAttributes] = {}

def validate_execution_signature(
    signature: ExecutionSignature,
    expected_algorithm: Optional[ExecutionSignatureAlgorithm]=None,
):
    assert len(signature) > 0
    algorithm = signature[0]
    if expected_algorithm is not None:
        assert algorithm == expected_algorithm
    assert algorithm in execution_signature_registry
    execution_signature_registry[algorithm].validate(signature)

def recover_execution_signer(signature: ExecutionSignature, sig_hash: Hash32) -> ExecutionAddress:
    algorithm = signature[0]
    return execution_signature_registry[algorithm].recover_signer(signature, sig_hash)

ECDSA signatures

Name Value Description
SECP256K1_ALGORITHM ExecutionSignatureAlgorithm(0x01) Prefix indicating a secp256k1 ECDSA signature
SECP256K1_SIGNATURE_SIZE 1 + 32 + 32 + 1 (= 66) Byte length of a secp256k1 ECDSA signature
def secp256k1_pack(r: uint256, s: uint256, y_parity: uint8) -> ExecutionSignature:
    return (
        bytes([SECP256K1_ALGORITHM]) +
        r.to_bytes(32, 'big') + s.to_bytes(32, 'big') + bytes([y_parity])
    )

def secp256k1_unpack(signature: ExecutionSignature) -> tuple[uint256, uint256, uint8]:
    assert len(signature) == SECP256K1_SIGNATURE_SIZE
    assert signature[0] == SECP256K1_ALGORITHM
    r = uint256.from_bytes(signature[1:33], 'big')
    s = uint256.from_bytes(signature[33:65], 'big')
    y_parity = signature[65]
    return (r, s, y_parity)

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

def secp256k1_recover_signer(signature: ExecutionSignature, sig_hash: Hash32) -> ExecutionAddress:
    ecdsa = ECDSA()
    recover_sig = ecdsa.ecdsa_recoverable_deserialize(signature[1:65], signature[65])
    public_key = PublicKey(ecdsa.ecdsa_recover(sig_hash, recover_sig, raw=True))
    uncompressed = public_key.serialize(compressed=False)
    return ExecutionAddress(keccak(uncompressed[1:])[12:])

execution_signature_registry[SECP256K1_ALGORITHM] = ExecutionSignatureAttributes(
    validate=secp256k1_validate,
    recover_signer=secp256k1_recover_signer,
)

Gas fees

The different kinds of gas fees are combined into a single structure.

Name SSZ equivalent Description
FeePerGas uint256 Fee per unit of gas
class BasicFeesPerGas(ProgressiveContainer(active_fields=[1])):
    regular: FeePerGas

class BlobFeesPerGas(ProgressiveContainer(active_fields=[1, 1])):
    regular: FeePerGas
    blob: FeePerGas

Normalized transactions

RLP transactions are converted to a normalized SSZ representation. Their original RLP TransactionType is retained to enable recovery of their original RLP representation and associated sig_hash and historical tx_hash values.

Name SSZ equivalent Description
TransactionType uint8 EIP-2718 transaction type, range [0x00, 0x7F]
ChainId uint256 EIP-155 chain ID
GasAmount uint64 Amount in units of gas

Replayable legacy transactions

The original RLP representation of these transactions is replayable across networks with different chain ID.

class RlpLegacyReplayableBasicTransactionPayload(
    ProgressiveContainer(active_fields=[1, 0, 1, 1, 1, 1, 1, 1])
):
    type_: TransactionType  # 0x00
    nonce: uint64
    max_fees_per_gas: BasicFeesPerGas
    gas: GasAmount
    to: ExecutionAddress
    value: uint256
    input_: ProgressiveByteList

class RlpLegacyReplayableCreateTransactionPayload(
    ProgressiveContainer(active_fields=[1, 0, 1, 1, 1, 0, 1, 1])
):
    type_: TransactionType  # 0x00
    nonce: uint64
    max_fees_per_gas: BasicFeesPerGas
    gas: GasAmount
    value: uint256
    input_: ProgressiveByteList

EIP-155 legacy transactions

These transactions are locked to a single EIP-155 chain ID.

class RlpLegacyBasicTransactionPayload(
    ProgressiveContainer(active_fields=[1, 1, 1, 1, 1, 1, 1, 1])
):
    type_: TransactionType  # 0x00
    chain_id: ChainId
    nonce: uint64
    max_fees_per_gas: BasicFeesPerGas
    gas: GasAmount
    to: ExecutionAddress
    value: uint256
    input_: ProgressiveByteList

class RlpLegacyCreateTransactionPayload(
    ProgressiveContainer(active_fields=[1, 1, 1, 1, 1, 0, 1, 1])
):
    type_: TransactionType  # 0x00
    chain_id: ChainId
    nonce: uint64
    max_fees_per_gas: BasicFeesPerGas
    gas: GasAmount
    value: uint256
    input_: ProgressiveByteList

Legacy transactions

RlpLegacyTransactionPayload = (
    RlpLegacyReplayableBasicTransactionPayload |
    RlpLegacyReplayableCreateTransactionPayload |
    RlpLegacyBasicTransactionPayload |
    RlpLegacyCreateTransactionPayload
)

EIP-2930 access list transactions

These transactions support specifying an EIP-2930 access list.

class AccessTuple(Container):
    address: ExecutionAddress
    storage_keys: ProgressiveList[Hash32]

class RlpAccessListBasicTransactionPayload(
    ProgressiveContainer(active_fields=[1, 1, 1, 1, 1, 1, 1, 1, 1])
):
    type_: TransactionType  # 0x01
    chain_id: ChainId
    nonce: uint64
    max_fees_per_gas: BasicFeesPerGas
    gas: GasAmount
    to: ExecutionAddress
    value: uint256
    input_: ProgressiveByteList
    access_list: ProgressiveList[AccessTuple]

class RlpAccessListCreateTransactionPayload(
    ProgressiveContainer(active_fields=[1, 1, 1, 1, 1, 0, 1, 1, 1])
):
    type_: TransactionType  # 0x01
    chain_id: ChainId
    nonce: uint64
    max_fees_per_gas: BasicFeesPerGas
    gas: GasAmount
    value: uint256
    input_: ProgressiveByteList
    access_list: ProgressiveList[AccessTuple]

RlpAccessListTransactionPayload = (
    RlpAccessListBasicTransactionPayload |
    RlpAccessListCreateTransactionPayload
)

EIP-1559 fee market transactions

These transactions support specifying EIP-1559 priority fees.

class RlpBasicTransactionPayload(
    ProgressiveContainer(active_fields=[1, 1, 1, 1, 1, 1, 1, 1, 1, 1])
):
    type_: TransactionType  # 0x02
    chain_id: ChainId
    nonce: uint64
    max_fees_per_gas: BasicFeesPerGas
    gas: GasAmount
    to: ExecutionAddress
    value: uint256
    input_: ProgressiveByteList
    access_list: ProgressiveList[AccessTuple]
    max_priority_fees_per_gas: BasicFeesPerGas

class RlpCreateTransactionPayload(
    ProgressiveContainer(active_fields=[1, 1, 1, 1, 1, 0, 1, 1, 1, 1])
):
    type_: TransactionType  # 0x02
    chain_id: ChainId
    nonce: uint64
    max_fees_per_gas: BasicFeesPerGas
    gas: GasAmount
    value: uint256
    input_: ProgressiveByteList
    access_list: ProgressiveList[AccessTuple]
    max_priority_fees_per_gas: BasicFeesPerGas

RlpFeeMarketTransactionPayload = (
    RlpBasicTransactionPayload |
    RlpCreateTransactionPayload
)

EIP-4844 blob transactions

These transactions support specifying EIP-4844 blobs.

class RlpBlobTransactionPayload(
    ProgressiveContainer(active_fields=[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1])
):
    type_: TransactionType  # 0x03
    chain_id: ChainId
    nonce: uint64
    max_fees_per_gas: BlobFeesPerGas
    gas: GasAmount
    to: ExecutionAddress
    value: uint256
    input_: ProgressiveByteList
    access_list: ProgressiveList[AccessTuple]
    max_priority_fees_per_gas: BasicFeesPerGas
    blob_versioned_hashes: ProgressiveList[VersionedHash]

EIP-7702 set code transactions

These transactions support specifying an EIP-7702 authorization list.

class RlpReplayableBasicAuthorizationPayload(ProgressiveContainer(active_fields=[1, 0, 1, 1])):
    magic: TransactionType  # 0x05
    address: ExecutionAddress
    nonce: uint64

class RlpBasicAuthorizationPayload(ProgressiveContainer(active_fields=[1, 1, 1, 1])):
    magic: TransactionType  # 0x05
    chain_id: ChainId
    address: ExecutionAddress
    nonce: uint64

class RlpSetCodeAuthorizationPayload(CompatibleUnion({
    0x01: RlpReplayableBasicAuthorizationPayload,
    0x02: RlpBasicAuthorizationPayload,
})):
    pass

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

class RlpSetCodeTransactionPayload(
    ProgressiveContainer(active_fields=[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1])
):
    type_: TransactionType  # 0x04
    chain_id: ChainId
    nonce: uint64
    max_fees_per_gas: BasicFeesPerGas
    gas: GasAmount
    to: ExecutionAddress
    value: uint256
    input_: ProgressiveByteList
    access_list: ProgressiveList[AccessTuple]
    max_priority_fees_per_gas: BasicFeesPerGas
    authorization_list: ProgressiveList[RlpSetCodeAuthorization]

Transaction helpers

class TransactionPayload(CompatibleUnion({
    0x01: RlpLegacyReplayableBasicTransactionPayload,
    0x02: RlpLegacyReplayableCreateTransactionPayload,
    0x03: RlpLegacyBasicTransactionPayload,
    0x04: RlpLegacyCreateTransactionPayload,
    0x05: RlpAccessListBasicTransactionPayload,
    0x06: RlpAccessListCreateTransactionPayload,
    0x07: RlpBasicTransactionPayload,
    0x08: RlpCreateTransactionPayload,
    0x09: RlpBlobTransactionPayload,
    0x0a: RlpSetCodeTransactionPayload,
})):
    pass

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

class RlpTxType(IntEnum):
    LEGACY = 0x00
    ACCESS_LIST = 0x01
    FEE_MARKET = 0x02
    BLOB = 0x03
    SET_CODE = 0x04
    SET_CODE_MAGIC = 0x05

def validate_transaction(tx: Transaction):
    tx_data = tx.payload.data()

    if hasattr(tx_data, "type_"):
        match tx_data.type_:
            case RlpTxType.LEGACY:
                assert isinstance(tx_data, RlpLegacyTransactionPayload)
            case RlpTxType.ACCESS_LIST:
                assert isinstance(tx_data, RlpAccessListTransactionPayload)
            case RlpTxType.FEE_MARKET:
                assert isinstance(tx_data, RlpFeeMarketTransactionPayload)
            case RlpTxType.BLOB:
                assert isinstance(tx_data, RlpBlobTransactionPayload)
            case RlpTxType.SET_CODE:
                assert isinstance(tx_data, RlpSetCodeTransactionPayload)
            case _:
                assert False

    if hasattr(tx_data, "authorization_list"):
        for auth in tx_data.authorization_list:
            auth_data = auth.payload.data()

            if hasattr(auth_data, "magic"):
                assert auth_data.magic == RlpTxType.SET_CODE_MAGIC
            if hasattr(auth_data, "chain_id"):
                assert auth_data.chain_id != 0

            validate_execution_signature(auth.signature, expected_algorithm=SECP256K1_ALGORITHM)

    validate_execution_signature(tx.signature, expected_algorithm=SECP256K1_ALGORITHM)

Execution block header changes

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

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

block_header.transactions_root = transactions.hash_tree_root()

Engine API

In the engine API, the semantics of the transactions field in ExecutionPayload versions adopting this EIP are changed to emit transactions using SSZ serialization.

Consensus ExecutionPayload changes

When building a consensus ExecutionPayload, the transactions list is no longer opaque and uses the new Transaction type, aligning the transactions_root across execution blocks and execution payloads.

class ExecutionPayload(...):
    ...
    transactions: ProgressiveList[Transaction]
    ...

Unique transaction identifier

For each transaction, an additional identifier tx_root SHALL be assigned:

def compute_tx_root(tx: Transaction) -> Hash32:
    return Hash32(tx.hash_tree_root())

Note that this tx_root differs from the existing tx_hash. Existing APIs based on tx_hash remain available.

Rationale

Forward compatibility

The proposed transaction design is extensible with new fee types, new signature types, and entirely new transaction features (e.g., CREATE2), while retaining compatibility with the proposed transactions.

Verifier improvements

The transactions_root is effectively constructed from the list of tx_root, enabling transaction inclusion proofs. Further, partial data becomes provable, such as destination / amount without requiring the full calldata. This can reduce gas cost or zk proving cost when verifying L2 chain data in an L1 smart contract.

Consensus client improvements

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 that validation. Future versions of the engine API could be simplified to drop the transfers of blob_kzg_commitments to the EL.

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 conversion from RLP transactions to SSZ is lossless. The original RLP sig_hash and tx_hash can be recovered from the SSZ representation.

RLP and SSZ transactions may clash when encoded. It is essential to use only a single format within one channel.

Security Considerations

None

Copyright

Copyright and related rights waived via CC0.