Ethereum Transactions Explained

Joseph H

20th September, 2024

Ethereum has continuously evolved since its inception in 2015. At the core of its functionality lies the concept of transactions—actions that enable everything from simple token transfers to complex interactions with decentralized applications (dApps) and smart contracts. While the essence of transactions has remained constant, the structure, efficiency, and functionality of these operations have undergone significant changes over the years, primarily driven by various Ethereum Improvement Proposals (EIPs).

In this post, I’ll take you through the evolution of Ethereum transactions, starting from the basic legacy transaction model to the advancements introduced by different EIPs over the years that each added support for different forms of expressions.

Transaction Signature Scheme

But first, a bit about the cryptographic signature scheme. Ethereum uses public/private key cryptography to perform authentication. More specifically, the Elliptic Curve Digital Signature Algorithm (ECDSA) is employed with the choice of the widely appreciated secp256k1 curve (same curve used by Bitcoin's transaction signature scheme). A private key takes the form of a randomly generated u256 (32-byte) unsigned integer (with some restrictions) that holds a mathematical relationship with a public key counterpart.

For instance, we might have the private key declared as follows:

from web3 import Web3
from eth_account import Account
from eth_account.signers.local import LocalAccount

account: LocalAccount = Account.from_key('34b4f5ea9a6b25433c14b0253a20e1971b360c4b0085626bbbf012a6a09a2675')

The affiliated public key is derived via ECDSA and is represented by 64 bytes:

>>> public_key = account._key_obj.public_key
>>> public_key
'0x2dfc9b82cac18f581d39130ab46194f01f2d95963188a5535cfbc152d481307dcd1a7778a5fc82851453dad6846d9cf97d7144f899298074b3750ee73e02dcfa'

The public wallet address is subsequently derived from the public key via the process of performing SHA3-256 (Keccak) hashing over the public key and extracting the trailing 20 bytes of the 32 byte output: keccak256(public_key)[:20]
Or more simply, via calling the address parameter in python:

>>> account.address
'0x47457BC3646b1a7e04B269C875a603588bD75D48'

From here, a message (transaction information) is signed by an account using the affiliated private key, and a signature which is produced forms part of the transaction that blockchain software stores for accounting and record-keeping purposes. Then, Blockchain nodes/users are able to take the signature and, in combination with the affiliated message and public key, verify that the message was in fact signed by the account in question.

Okay, now let's dive in to understanding in more detail what the message that's signed comprises of, and subsequently how the transaction hash is derived for each of the following types of transactions that Ethereum has evolved to support over the years.

Legacy Transactions

In the beginning, Ethereum transactions comprised of 6 input fields:

  • nonce: Incremental count of the number of outgoing Txs from the signing EOA address, starting with 0.
  • gasPrice: The price that determines how much ether per unit of gas that the transaction will spend.
  • gasLimit: Sets the upper-bound limit of how many units of gas the transaction will spend at most.
  • to: The ethereum address in which the transaction is interacting/engaging with. Empty in the case of new smart contract creation.
  • value: The quantity of ether that is transferred.
  • data: A field that consists of an arbitrary message, or details corresponding with a method call on a smart contract. E.g. swap(amountIn, minAmountOut)

For legacy transactions, the message that is signed by the private key is the RLP encoding of the raw transaction inputs. Recursive-Length Prefix (RLP) serialization is widely used by the Ethereum client to encode arbitrarily nested arrays of data space-efficiently. (read more)
The eth_utils and rlp libraries come handy for performing these steps in Python. Let's reconstruct the transaction with the hash 0x6b9c5a...d93a77:

from eth_utils import keccak, to_bytes, to_checksum_address
from rlp.sedes import big_endian_int, binary, CountableList, List
import rlp

fields_values = [
  1,
  30000000000,
  90000,
  to_bytes(hexstr='0xAA1A6e3e6EF20068f7F8d8C835d2D22fd5116444'),
  25411899983046459021,
  to_bytes(hexstr='0x0f2c932900000000000000000000000011403b7d3958eaa3e7c24ee1c32592f5d5a9e25f00000000000000000000000092911ba41a6aefdb4b20a050f88c39322a82d24a')
]

fields_sedes = [
  big_endian_int,  # nonce
  big_endian_int,  # gasPrice
  big_endian_int,  # gasLimit
  binary,          # to
  big_endian_int,  # value
  binary,          # input
]

encoded_unsigned_transaction = rlp.encode(fields_values, sedes=List(fields_sedes))

Gives us:

>>> encoded_unsigned_transaction.hex()
f870018506fc23ac0083015f9094aa1a6e3e6ef20068f7f8d8c835d2d22fd5116444890160a93465853d3e8db8440f2c932900000000000000000000000011403b7d3958eaa3e7c24ee1c32592f5d5a9e25f00000000000000000000000092911ba41a6aefdb4b20a050f88c39322a82d24a

We then take the RLP-encoded unsigned transaction details and perform a keccak256 hashing over it to receive our message hash that's ready to be signed:

>>> message_hash = keccak(encoded_unsigned_transaction)
>>> message_hash.hex()
2e2320a7378d57d955ac7473ac552d092f829a4b8249b922849436fa3be2f288

Signing with the user's private key would yield the following signature details:

v = 28
r = '0x723bf9c2054bf7f28b1b60d2f9659096a713a35c5d55884d7fa993d5e792addf'
s = '0x22b9ae999b6361fc0d57bcd74593e1b84dd21470658ff0ec58e285f4596f3ee1'

Furthermore, by appending the signature to RLP-encoding of the transaction details and hashing over it, we can derive the transaction hash:

fields_values += [
  v,
  to_bytes(hexstr=r),
  to_bytes(hexstr=s)
]

fields_sedes += [big_endian_int, binary, binary]

encoded_signed_transaction = rlp.encode(fields_values, sedes=List(fields_sedes))
transaction_hash = keccak(encoded_signed_transaction)
>>> transaction_hash.hex()
'6b9c5a14549f42092ee96093278b1fd0221ba7d252047e311978f206d0d93a77'

Deriving the sender's address

Notice that the from address is not included anywhere. The from address can be derived via performing an ECDSA recovery of the public key using the message hash plus signature details:

recovery_id = v - 27
# recovery_id = v - 27 if v in (27, 28) else (v - 35) % 2
sig = w3.eth.account._keys.Signature(vrs=(
  recovery_id,
  w3.to_int(to_bytes(hexstr=r)),
  w3.to_int(to_bytes(hexstr=s))
))
public_key = sig.recover_public_key_from_msg_hash(message_hash)

>>> public_key.to_address()
'0x92911ba41a6aefdb4b20a050f88c39322a82d24a'

The steps outlined above follows a real world, on-chain example of a Pre-EIP-155 transaction. It's worth noting that for Pre-EIP-155 transactions, v = {0,1} + 27.

Post-EIP-155 Transactions (Still Considered Legacy)

EIP-155 was introduced at block 2,675,000 (Nov-22-2016) and brought optional simple replay attack protection. With the existance of public testnets and the possibility of an Ethereum mainnet hard fork (such as Ethereum Classic), transactions that had otherwise been signed and confirmed (via inclusion into a block) on one chain would become susceptible to being submitted to another blockchain and effectively 'replayed'.

To get around this issue, a new form of transaction was designed that protects transactions from the replay susceptibility. Instead of signing just 6 fields, the message that gets signed in post-EIP-155 transactions also feature an additional 3 fields:

(nonce, gasPrice, gasLimit, to, value, data, chainid, 0, 0)

In the case of mainnet Ethereum, chainid would take on the value 1 while other public blockchains were encouraged to adopt their own unique identifier. In addition to new fields, message hashes would also be signed with v = {0, 1} + CHAIN_ID * 2 + 35 which also served as an indicator that the transaction took on the Post-EIP-155 format (Important seeing as pre-EIP-155 transaction style is still acceptable).

EIP-2718 Transactions

EIP-2718 was introduced as part of the Berlin hard fork upgrade at block 12,244,000 (Apr-15-2021) and introduced the concept of "Transaction Envelopes" with the main goal of making it easier for new transaction types to be introduced without breaking backwards compatibility.

With EIP-2718, transactions are put in an envelope that includes a Transaction Type byte, followed by the encoded transaction data. The format allows for operators in the network to differentiate between different types of transactions easily. Each new transaction type gets its unique identifier, simplifying network upgrades and adding flexibility.

Legacy Transactions that existed before EIP-2718 are considered type 0x00, maintaining their original structure and compatibility.
As of block 12,244,000, Transaction can be either TransactionType || TransactionPayload or LegacyTransaction where:

  • TransactionType is a positive unsigned 8-bit number between 0x01 and 0x7f.
  • TransactionPayload is an opaque byte array whose intepretation depends on TransactionType and defined in future EIPs.
  • LegacyTransaction is rlp([nonce, gasPrice, gasLimit, to, value, data, v, r, s])

For non-legacy transactions, the message hash, that's signed by the user's private key, is derived from concatenating the 8-bit TransactionType with the RLP-encoded unsigned transaction details.

EIP-2930 Transactions

EIP-2930 was also introduced as part of the Berlin upgrade that enabled EIP-2718, and introduced the first transaction type that followed the new format. The transaction type introduced an additional field, accessList (a list of addresses and storage keys), that provides ethereum nodes with predictability when processing the transaction allowing for quicker processing via pre-pricessing and parallel reading capability, and consequently leads to cheaper transactions than the legacy predecessor.

EIP-2930 transactions are of the format 0x01 || rlp([chainId, nonce, gasPrice, gasLimit, to, value, data, accessList, signatureYParity, signatureR, signatureS])

The message hash is computed over TransactionType=1 || TransactionPayload:
keccak256(0x01 || rlp([chainId, nonce, gasPrice, gasLimit, to, value, data, accessList]))

The signatureYParity, signatureR, signatureS elements of this transaction represent a secp256k1 signature over the message hash. The signatureYParity field is assigned to either 0 or 1 and represents the parity of the y coordinate, where as in legacy type transactions, the v parameter carries both signature parity and chain ID information.

EIP-1559 Transactions

At block 12,965,000 (Aug-05-2021), the london hard fork took place and introduced EIP-1559. As well as introducing a new transaction type, it also considerably changed the fee markets and format of new blocks (in terms of size).

EIP-1559 introduced a mechanism where the block gas limit (previously static at 15,000,000) can dynamically vary between 50% and 100% of a newly set maximum block gas limit of 30,000,000 which is intended to handle spikes and troughs in demand more efficiently than under the previous first price auction model.

The newly introduced EIP-1559 transaction type follows the format 0x02 || rlp([chain_id, nonce, max_priority_fee_per_gas, max_fee_per_gas, gas_limit, destination, amount, data, access_list, signature_y_parity, signature_r, signature_s])

The gasPrice parameter from previous transaction types is replaced with (max_priority_fee_per_gas, max_fee_per_gas).

  • max_fee_per_gas sets the upper limit on the accepted amount the sender is willing to pay to cover the block's network fee per gas (aka: base fee). The base fee component of the transaction is burned and effectively taken out from Ethereum's floating supply of ETH.
  • max_priority_fee_per_gas sets the upper limit on the maximum fee per gas that the sender is willing to give to the miner as incentive for faster inclusion of their transaction.

Importantly, the update is backwards-compatible and older transaction types are still considered valid and able to be picked up by newly processed blocks.

The burning mechanism introduced by EIP-1559 only applies to EIP-1559-type transactions (0x02) and is aimed at reducing the overall supply of ETH over time, providing a deflationary pressure on the cryptocurrency. Legacy and EIP-2930 transactions, however, retain the original fee model without any burning.

The message hash, that's signed, is similarly computed over TransactionType=2 || TransactionPayload: keccak256(0x02 || rlp([chainId, nonce, max_priority_fee_per_gas, max_fee_per_gas, gasLimit, to, value, data, accessList]))