0x353535…35351 ETH0 bytes0xAlice 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.
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:
| Field | Example | Meaning |
|---|---|---|
chainId | 1 | Chain on which the signature is intended to work |
nonce | 9 | Sender's account sequence number |
maxPriorityFeePerGas | 2 gwei | Maximum priority fee per gas |
maxFeePerGas | 30 gwei | Maximum total price per gas |
gasLimit | 21000 | Maximum gas this transaction may consume |
to | 0x3535…3535 | Destination account |
value | 1000000000000000000 wei | Native ETH sent to the destination |
data | 0x | Input 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.
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.
0x353535…35351 ETH0 bytes0xSwitch 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.
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 range | Component | Encoding |
|---|---|---|
| 0–3 | Selector · 4 bytes | 0xa9059cbb |
| 4–35 | Recipient · 32 bytes | 12 zero bytes + 20-byte address |
| 36–67 | Amount · 32 bytes | Unsigned 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:
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 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.
| Field | Native ETH | ERC-20 |
|---|---|---|
to | Bob | Token contract |
value | 1000000000000000000 wei | 0 |
data | 0x | Selector + 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.
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:
02The 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.
020x02f0010984773594008506fc23ac00825208943535353535353535353535353535353535353535880de0b6b3a764000080c00xfae77debb64203fbaea6213fcde74f1b138c6854c3d7b44ba1c2ced52c2d8c4d00x2b03b67e070f45175ce9d07c4512720168bd468a24edb6997977a53d48c87a120x733d775fdd689d306e08ac8ab399f34b5a0253b47ed81b8bf2d2a6ea607fcac7The 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.
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:
02The 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.
800x02f873010984773594008506fc23ac00825208943535353535353535353535353535353535353535880de0b6b3a764000080c080a02b03b67e070f45175ce9d07c4512720168bd468a24edb6997977a53d48c87a12a0733d775fdd689d306e08ac8ab399f34b5a0253b47ed81b8bf2d2a6ea607fcac70xbb94970b7e5afad02e4e38a462eacd085a96791deacaab2827d61aeb20e0778eThe 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.
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.
This complete ethers 6.17.0 example reproduces the diagram's default type-2 transaction. It performs no network request and broadcasts nothing.
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.