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.

Let F be the ordered nine-field list above. The type-2 signing data is:

Fields → signing digest
Ordered list · 9 items
chainIdnoncemaxPriorityFeePerGasmaxFeePerGasgasLimittovaluedataaccessList
RLP encode
Type · 1 byte02
RLP list headerf0
Encoded fieldsF
U · signing preimage · 50 bytes
Keccak-256
h_sign32 bytes

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
Type021 B
RLP list headerf048 B payload
chainId011 B
nonce091 B
maxPriorityFeePerGas84773594005 B
maxFeePerGas8506fc23ac006 B
gasLimit8252083 B
to94353535353535353535353535353535353535353521 B
value880de0b6b3a76400001 ETH
data800x · empty
accessListc0[]
U · 50 bytes · signing preimage0x02f0010984773594008506fc23ac00825208943535353535353535353535353535353535353535880de0b6b3a764000080c0
Keccak-256
Signing digest · 32 bytes0xfae77debb64203fbaea6213fcde74f1b138c6854c3d7b44ba1c2ced52c2d8c4d
ECDSA
yParity · r · s

The 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.

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 F.

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

Fields + signature → transaction hash
Ordered list · 12 items
chainIdnoncemaxPriorityFeePerGasmaxFeePerGasgasLimittovaluedataaccessListyParityrs
RLP encode
Type · 1 byte02
RLP list headerf873
Encoded fieldsF + yParity, r, s
T · signed transaction · 118 bytes
Keccak-256
h_tx32 bytes

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.

Fields → signature → transaction
Signing data
0x02RLP(9 fields)
Keccak-256
Signing digest0xfae77d…8c4d
Public test keyECDSA
yParity0
r0x2b03b6…7a12
s0x733d77…cac7
Re-encode
Signed transaction
0x02RLP(9 fields + yParity, r, s)
Keccak-256
Transaction hash0xbb9497…778e
digest + signaturerecover sender
0x9d8a62…5a4f
Signing data
0x02f0010984773594008506fc23ac00825208943535353535353535353535353535353535353535880de0b6b3a764000080c0
Signing digest
0xfae77debb64203fbaea6213fcde74f1b138c6854c3d7b44ba1c2ced52c2d8c4d
r
0x2b03b67e070f45175ce9d07c4512720168bd468a24edb6997977a53d48c87a12
s
0x733d775fdd689d306e08ac8ab399f34b5a0253b47ed81b8bf2d2a6ea607fcac7
Signed transaction
0x02f873010984773594008506fc23ac00825208943535353535353535353535353535353535353535880de0b6b3a764000080c080a02b03b67e070f45175ce9d07c4512720168bd468a24edb6997977a53d48c87a12a0733d775fdd689d306e08ac8ab399f34b5a0253b47ed81b8bf2d2a6ea607fcac7
Transaction hash
0xbb94970b7e5afad02e4e38a462eacd085a96791deacaab2827d61aeb20e0778e
Recovered sender
0x9d8a62f656a8d1615c1294fd71e9cfb3e4855a4f

The 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.

The final bytes

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.

Signed transaction · nested data
Type02
RLP list · 12 items
chainIdnoncemaxPriorityFeePerGasmaxFeePerGasgasLimittovaluedatacalldataaccessListyParityrs

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