Alice sends 1 ETH to Bob. Her wallet encodes the destination, amount, and remaining transaction fields into a byte sequence, hashes it with Keccak-256 ⁠, and signs the digest with her private key.

This example follows an EIP-1559 type-2 transaction from its fields to its signing preimage, signature, and final serialized bytes.

The transaction fields

Assume Alice sends 1 ETH to 0x3535353535353535353535353535353535353535. The destination is an ordinary account with no code or delegation. The examples use chain ID 1, nonce 9, and illustrative fee caps.

The unsigned type-2 payload has nine fields, in this order:

FieldExampleMeaning
chainId1Chain on which the signature is intended to work
nonce9Sender's account sequence number
maxPriorityFeePerGas2 gweiMaximum priority fee per gas
maxFeePerGas30 gweiMaximum total price per gas
gasLimit21000Maximum gas this transaction may consume
to0x3535…3535Destination account
value1000000000000000000 weiNative ETH sent to the destination
data0xInput bytes; empty for this transfer
accessList[]Optional addresses and storage keys to prewarm

type: 2 selects the outer envelope. It is not a tenth field inside that RLP list.

from is also absent from the serialized payload. The node derives the sender from the signature and signing digest. A wallet request may include from to select an account, but that JSON property is not an independent claim the protocol trusts.

The access list changes access charging; it is not an approval list or a restriction on which contracts the transaction may call. Our transfer needs no entries.

For contract creation, the destination is empty and the data supplies initialization code. Here the destination is a 20-byte address, so this is a transaction to an existing address rather than contract creation.

Value and calldata

Native ETH has its own transaction field: value. An ordinary 1 ETH transfer does not encode transfer(...) in data.

A contract call uses that same outer envelope. Its to points to the contract, while data supplies the bytes the contract will interpret. On entry, the EVM exposes these as msg.data; the native ETH carried by the call is msg.value.

Value × calldata
1 ETH
to0x353535…3535
value1 ETH
data0 bytes
Calldata · 0 bytes0x

Switch to ERC-20. The outer to becomes the token contract, and Bob moves into the function arguments. The outer value is zero because this example transfers tokens rather than ETH. The token contract updates its own balance storage.

A call can also carry both nonempty data and native ETH, provided the destination's code accepts that value. Sending empty data to a Solidity contract can enter receive or fallback; it does not imply that no code runs. This is why a contract transfer may require more than 21,000 gas.

Calldata is input, not executable bytecode supplied by the caller. The destination's existing code decides how to interpret it. It is also public: the transaction signature authenticates these bytes without encrypting them.

Assemble the call

For a conventional Solidity ABI call, the first four bytes select a function:

The first four bytes of keccak256("transfer(address,uint256)") are 0xa9059cbb.

The canonical signature contains parameter types, with no names or spaces. The arguments follow the selector. For this function, each occupies one 32-byte word:

Byte rangeComponentEncoding
0–3Selector · 4 bytes0xa9059cbb
4–35Recipient · 32 bytes12 zero bytes + 20-byte address
36–67Amount · 32 bytesUnsigned integer, left-padded with zero bytes

A token with 6 decimals represents one token as 1000000 units. Decimal precision is a token convention; the ABI encodes an integer. Neither 1 token nor 6 decimals is transmitted as text.

This ethers 6.17.0 example constructs and checks the 68-byte calldata:

JS
import assert from 'node:assert/strict';
import { Interface, getBytes } from 'ethers';
 
const abi = new Interface([
  'function transfer(address to, uint256 amount) returns (bool)',
]);
const recipient = '0x3535353535353535353535353535353535353535';
const data = abi.encodeFunctionData('transfer', [recipient, 1_000_000n]);
 
assert.equal(data.slice(0, 10), '0xa9059cbb');
assert.equal(getBytes(data).length, 68);
const decoded = abi.decodeFunctionData('transfer', data);
assert.equal(decoded[0].toLowerCase(), recipient);
assert.equal(decoded[1], 1_000_000n);
console.log(data);

Dynamic arguments such as string and bytes need more structure: a word in the argument head points to a tail containing a length and padded contents. Those offsets are relative to the start of the argument encoding, after the selector. The fixed two-word example above does not need offsets.

The selector and ABI layout are conventions enforced by contract code. The Ethereum transaction format only requires data to be bytes. A four-byte selector alone does not establish what arbitrary code at an address will do.

The outer envelope

The calldata above fills just one field: data. The other fields retain their positions in the outer transaction. For an ERC-20 transfer, to is the token contract and value is zero; Bob and the token amount are inside data.

FieldNative ETHERC-20
toBobToken contract
value1000000000000000000 wei0
data0xSelector + recipient word + amount word

The examples use fixed chainId, nonce, fee fields, gasLimit, and accessList so the complete transaction bytes can be reproduced. These are outer fields, not part of the ABI arguments. Changing the ETH amount changes value while calldata stays 0x; changing the token amount changes the last ABI word while value stays zero.

Encode before signing

A JavaScript object is convenient for preparing a request. It is not what ECDSA signs.

A type-2 signing preimage starts with 0x02, followed by an RLP list of nine fields in this order:

Signing preimage · field layout
Type02
RLP · 9 ordered fields
chainIdnoncemaxPriorityFeePerGasmaxFeePerGasgasLimittovaluedatacalldata · 0xaccessList

The 0x02 is a single byte and is included in the hash. RLP ⁠ serializes the outer transaction fields; the ABI encoding, if present, is already inside the data field. These are two different layers of encoding.

