46 KiB
eip | title | description | author | discussions-to | status | type | category | created | requires |
---|---|---|---|---|---|---|---|---|---|
6404 | SSZ Transactions Root | Migration of transactions MPT commitment to SSZ | Etan Kissling (@etan-status), Vitalik Buterin (@vbuterin) | https://ethereum-magicians.org/t/eip-6404-ssz-transactions-root/12783 | Draft | Standards Track | Core | 2023-01-30 | 155, 658, 1559, 2718, 2930, 4844, 6475 |
Abstract
This EIP defines a migration process of existing Merkle-Patricia Trie (MPT) commitments for transactions to Simple Serialize (SSZ).
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:
-
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.
-
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.
-
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.
-
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 consensusExecutionPayloadHeader
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-2718LegacyTransaction
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:
- Baseline: Opaque
ByteList
containing the transaction's original representation - SSZ Union: RLP encoded transactions converted to SSZ objects
- 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:
- Transaction: Obtain the sequential
tx_index
within anExecutionPayload
for a specifictx_hash
- Amount: Proof that a transaction sends a certain minimum amount to a specific destination
- Sender: Obtain sender addres who sent a certain minimum amount to a specific destination
- 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 specifictx_root
, which differs for non-SSZ transaction types. Applications that wish to verify transaction data against thetransactions_root
are required to migrate totx_root
. Thetx_root
is deteriministically computable from the full transaction data. For these measurements, it is assumed that the application verifies the proof usingtx_root
where necessary. - (**) The SSZ Union representation does not commit to the transaction's
tx_from
. If needed,tx_from
can be recovered fromsignature
andsig_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'ssig_hash
, requiring the entire transaction to be fetched to computetx_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
andnonce
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 computetx_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 fromsignature
andsig_hash
using secp256k1 public key recovery and keccak256 hashing. - (**)
tx_to
for transactions that deploy a new smart contract is computed fromtx_from
andnonce
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 topayload.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
Copyright and related rights waived via CC0.