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.
Let F be the ordered nine-field list above. The type-2 signing data is:
02f0Fh_sign32 bytesThe 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.
021 Bf048 B payload011 B091 B84773594005 B8506fc23ac006 B8252083 B94353535353535353535353535353535353535353521 B880de0b6b3a76400001 ETH800x · emptyc0[]0x02f0010984773594008506fc23ac00825208943535353535353535353535353535353535353535880de0b6b3a764000080c00xfae77debb64203fbaea6213fcde74f1b138c6854c3d7b44ba1c2ced52c2d8c4dThe first two rows are the type byte and the RLP list header. Every following row shows one field after RLP encoding, in its actual position. Concatenating the rows gives U, the complete signing preimage. Changing the amount or nonce changes that field's encoded bytes and the resulting digest. Empty data stays 80.
The default U 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 F.
The wallet then encodes a new list containing the original fields plus those three values:
02f873F + yParity, r, sh_tx32 bytesThe 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.
0x02RLP(9 fields)0xfae77d…8c4d00x2b03b6…7a120x733d77…cac70x02RLP(9 fields + yParity, r, s)0xbb9497…778e0x9d8a62…5a4fThe interactive diagram computes both encodings and the ECDSA signature locally. It uses the public 0x46…46 test key from EIP-155, not a connected wallet. This key is public and must never hold funds. Changing amount or nonce keeps the recovered sender constant because the example signs each new payload again with the same test key.
eth_sendRawTransaction takes T, 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.
02For 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.