RLP integers use their shortest big-endian byte representation. Zero is encoded from empty bytes, giving 0x80; integer one becomes 0x01. An empty byte string and an empty list are also different: empty data encodes as 0x80, while empty accessList encodes as 0xc0.

Native transfer · signing preimage
Type
RLP header
chainId
nonce
maxPriorityFeePerGas
maxFeePerGas
gasLimit
to
value
data
accessList
02

Signing preimage

50 bytes0x02f0010984773594008506fc23ac00825208943535353535353535353535353535353535353535880de0b6b3a764000080c0
01Keccak-256

Signing digest

32 bytes0xfae77debb64203fbaea6213fcde74f1b138c6854c3d7b44ba1c2ced52c2d8c4d
02ECDSATest key

Signature

yParity0
r0x2b03b67e070f45175ce9d07c4512720168bd468a24edb6997977a53d48c87a12
s0x733d775fdd689d306e08ac8ab399f34b5a0253b47ed81b8bf2d2a6ea607fcac7

The field selector follows the byte order: type, RLP list header, then the nine fields after RLP encoding. Select a field to inspect its encoded bytes on the right and locate them in the complete signing preimage below. This example sends 1 ETH with nonce 9. Empty data encodes as 80.

The signing preimage is 50 bytes: one type byte, one RLP list-header byte (f0), and 48 bytes of encoded fields. Keccak-256 maps the complete sequence to the 32-byte h_sign consumed by ECDSA.

Produce the signature

The wallet uses the sender's private key to sign h_sign with secp256k1 ECDSA. A hardware wallet may keep the key inside its device and return only the signature. The private key is never a transaction field.

For type 2, the signature contributes yParity, r, and s. yParity is the recovery parity, 0 or 1. Unlike the legacy EIP-155 v, it does not pack the chain ID; type 2 already includes chain ID explicitly in its field list.

The wallet then encodes a new list containing the original fields plus those three values:

Signed transaction · field layout
Type02
RLP · 12 ordered fields
chainIdnoncemaxPriorityFeePerGasmaxFeePerGasgasLimittovaluedatacalldata · 0xaccessListyParityrs

The signature values are appended as list items before RLP encoding. Adding fields changes both the list payload and its RLP length prefix: in this example, f0 becomes f873. Appending signature bytes to an already encoded list would leave the wrong header.

h_sign is the digest authorized by the key. h_tx is the hash identifying the complete signed transaction. They hash different byte sequences. Changing a field changes the signing digest, requires a corresponding signature, and changes the final transaction hash.

Signature → transaction bytes
Type
RLP header
chainId
nonce
maxPriorityFeePerGas
maxFeePerGas
gasLimit
to
value
data
accessList
yParity
r
s
80

Signed transaction

118 bytes0x02f873010984773594008506fc23ac00825208943535353535353535353535353535353535353535880de0b6b3a764000080c080a02b03b67e070f45175ce9d07c4512720168bd468a24edb6997977a53d48c87a12a0733d775fdd689d306e08ac8ab399f34b5a0253b47ed81b8bf2d2a6ea607fcac7
01Keccak-256

Transaction hash

32 bytes0xbb94970b7e5afad02e4e38a462eacd085a96791deacaab2827d61aeb20e0778e

The diagram continues the same 1 ETH transfer with nonce 9. It computes the encoding and signature locally using the public 0x46…46 test key from EIP-155, not a connected wallet. This key is public and must never hold funds. The recovered sender is derived from the signing digest and signature, not from the transaction hash.

The final bytes

eth_sendRawTransaction takes the complete signed transaction. It does not take calldata by itself. The node can decode the envelope to find to, value, and data, then recover the sender from the signature.

For the native transfer, this data field is empty bytes and its RLP encoding is 0x80. For the ERC-20 call, it contains the 68 ABI-encoded bytes. 0x80 is an RLP encoding of emptiness, not the native transfer's calldata. See RLP ⁠ for the byte-string and list prefix rules.

Assemble and verify

This complete ethers 6.17.0 example reproduces the diagram's default type-2 transaction. It performs no network request and broadcasts nothing.

JS
import assert from 'node:assert/strict';
import { Wallet, Transaction, keccak256, parseEther, parseUnits } from 'ethers';
 
const wallet = new Wallet('0x' + '46'.repeat(32)); // Public test key
const request = {
  type: 2,
  chainId: 1,
  nonce: 9,
  maxPriorityFeePerGas: parseUnits('2', 'gwei'),
  maxFeePerGas: parseUnits('30', 'gwei'),
  gasLimit: 21000n,
  to: '0x3535353535353535353535353535353535353535',
  value: parseEther('1'),
  data: '0x',
  accessList: [],
};
 
const unsigned = Transaction.from(request);
const raw = await wallet.signTransaction(request);
const signed = Transaction.from(raw);
 
assert.equal(unsigned.unsignedHash, keccak256(unsigned.unsignedSerialized));
assert.equal(signed.hash, keccak256(raw));
assert.equal(signed.from, wallet.address);
assert.notEqual(signed.hash, unsigned.unsignedHash);
 
const changed = Transaction.from({
  ...request,
  value: parseEther('2'),
  signature: signed.signature,
});
assert.notEqual(changed.from, wallet.address);
console.log({ signingHash: unsigned.unsignedHash, raw, txHash: signed.hash });

Wallet.signTransaction expects a populated request and returns serialized signed bytes. sendTransaction is the higher-level path that can populate and submit through a provider. A browser wallet may expose eth_sendTransaction and handle approval, signing, and submission internally instead of returning raw bytes to the page.

References