Part III · Chapter 9 of 43 · Spurious Dragon

EIP-155: binding a signature to its chain

A pre-155 Ethereum signature said who signed and what they signed — but never which network it was for. So the exact same signed bytes were valid on every chain that shared your address, and a transaction could be replayed across a fork like ETH/ETC. EIP-155 folds a chainId into what you sign, so a signature is locked to one chain.

Updated Jun 23, 2026 · 10 min
Assumed
  • digital signatures
  • what a transaction is

A signature proves two things: who signed, and what they signed. Early Ethereum transactions signed over [nonce, gasPrice, gasLimit, to, value, data] — the action, but not the network it was meant for. That omission seems harmless until you remember that your private key derives the same address on every Ethereum-like chain. So a perfectly valid signed transaction on one network is a perfectly valid signed transaction on another — copy the bytes, submit them elsewhere, and they execute again. This turned catastrophic at the 2016 ETH/ETC split, when two chains suddenly shared addresses, balances, and nonces. EIP-155 is the one-line idea that fixes it: put the chain into the signature. Let’s derive it.

1 step A signature with no chain

The problem: a signature that’s valid everywhere

To send a transaction you sign the hash of its fields. Before EIP-155 those fields were just [nonce, gasPrice, gasLimit, to, value, data], and the signature (v, r, s) used v = 27 or 28 — purely the recovery id The extra bit (parity) stored in v that lets a verifier recover the signer's public key from r and s. Pre-155 it was just 27 or 28 — one bit, carrying no other information. , telling the verifier which of two possible public keys recovered. Nowhere in the signed data is there anything that says “this transaction is for Ethereum mainnet.” So the signature authorizes the action on any chain that recognizes your address.

one signed transaction, no chain identity signed tx v = 27 / 28 Chain A · ETH ✓ accepted Chain B · ETC ✓ replayed! the same signed bytes are valid on every chain that shares your address
A pre-155 signature covers the action but not the network. Because your key gives you the same address on every chain, the identical signed bytes are valid on all of them — so a transaction broadcast on one chain can be copied and replayed on another.

This is a replay attack Re-broadcasting a validly-signed transaction on a different chain (or after a fork) where the signer never intended it. Because the signature is still valid, the transaction executes again, moving funds the user never meant to move there. . After the DAO hard fork, ETH and ETC shared identical history up to the split, so a transaction you made on one was instantly replayable on the other — draining or duplicating transfers with no extra signing.

→ Step 2: make the chain part of what you sign.

2 step Sign the chainId too

Fold the chainId into the signed data

Give every network a public number — a chainId A unique integer identifying a chain: 1 for Ethereum mainnet, 11155111 for Sepolia, 61 for Ethereum Classic, and so on. Registered so wallets and nodes agree on which network they're signing for. (mainnet is 1, Ethereum Classic is 61). Now, instead of hashing just the six fields, hash the list with the chainId appended plus two zero placeholders: [nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0]. That whole list is what gets keccak-hashed and signed. Because the chainId is now inside the hash, the signed digest is different on every chain — a signature computed for chainId 1 simply isn’t a signature over the digest that chainId 61 would compute.

fold the chain id into the signed data rlp[nonce, gasPrice, gas, to, value, data] before — valid on any chain rlp[ … , chainId, 0, 0] after — bound to one chainId keccak now differs per chain
Append chainId (and two zero placeholders) to the fields before hashing. The chainId is now part of the signed digest, so the same transaction produces a different hash — and thus needs a different signature — on each chain.

The two trailing zeros are a neat trick: they make the to-be-signed structure look like a nine-element transaction whose (v, r, s) slots are (chainId, 0, 0), reusing the existing encoding shape rather than inventing a new one.

→ Step 3: smuggle the chainId into v.

3 step Carry it in v

Encode the chainId in v — backward-compatibly

There’s no room for a new field, so reuse v. Redefine it as v = chainId·2 + 35 + parity, where parity is the 0/1 recovery bit. On mainnet (chainId 1) that gives v = 37 or 38; the receiver reads v, backs out the chainId, and knows exactly which digest to reconstruct before recovering the signer. And crucially, every legacy value is below this range: v = 27/28 still means “unprotected, no chainId,” so old transactions remain valid forever — they’re just replayable, by their own choice.

why it's backward-compatible: the v ranges can't overlap chainId·2 + 35 + parity → 37 or 38 27283738 legacy unprotected 29–36 · empty gap chainId 1 protected chainId·2+35 always lands past 28 — read v and you know the scheme
The chainId rides inside v as chainId·2 + 35 + parity (so chainId 1 → v of 37 or 38). The receiver recovers the chainId from v; legacy v = 27/28 transactions still validate, simply without replay protection.
Formula
v = chainId 1 · 2 + 35 2 + parity 3
  1. 1 the network's id — 1 for mainnet, 61 for Ethereum Classic
  2. 2 the EIP-155 offset; keeps legacy v = 27/28 valid
  3. 3 the signature's recovery bit, 0 or 1
The chain is welded into v, so the signature only recovers the right signer on its own chain.

→ Step 4: watch the wrong chain reject it.

4 step Wrong chain, rejected

The payoff: a replay fails signer recovery

Now follow an attacker copying your chainId-1 transaction onto chainId 61. That chain validates by reconstructing the signed digest with its own chainId, 61 — not 1. The hash it builds is different from the one you actually signed, so when it runs ECDSA recovery on your (r, s), the public key that pops out is not your address. The transaction looks like it was signed by some random account that has no funds, and it’s rejected. The signature was cryptographically welded to chain 1; it cannot travel.

replay it on the wrong chain tx signed for chainId 1 replay → chain · chainId 61 recompute hash w/ 61 signer ≠ sender ✗ rejected chain 61 recomputes the hash with its own id → recovery fails → rejected
Replayed on chainId 61, the chain rebuilds the digest with its own id, so signer recovery returns the wrong address — not the sender. The signature only matches on the chain it was made for, so the replay is rejected.
04 Go Deeper Where to take it from here