The Ethereum Wallet & Signing Bible
Every EIP and ERC that matters for wallets, transaction signing, signature verification, and account abstraction — with history, diagrams, Solidity & TypeScript, and the 2026 ecosystem map.
EIP-155EIP-2718EIP-1559EIP-191EIP-712ERC-1271ERC-6492ERC-7739EIP-2612ERC-4337EIP-7702ERC-7579ERC-6900EIP-5792
1 · A Brief History: From One Key to Programmable Accounts
Every standard in this guide exists because of one design decision made in 2014 — and a decade spent escaping its consequences.
When Ethereum launched (Frontier, July 2015), it inherited Bitcoin's account security model: an account is a keypair, and a signature from the private key is absolute, unconditional authority. That was the simplest thing that could work. But Ethereum also introduced something Bitcoin didn't have — contract accounts, on-chain programs with their own storage and balance. From day one there were two kinds of citizens: EOAs (key-controlled, can start transactions, zero logic) and contracts (logic-controlled, infinitely programmable, but unable to act on their own).
The tension was visible almost immediately. 2016–2017: the first multisig wallets appeared (the Parity multisig — famously frozen in 2017 by a library self-destruct, losing 513k ETH — and Gnosis Safe, which survived and became the institutional standard). These proved people wanted accounts with rules: multiple approvers, daily limits, recovery. 2016: EIP-155 added chainId to signatures after the ETH/ETC split made cross-chain replay a live exploit. 2018: ERC-1271 answered "how does a contract sign things?"; EIP-1014 (CREATE2) made counterfactual wallet addresses possible — an address you can fund before the wallet exists. 2017–2020: EIP-712 gave signatures human-readable structure, and EIP-2612 used it to make token approvals gasless.
Meanwhile the "make accounts programmable at the protocol level" dream kept stalling: EIP-86, EIP-2938 (native account abstraction) both died — consensus changes are slow and risky. 2021: Vitalik Buterin and the eth-infinitism team (Yoav Weiss, Dror Tirosh et al.) published ERC-4337: account abstraction with zero protocol changes, using an alternative mempool and a singleton EntryPoint contract. The EntryPoint went live on mainnet in March 2023, and an industry grew around it — bundlers, paymasters, smart account SDKs (Biconomy, ZeroDev, Alchemy, Pimlico, Safe modules).
But 4337 accounts have new addresses — useless for the hundreds of millions of existing EOAs. 2024: EIP-3074 (AUTH/AUTHCALL opcodes) was approved for inclusion, then withdrawn after a community storm over its security model. Vitalik proposed EIP-7702 as the replacement in about a day. May 7, 2025: the Pectra upgrade shipped 7702 — EOAs can now carry code, and the same account you made in 2017 can be a smart wallet today. 2026: the mainstream architecture is "7702 + 4337": your old address, delegated to a modular (ERC-7579) implementation, transacting through bundlers with sponsored gas — while EIP-7701 (native AA, the "endgame") sits on the long-term roadmap.
An EOA is a personal debit card: one PIN, absolute power, no recovery — lose the PIN and the money is gone; leak it and the money is also gone. A smart contract wallet is a corporate bank account: 2-of-3 director signatures, daily limits, an assistant who can pay small invoices (session keys), and the ability to replace a lost keycard without changing the account number. The entire history above is the industry building the corporate account — and then (7702) figuring out how to retrofit the corporate features onto everyone's existing debit cards.
2 · From Seed Phrase to Keys: BIP-39, BIP-32, BIP-44
Before any EIP matters, a wallet has to make keys. Every "12 words on paper" wallet you've ever used is three Bitcoin standards stacked together — Ethereum adopted them wholesale.
2.1 BIP-39: entropy → mnemonic → seed
A mnemonic is not the key — it's a human-readable encoding of randomness:
1. entropy = 128 or 256 random bits (from a CSPRNG)
2. checksum = first (entropy_bits / 32) bits of SHA-256(entropy) → 4 or 8 bits
3. concat = entropy ‖ checksum → 132 or 264 bits
4. split into 11-bit chunks → 12 or 24 indexes into a fixed 2048-word list
(2048 = 2¹¹ — each word carries exactly 11 bits)
5. seed = PBKDF2-HMAC-SHA512(
password = mnemonic,
salt = "mnemonic" + optional_passphrase,
iterations = 2048) → 512-bit seed
- The checksum is why a mistyped word is usually caught: only 1 in 16 random 12-word strings is valid.
- The optional passphrase (the "25th word") creates a completely different seed — plausible-deniability hidden wallets, but also a classic way to lose funds (no checksum protects it).
- The 2048-word list is fixed and language-specific; the first four letters of each word are unique — hardware wallets exploit this for entry.
The mnemonic is the DNA sample; PBKDF2 is the lab process that grows it into a master cell (the 512-bit seed). Same DNA + same process = same cell, every time, on any device — which is why 12 words restore an entire wallet anywhere.
2.2 Worked example: 16 bytes → 12 words → an address every value below is real
The algorithm above is easier to trust once you've watched every byte move. Here is one complete run, start to finish, with nothing elided.
This is BIP-39 official test vector #2 — its entropy is the byte
0x7f repeated 16 times, and it is published in the spec, in every BIP-39 test suite, and now here. Thousands of machines know these keys. It is a teaching fixture, not a wallet. Real entropy comes from a CSPRNG you never see, and a real mnemonic is one no one has ever printed.
Step 1 — entropy. 128 bits from a CSPRNG. Ours is deliberately the least random value imaginable, so you can follow the bits by eye:
entropy (hex) = 7f7f7f7f 7f7f7f7f 7f7f7f7f 7f7f7f7f (16 bytes = 128 bits)
entropy (bits) = 01111111 01111111 01111111 01111111 ... (0x7f = 0111 1111, ×16)
Step 2 — checksum. Hash the entropy and keep the first 128/32 = 4 bits:
SHA-256(entropy) = 87dcde7fa6df23e15fa7ba9b2a1f31408eac832f4e615ea815ae92024e3d818b
^^
first byte 0x87 = 1000 0111
────
first 4 bits = 1000 ← the checksum
Step 3 — concatenate and slice into 11-bit words. 128 + 4 = 132 bits = exactly 12 × 11. Each chunk is an index into the 2048-word list:
128 entropy bits ‖ 1000 → 132 bits → twelve 11-bit indexes
# 11 bits index word
─────────────────────────────────────
1 01111111011 1019 legal
2 11111011111 2015 winner
3 11011111110 1790 thank
4 11111110111 2039 year
5 11110111111 1983 wave
6 10111111101 1533 sausage
7 11111101111 2031 worth
8 11101111111 1919 useful
9 01111111011 1019 legal ← repeats: the entropy repeats every 8 bytes
10 11111011111 2015 winner
11 11011111110 1790 thank
12 11111111000 2040 yellow ← last word carries the checksum
└── the 4 checksum bits live here, which is why word 12
differs from word 4 ("year") despite identical entropy bits
Word 12 is where the checksum earns its keep: change any earlier word and the final word almost certainly stops agreeing with the hash. That is the "1 in 16" check from §2.1 made concrete.
Step 4 — stretch the words into a seed. This is the step people misread. The words are not hashed into the seed directly; they are run through PBKDF2 2048 times, and the passphrase goes in the salt, not the password:
seed = PBKDF2-HMAC-SHA512(
password = "legal winner thank year wave sausage worth useful legal winner thank yellow",
salt = "mnemonic" + passphrase, ← passphrase lands HERE
iterations = 2048,
dkLen = 64 bytes)
The passphrase: same words, a different wallet
Run the identical 12 words twice, once bare and once with the passphrase trezor, and the 512-bit seeds share nothing:
salt = "mnemonic" (no passphrase)
seed = 878386efb78845b3355bd15ea4d39ef97d179cb712b77d5c12b6be415fffeffe
5f377ba02bf3f8544ab800b955e51fbff09828f682052a20faa6addbbddfb096
salt = "mnemonictrezor" (passphrase = "trezor")
seed = 9fad48776d59511b5d532911300fd684daf464c06865041dc045a0f3dfe9e284
1219290013bfc454f2bb9aade52751f5e9f0cd67c0dad602df3ca4b5001b5304
▲ not one byte in common — this is a whole separate wallet
↔ scroll the diagram sideways
Step 5 — the seed becomes the tree. From here §2.3 takes over: the 512-bit seed is HMAC'd into a master key + chain code, and the BIP-44 path walks down to an address:
no passphrase passphrase = "trezor"
──────────────────────────── ────────────────────────────
master key 7e56ecf5943d79e1… master key 578e0c6b44d56f83…
chain code 598b4595ea728027… chain code a8806347c85e7acb…
m/44'/60'/0'/0/0 m/44'/60'/0'/0/0
0x58A57ed9d8d624cBD12e2C467D… 0x34FF70540588fa9B3f7df225e…
m/44'/60'/0'/0/1 m/44'/60'/0'/0/1
0x0D3eB21b6b21833A4939Cfff48… 0xBDCEeacb069fD88B7f39F5Ae30…
A password guards one account and can be reset. This passphrase selects which of 2512 wallets your words unlock — every possible passphrase produces a valid, empty-looking wallet, and there is no server to tell you which one was yours. That is the plausible deniability: a thief with your 12 words gets a real wallet, just not your real wallet. It is also the footgun, and it is the same property viewed from either side.
Want to check the arithmetic yourself? Every number above is reproducible in a few lines:
Reproduce it
import { mnemonicToSeedSync } from '@scure/bip39'
import { HDKey } from '@scure/bip32'
const M = 'legal winner thank year wave sausage worth useful legal winner thank yellow'
for (const pass of ['', 'trezor']) {
const seed = mnemonicToSeedSync(M, pass) // passphrase → PBKDF2 salt
const addr = HDKey.fromMasterSeed(seed).derive("m/44'/60'/0'/0/0")
console.log(pass || '(none)', Buffer.from(seed).toString('hex').slice(0, 16))
// → '' 878386efb78845b3… → 0x58A57ed9…
// → 'trezor' 9fad48776d59511b… → 0x34FF7054…
}
2.3 BIP-32: the HD (Hierarchical Deterministic) tree
The 512-bit seed becomes the root of an infinite key tree:
I = HMAC-SHA512(key = "Bitcoin seed", data = seed) // yes, even on Ethereum
master_private_key = I[0..32] // left half
master_chain_code = I[32..64] // right half — extra entropy for derivation
child derivation (index i):
normal (i < 2³¹): child = f(parent_pubkey, chain_code, i) // xpub can derive
hardened (i ≥ 2³¹): child = f(parent_PRIVkey, chain_code, i) // notated i′ or i'
- An extended key (xprv/xpub) = key + chain code. An
xpubcan derive all normal child public keys without ever seeing a private key — how watch-only wallets and exchanges generate deposit addresses. - Hardened derivation breaks that property on purpose: leaking one child private key + the parent xpub would otherwise let an attacker walk back up the tree. That's why the account levels are hardened.
A family tree grown from one ancestor. Normal derivation is like a public genealogy registry — anyone with the registry (xpub) can list all descendants' faces (addresses) but not their house keys. Hardened derivation is a branch that opted out of the registry entirely.
2.4 BIP-44: the derivation path — one seed, every account
m / purpose' / coin_type' / account' / change / address_index
m / 44' / 60' / 0' / 0 / 0 ← your first ETH account
m / 44' / 60' / 0' / 0 / 1 ← MetaMask "Account 2"
m / 44' / 0' / 0' / 0 / 0 ← same seed, Bitcoin
└── 60 = Ethereum's registered coin type (SLIP-44)
One seed, many chains. The coin_type' level (registered in SLIP-44) is how a single mnemonic drives wallets on completely different networks:
| Chain | Typical path | Curve | Address derivation |
|---|---|---|---|
| Bitcoin | m/44'/0'/0'/0/0 (84' for segwit) | secp256k1 | hash160(pubkey) → base58/bech32 |
| Ethereum / all EVM | m/44'/60'/0'/0/i | secp256k1 | last20(keccak256(pubkey)) → 0x… |
| Tron | m/44'/195'/0'/0/i | secp256k1 | same keccak trick, then 0x41-prefix + base58check → T… |
| Cosmos ecosystem | m/44'/118'/0'/0/i (shared by many app-chains) | secp256k1 | bech32(ripemd160(sha256(pubkey))) → cosmos1…/osmo1… |
| Solana | m/44'/501'/0'/0' — all hardened | ed25519 | address = base58(pubkey) itself, no hashing |
Note Solana's oddity: ed25519 doesn't support BIP-32's normal (non-hardened) derivation math, so it uses SLIP-0010 with every level hardened — no xpub-style watch-only trees. Full non-EVM comparison in §19. BSC/Polygon/Arbitrum/etc. all reuse 60' — same key, same 0x address on every EVM chain (which is exactly why EIP-155 chainId matters).
- Ethereum ignores
change(a Bitcoin UTXO concept) — it's always0. - Ledger historically used
m/44'/60'/i'/0/0(bumping account) vs MetaMask'sm/44'/60'/0'/0/i(bumping index) — why "my Ledger shows different addresses" is a FAQ, and why wallets offer path pickers. - Each leaf is an independent secp256k1 private key → public key →
address = last20(keccak256(pubkey)). Compromising one leaf key does NOT compromise siblings or the master (you'd need the extended parent).
import { english, generateMnemonic, mnemonicToAccount } from 'viem/accounts'
const mnemonic = generateMnemonic(english) // BIP-39: entropy → 12 words
const account0 = mnemonicToAccount(mnemonic) // default m/44'/60'/0'/0/0
const account1 = mnemonicToAccount(mnemonic, { addressIndex: 1 }) // …/0/1
const ledger3 = mnemonicToAccount(mnemonic, { accountIndex: 3 }) // m/44'/60'/3'/0/0
console.log(account0.address) // 0x… — last20(keccak256(pubkey))
// account0.signTypedData(…), signMessage(…), signTransaction(…) all available
• The 12 words ARE every key, forever — including addresses you haven't generated yet. There is no "change just one password."
• A leaked
xpub leaks your entire address graph (privacy, not funds) — and xpub + any one non-hardened child private key = the parent xprv. This is why purpose/coin/account are hardened.• None of this hierarchy exists for smart wallets (§7–8): their "key" is whatever validation logic you install — which is exactly the upgrade this guide builds toward.
2.5 Two kinds of citizens
2.6 ECDSA in five lines
An EOA's address is derived from its secp256k1 public key:
privkey (32 bytes, random)
→ pubkey = privkey × G (elliptic curve point, 64 bytes)
→ address = last20( keccak256(pubkey) )
A signature is 65 bytes — r (32) ‖ s (32) ‖ v (1) — and Ethereum's superpower is public key recovery: given a hash and (v, r, s), the ecrecover precompile (address 0x01) returns the address that signed. You never store or transmit public keys.
ECDSA recovery is a wax seal. You don't need the king's ring to verify a letter — the imprint itself proves which ring pressed it.
ecrecover reads the imprint and tells you the ring.2.7 Why EOAs hurt — the problem statement for the rest of this guide
- One key = single point of failure. No rotation (the address is the key), no recovery, no 2FA.
- No batching.
approve+swap= two transactions, two wallet popups, two fees. - Gas must be ETH, paid by the sender. A new user with $50 of USDC and zero ETH is stuck — the classic onboarding wall.
- No custom rules. Spending limits, session keys, passkeys, multisig — all impossible.
Smart wallets fix all four, but pre-2023 they had a bootstrap problem: contracts can't start transactions, so a centralized "relayer" EOA had to poke them. ERC-4337 (§7) standardizes and decentralizes the relayer layer; EIP-7702 (§8) removes the migration cost.
3 · Transaction Anatomy & the Typed Envelope
3.1 Legacy transactions and EIP-155 replay protection · 2016
A legacy (type-0) transaction is the RLP encoding of nine fields:
rlp([nonce, gasPrice, gasLimit, to, value, data, v, r, s])
The bug: originally the chain wasn't part of what you signed. After the 2016 ETH/ETC split, any transaction broadcast on one chain could be replayed verbatim on the other — same key, same nonce, same validity. EIP-155 folds chainId into the signed preimage and encodes it into v:
signed: keccak256( rlp([nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0]) )
v = chainId × 2 + 35 (or 36) // recover chainId from v
Writing the country name on a bank cheque so it can't be cashed abroad. Same signature, but now the paper says where it's valid.
3.2 EIP-2718: the typed transaction envelope 2020
Rather than torturing the legacy format forever, EIP-2718 declares: a transaction is simply
TransactionType (1 byte) ‖ TransactionPayload (opaque, type-defined)
One leading byte selects the format, and each new type defines its own payload and its own signature scheme. This is the extension point that made everything after 2020 possible:
3.3 EIP-1559: the fee market London · 2021
Type-2 transactions replace one gasPrice with two caps, against a protocol-computed, burned baseFee that rises when blocks are full and falls when they're empty:
effectiveGasPrice = min(maxFeePerGas, baseFee + maxPriorityFeePerGas)
validator tip = effectiveGasPrice − baseFee // baseFee is burned 🔥
Surge pricing with a burn. The surge portion goes to no one (burned), you only tip the driver on top, and you pre-authorize a maximum fare (
maxFeePerGas) so you can never be overcharged while you sleep.ERC-4337 UserOperations (§7) copy this fee model field-for-field (
maxFeePerGas/maxPriorityFeePerGas inside the UserOp), and bundlers arbitrage the difference. Understanding 1559 = understanding UserOp gas math.3.4 Gas mechanics in practice — units, limits, and who gets what
Two numbers that beginners conflate, kept strictly apart:
gas = WORK — how many units of computation/storage the tx consumes
gasPrice = MONEY PER UNIT — what you pay for each unit, in wei
fee paid = gasUsed × effectiveGasPrice
units: 1 ether = 10⁹ gwei = 10¹⁸ wei (gas prices are quoted in gwei)
| Field | Set by | Meaning | Gotchas |
|---|---|---|---|
gasLimit (per tx) | you / your wallet | max work you authorize. Unused gas is refunded; if execution needs more, the tx reverts out-of-gas but you still pay for gas consumed up to the failure | a plain ETH transfer costs exactly 21,000 (the "intrinsic" cost); calldata bytes, storage writes (SSTORE ≈ 20k for a new slot) dominate real costs |
| block gas limit | protocol/validators | total work per block (~36M gas, drifting upward via validator votes) | this is the ceiling that makes blockspace scarce — the whole reason fees exist and L2s exist (§16) |
gasPrice (legacy, type-0) | you | one flat number: bid per unit | overpays whenever the network calms down mid-flight — the problem 1559 fixed |
baseFee | protocol, per block | algorithmic floor: ±12.5% max per block based on whether the previous block was over/under 50% full | burned 🔥 — the validator never sees it, so there's no incentive to stuff blocks and inflate it |
maxPriorityFeePerGas ("tip") | you | the only part the validator keeps — your bid for inclusion priority | 1–2 gwei is usually plenty; spikes during NFT mints/liquidation storms |
maxFeePerGas | you | hard cap: baseFee + tip will never exceed this | wallets typically set ≈ 2× current baseFee + tip so the tx survives base-fee climbs while pending |
gasLimit is the fuel tank size you authorize for a journey; gasUsed is fuel actually burned (the rest returns to your wallet); baseFee is the government-set toll that gets shredded at the booth; the tip is the only cash the toll operator pockets; maxFeePerGas is the note to your driver: "if tolls rise above this, park and wait."ETH is not an ERC-20 — it has no contract, no
transfer(), no allowance; it moves via the protocol's value field and is readable as address.balance. That asymmetry is why WETH (wrapped ETH) exists, why every DEX pairs against WETH internally, and why paymasters that accept "any token" still need a swap route back to real ETH to pay the validator. On other EVM chains the native asset differs (BNB, MATIC/POL, TRX-adjacent models) — same mechanics, different burn/tip policies.4 · Signing Messages: EIP-191 & EIP-712
Wallets sign two different things: transactions (state changes, §3) and messages (proofs and intents — logins, orders, permits). If the two byte-formats could ever collide, a "harmless login" could secretly be a valid transaction. Both standards exist to make that collision impossible.
4.1 EIP-191: personal_sign 2016
digest = keccak256("\x19Ethereum Signed Message:\n" + len(message) + message)
- The leading byte
0x19can never begin a valid RLP transaction — so a signed message can never replay as a transaction. That single byte is the whole security argument. - Generalized format:
0x19 ‖ version ‖ version-data ‖ payload. Version0x45('E') ispersonal_sign; version0x01is reserved for EIP-712; version0x00binds data to one intended validator contract.
Weakness: the user sees a text blob or hex. Fine for "Sign in to OpenSea", terrible for "sell 3 NFTs for X" — no structure, nothing a wallet can render or a contract can decompose. Hence 712.
4.2 EIP-712: typed structured data the important one · 2017
EIP-712 lets the wallet show the user exactly what they're signing, field by field — and lets a contract rebuild the identical digest on-chain to verify it.
EIP-191 is signing a napkin; EIP-712 is signing a standardized printed form with labeled boxes. The notary (wallet) reads every box aloud to you before you sign; the court (contract) can reprint the identical form later and check the seal matches.
import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
contract Orders is EIP712 {
bytes32 private constant ORDER_TYPEHASH =
keccak256("Order(address maker,uint256 amount,uint256 nonce,uint256 deadline)");
mapping(address => mapping(uint256 => bool)) public usedNonces;
constructor() EIP712("OrbitXPay", "1") {} // builds the domain separator
function fill(Order calldata o, bytes calldata sig) external {
require(block.timestamp <= o.deadline, "expired");
require(!usedNonces[o.maker][o.nonce], "replayed");
bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(
ORDER_TYPEHASH, o.maker, o.amount, o.nonce, o.deadline)));
require(ECDSA.recover(digest, sig) == o.maker, "bad sig");
usedNonces[o.maker][o.nonce] = true;
// …execute the order
}
}
const signature = await walletClient.signTypedData({
domain: { name: 'OrbitXPay', version: '1', chainId: 1, verifyingContract: ordersAddr },
types: {
Order: [
{ name: 'maker', type: 'address' },
{ name: 'amount', type: 'uint256' },
{ name: 'nonce', type: 'uint256' },
{ name: 'deadline', type: 'uint256' },
],
},
primaryType: 'Order',
message: { maker, amount: 500_000_000n, nonce: 7n, deadline },
})
Where you've already met 712: EIP-2612 permit, Permit2, Safe's SafeTx, Uniswap orders, OpenSea Seaport, CoW Swap, Sign-In-With-Ethereum variants, and — since EntryPoint v0.8 — the ERC-4337 userOpHash itself.
4.3 Signature pitfalls checklist interview-grade
| Attack | Defense |
|---|---|
| Replay the same signature twice | Include a nonce; mark it consumed on-chain |
| Replay years later | Include a deadline; check block.timestamp <= deadline |
| Replay on another chain / another contract / another dApp | EIP-712 domain separator (chainId + verifyingContract). Never verify raw unhashed data |
Malleability: for any sig (r,s,v), (r, n−s, v′) is also valid ECDSA | Reject high-s values. OZ ECDSA.recover does this; raw ecrecover does NOT. Never use a signature as a unique ID or nonce |
ecrecover returns address(0) on garbage | Check recovered ≠ 0 AND == expected signer — or just use OZ, which reverts |
| Signing a hash the wallet can't render ("blind signing") | Prefer 712 over 191 for anything with value; wallets increasingly warn on raw hash signing |
5 · Proving Wallet Ownership: ERC-1271, SignatureChecker, ERC-6492
This is the standard you remembered as "EIP-1270" — it's ERC-1271, and the OpenZeppelin helper is SignatureChecker. It answers: how does a wallet with no private key "sign" anything?
5.1 The problem
ecrecover answers "which private key made this signature?" — but a smart contract wallet has no private key. A Safe with 3 owners: whose handwriting is "the Safe's"? If your dApp verifies logins or orders with ecrecover alone, every smart-wallet user is locked out.
5.2 ERC-1271: flip the question isValidSignature · 2018
Instead of recovering a signer, ask the account itself: "is this signature valid, by your own rules?"
interface IERC1271 {
/// @return magicValue 0x1626ba7e when valid
function isValidSignature(bytes32 hash, bytes calldata signature)
external view returns (bytes4 magicValue);
}
- Magic value
0x1626ba7e=bytes4(keccak256("isValidSignature(bytes32,bytes)")). Returning the selector instead oftruemeans a contract that accidentally returns non-zero garbage still fails the check. - The contract defines validity: a Safe checks threshold-of-owners signatures; a passkey wallet verifies a P-256 WebAuthn assertion; a session-key module checks the key and its permissions haven't expired.
Verifying a person's signature = comparing handwriting (
ecrecover). Verifying a company's approval: you can't compare handwriting — you call the company's front desk and ask "do you honor this document?", and their internal bylaws (two directors? CFO above $10k?) produce the yes/no. ERC-1271 is the front desk's phone number, standardized.contract MiniWallet is IERC1271 {
using ECDSA for bytes32;
address public owner;
bytes4 constant MAGIC = 0x1626ba7e;
function isValidSignature(bytes32 hash, bytes calldata sig)
external view returns (bytes4)
{
return hash.recover(sig) == owner ? MAGIC : bytes4(0xffffffff);
}
}
5.3 OpenZeppelin SignatureChecker — the one-liner your dApp should use
import {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol";
bool ok = SignatureChecker.isValidSignatureNow(signer, digest, signature);
import { createPublicClient, http } from 'viem'
import { mainnet } from 'viem/chains'
const client = createPublicClient({ chain: mainnet, transport: http() })
const valid = await client.verifyTypedData({
address: signer, // EOA, deployed smart wallet, or undeployed smart wallet
domain, types, primaryType, message,
signature,
}) // ✅ ecrecover → ERC-1271 → ERC-6492, all attempted for you
5.4 ERC-6492: verifying wallets that don't exist yet counterfactual
Modern smart wallets are counterfactual: CREATE2 fixes the address before any code is deployed; deployment happens lazily with the first transaction. So a brand-new user signs "Sign in" → your backend calls isValidSignature → there's no code there yet → verification fails, even though the wallet is real.
ERC-6492 wraps the signature with its own deployment recipe:
sig' = abi.encode(factory, factoryCalldata, originalSig) ‖ magicSuffix
magicSuffix = 0x6492649264926492649264926492649264926492649264926492649264926492
A 6492-aware verifier that spots the suffix: simulate the factory deployment inside an eth_call, then run the 1271 check against the now-materialized code — nothing is actually deployed. viem/ethers and the universal validator contract handle this transparently.
A cheque from a company that's incorporated but hasn't opened its office yet. 6492 staples the incorporation papers to the cheque so the bank can verify: "the moment this office exists, this cheque is good."
5.5 ERC-7739: defensive rehashing current best practice
A naive 1271 wallet that just runs ECDSA.recover(hash, sig) == owner creates a replay hole: the owner's one signature validates against every account that trusts that key (same EOA owning several smart accounts), and an identical 712 digest can mean different things to different apps. ERC-7739 nests the app's typed data inside a TypedDataSign envelope bound to the account's own domain, so each signature is valid for exactly one account. Solady and OpenZeppelin 5.x ship implementations; Kernel, Coinbase Smart Wallet, and Alchemy accounts adopted it. Know it exists — and use library code, not hand-rolled 1271.
6 · Gasless UX: permit, Gas Relayers & Gas Sponsorship
The classic UX wart: to let a contract spend your USDC you must first send approve(spender, amount) — a full gas-costing transaction — before the action you actually wanted.
6.1 EIP-2612 — approval by signature
function permit(address owner, address spender, uint256 value,
uint256 deadline, uint8 v, bytes32 r, bytes32 s) external;
function nonces(address owner) external view returns (uint256);
function DOMAIN_SEPARATOR() external view returns (bytes32);
The owner signs an EIP-712 Permit struct off-chain (free); anyone — typically the dApp contract, inside the same transaction as the swap/deposit — submits it. The token verifies the 712 digest via ecrecover and sets the allowance. Approve + action collapse into one transaction, signed once, gas payable by someone else.
Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)
Instead of driving to the bank to register a standing instruction (approve), you sign a letter of authorization at home and hand it to the merchant, who presents it to the bank together with the payment. One trip — and it's their trip.
• Only tokens that implement it (USDC ✓, mainnet USDT ✗) — hence Permit2 (Uniswap): approve one singleton once, and it manages signature-based sub-approvals for any ERC-20, with expirations.
• DAI's permit is nonstandard (
allowed/expiry instead of value/deadline).• EIP-2612 verifies with
ecrecover only → smart accounts can't use it (their "signature" is 1271). Permit2 supports 1271 — one more reason it won.6.2 Gas relayers: meta-transactions & ERC-2771 the pre-4337 era
permit only moves the gas cost of one function on one token. The general problem — "user signs, someone else pays, for any call" — was solved before 4337 with meta-transactions: the user signs an EIP-712 request off-chain, a relayer EOA wraps it in a real transaction and pays the gas.
The catch: inside the target contract, msg.sender is now the relayer, not the user. ERC-2771 standardizes the fix — a trusted forwarder verifies the user's signature + nonce, then calls the target with the real sender's address appended to the last 20 bytes of calldata:
// Forwarder (OpenZeppelin ERC2771Forwarder): verifies EIP-712 ForwardRequest
// (from, to, value, gas, nonce, deadline, data), then:
// target.call{...}(abi.encodePacked(req.data, req.from)) // ← sender appended
// Recipient inherits ERC2771Context instead of using msg.sender directly:
abstract contract ERC2771Context {
function _msgSender() internal view returns (address sender) {
if (msg.sender == TRUSTED_FORWARDER && msg.data.length >= 20) {
assembly { sender := shr(96, calldataload(sub(calldatasize(), 20))) }
} else {
sender = msg.sender;
}
}
}
// Rule: a 2771-aware contract must use _msgSender() EVERYWHERE, never msg.sender
- GSN (Gas Station Network) — EIP-1613/OpenGSN — decentralized this: a network of relayers, a RelayHub escrow, and a contract that agrees to repay relayers. GSN is where the word "paymaster" was born; ERC-4337 later absorbed the whole concept into the EntryPoint design.
- Biconomy's first act was exactly this: relayer-infrastructure-as-a-service with hosted forwarders (their old "dashboard + dApp gas tank" product) — before pivoting the same idea into 4337 paymasters. The lineage is direct.
- Still used today for plain old contracts that want one gasless function without the full 4337 stack (and by OpenSea/Polygon-era dApps everywhere).
A meta-transaction is a reply-paid envelope: you write and sign the letter (EIP-712 request), the company's mailroom (relayer) pays the postage, and the forwarder stamps "actually from: you" on the inside flap so the recipient files it under your name, not the mailroom's.
If a 2771-aware contract also exposes
multicall(bytes[] calls), an attacker can go through the trusted forwarder with a crafted inner call whose last 20 bytes are a forged address — the inner delegatecall slices calldata and trusts the appended bytes it shouldn't. This hit dozens of protocols built on the affected library (the Thirdweb disclosure) and forced mass migrations/pauses. Lesson: address-in-calldata is a fragile trust channel — audit every calldata-slicing path (multicall, batch, fallback) in any 2771 contract.6.3 Gas sponsorship — every way to pay someone else's gas, compared
| Mechanism | Who pays | Works for | Trust model | Status 2026 |
|---|---|---|---|---|
| EIP-2612 permit / Permit2 (§6.1) | whoever submits the tx | token approvals only | token contract verifies sig | standard for approvals |
| ERC-2771 meta-tx + relayer (§6.2) | relayer (reimbursed off-chain / not at all) | contracts that opted in (_msgSender()) | ⚠ trusted forwarder can impersonate anyone for that contract | legacy but alive; niche gasless functions |
| GSN | GSN paymaster contract | 2771-style recipients | RelayHub escrow + relayer network | largely superseded by 4337 |
| ERC-4337 paymaster (§7.4) | paymaster (verifying / ERC-20 mode) | any action of a smart account | staked contract; validation-rule sandbox; ERC-7677 service API | the mainstream answer |
| EIP-7702 sponsored upgrade (§8) | whoever submits the type-0x04 tx | the delegation tx itself — the authorization tuple is signed by the EOA, but anyone can broadcast it | authorization signature binds chain+nonce | how wallets upgrade users who own zero ETH |
| Protocol-native (§19) | any co-signer (Solana fee payer) / granter (Cosmos feegrant) | everything on those chains | built into the tx format | the "EVM needed 4337 for this" comparison point |
Every row is the same idea maturing: separate the intent-signer from the fee-payer. permit does it for one function, 2771 for one contract, GSN for a network, 4337 for the whole account model — and Solana/Cosmos just shipped it in the protocol. When someone says "gasless", your first question should be: which row?
7 · ERC-4337: Account Abstraction Without a Fork 2021 → live Mar 2023
"Account abstraction" = decouple who pays, who validates, and what logic approves from the protocol's hardcoded ECDSA+nonce rules. After native attempts (EIP-86, EIP-2938) died, ERC-4337 rebuilt the entire transaction pipeline in contract-land + off-chain infrastructure — zero consensus changes.
7.1 The cast of characters
A UserOperation is an order slip. The alt-mempool is the order rail in the kitchen. A bundler is a waiter collecting many tables' slips and firing them to the kitchen at once. The EntryPoint is the kitchen pass — every single plate crosses it and gets quality-checked. Your smart account is a membership card with its own rules. A paymaster is the company card behind the bar tab. The factory pre-prints your membership card number before you've ever visited.
7.2 The UserOperation struct EntryPoint v0.7/v0.8 packed form
struct PackedUserOperation {
address sender; // the smart account
uint256 nonce; // 2D: 192-bit key ‖ 64-bit sequence (parallel nonces!)
bytes initCode; // factory address + calldata — only on first-ever op
bytes callData; // what the account should execute
bytes32 accountGasLimits; // packed: verificationGasLimit ‖ callGasLimit
uint256 preVerificationGas; // bundler overhead compensation
bytes32 gasFees; // packed: maxPriorityFeePerGas ‖ maxFeePerGas (1559-style)
bytes paymasterAndData; // paymaster + its gas limits + its data
bytes signature; // ⭐ OPAQUE — whatever your validation logic understands
}
signature is opaque bytes. The protocol doesn't care if it's one ECDSA sig, three concatenated for a multisig, a P-256 passkey/WebAuthn assertion, or a BLS aggregate. Only your account contract's validateUserOp interprets it. That is the abstraction in "account abstraction".interface IAccount {
function validateUserOp(
PackedUserOperation calldata userOp,
bytes32 userOpHash,
uint256 missingAccountFunds // pay EntryPoint what you owe
) external returns (uint256 validationData);
// validationData packs: authorizer(0=ok,1=fail) ‖ validUntil ‖ validAfter
}
Why two loops? The bundler must be guaranteed payment even if your execution reverts, and must be able to simulate ops cheaply. So validation runs first for the whole bundle (restricted by ERC-7562 rules: limited opcodes/storage so one op can't invalidate another — DoS protection), execution second. Your op failing mid-swap still pays the bundler.
7.3 Paymasters — the onboarding superpower
interface IPaymaster {
function validatePaymasterUserOp(PackedUserOperation calldata, bytes32, uint256 maxCost)
external returns (bytes memory context, uint256 validationData);
function postOp(PostOpMode mode, bytes calldata context,
uint256 actualGasCost, uint256 actualUserOpFeePerGas) external;
}
- Verifying paymaster — your backend signs "we sponsor this op"; the paymaster contract checks that signature. The dominant sponsored-gas model (Pimlico, Alchemy Gas Manager, Biconomy).
- ERC-20 paymaster — user pays gas in USDC; the paymaster fronts ETH and pulls tokens in
postOp. - Paymasters stake ETH in the EntryPoint — skin in the game against griefing the mempool.
7.4 EntryPoint versions — know the addresses
| Version | Address | Highlights |
|---|---|---|
| v0.6 (2023) | 0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789 | The original mainnet launch; unpacked struct; legacy |
| v0.7 (2024) | 0x0000000071727De22E5E9d8BAf0edAc6f37da032 | Packed struct, paymaster gas limits, unused-gas penalty |
| v0.8 (2025) | 0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108 | EIP-712 userOpHash (readable signing!), native EIP-7702 support |
(A v0.9 with further paymaster parallelization exists; v0.7/v0.8 dominate production. Check which your bundler supports.)
7.5 Honest tradeoffs
- Gas overhead (~42k+ baseline vs 21k plain transfer) — trivial on L2s, noticeable on mainnet L1.
- New address ≠ your old EOA: migration friction for existing users — exactly the pain EIP-7702 removes.
- Infra dependency: bundler/paymaster uptime (mitigated by many providers + a shared P2P mempool).
8 · EIP-7702: Your EOA Becomes a Smart Wallet Pectra · May 7, 2025
8.1 The mechanism: type-0x04 + authorization tuples
A new transaction type 0x04 (SetCode) carries an authorization_list. Each entry is a tuple the EOA's key signed:
authorization = [chain_id, address, nonce, y_parity, r, s]
signed over: keccak256( 0x05 ‖ rlp([chain_id, address, nonce]) )
When processed, the EOA's account code becomes a delegation designator:
code = 0xef0100 ‖ <20-byte implementation address> (23 bytes total)
0xefis an unreachable opcode prefix for normal deployment (EIP-3541 reserved it) — the designator can only come from a 7702 transaction.- Delegation is persistent until replaced or cleared. It survives across transactions — unlike the withdrawn EIP-3074's per-call model.
chain_id = 0→ the authorization is valid on every EVM chain. Powerful for multichain UX; dangerous if the implementation isn't identically deployed everywhere.- Tuple
nonce= the EOA's account nonce — so an authorization can't be replayed after the account moves on.
Your old debit card gets a programmable chip installed. Same card number, same account, same history — but now it can enforce limits, batch payments, and let merchants cover the fees. And the bank keeps a master override (your private key) that no chip setting can ever disable — for better and for worse.
8.2 Why 7702 + 4337 is the 2026 architecture, not 7702 vs 4337
Point your delegation at an ERC-4337-compatible implementation and your EOA gains the entire 4337 stack — bundlers, paymasters, session keys, passkeys, batching — without changing address. EntryPoint v0.8 supports this natively. Wallets (MetaMask's delegator framework, Coinbase, OKX, Trust, Ambire) ship vetted implementation lists and surface capabilities via wallet_getCapabilities (EIP-5792, §10).
import { walletClient } from './client'
// 1. sign the authorization tuple with the EOA's key
const authorization = await walletClient.signAuthorization({
contractAddress: KERNEL_V3_IMPLEMENTATION, // vetted implementation
})
// 2. include it in a type-0x04 transaction (can be sent by a relayer!)
const hash = await walletClient.sendTransaction({
type: 'eip7702',
authorizationList: [authorization],
to: walletClient.account.address, // call yourself…
data: encodeFunctionData({ // …now running smart-account code
abi: kernelAbi,
functionName: 'executeBatch',
args: [[approveCall, swapCall]], // ⚡ two actions, one signature
}),
})
8.3 Security model — what YOU must re-check
•
require(msg.sender == tx.origin) no longer proves "plain EOA" — an EOA with code can originate a transaction and run logic. Every contract using this as a flash-loan/reentrancy/bot guard needs review.• Storage collisions on redelegation: switch implementation A → B, and B reads A's leftover slots. Mitigation: ERC-7201 namespaced storage.
• A malicious delegation drains everything in one signature. Signing an authorization tuple is now the most dangerous signature in Ethereum. Wallets whitelist implementations; never sign raw 7702 auths from a dApp.
• Initialization front-running: delegated code usually needs
initialize() — good implementations bind init to a signature from the EOA key itself.8.4 The road not taken (context)
EIP-3074 (AUTH/AUTHCALL — per-call invoker model) was approved, then withdrawn in 2024 after security controversy; 7702 replaced it with a persistent, proxy-like, 4337-compatible design. EIP-2938 (native AA) was abandoned in 2020. EIP-7701 (native account abstraction — every account a contract, no more special-cased ECDSA) remains the roadmap "endgame": 4337 and 7702 are the migration path toward it.
9 · Modular Smart Accounts: ERC-7579 & ERC-6900
Once everyone has a smart account, you want an app-store model: install a spending-limit module, a 2FA module, a recovery module — without redeploying the wallet.
The smart account is a smartphone; modules are apps; the standard is the app-store API that lets a module written once run on any compliant account (Kernel, Nexus, Safe…).
9.1 ERC-7579 — minimal, won the market
// Module types: 1 = validator · 2 = executor · 3 = fallback · 4 = hook
interface IERC7579Account {
function execute(bytes32 mode, bytes calldata executionCalldata) external;
function installModule(uint256 typeId, address module, bytes calldata init) external;
function uninstallModule(uint256 typeId, address module, bytes calldata deinit) external;
function isModuleInstalled(uint256 typeId, address module, bytes calldata ctx)
external view returns (bool);
}
| Module type | Power | Examples |
|---|---|---|
| Validator (1) | Decides if a UserOp / 1271 signature is authorized | ECDSA owner, passkey/WebAuthn, multisig, session keys |
| Executor (2) | Can initiate actions on the account | DCA bot, auto-repay loans, subscriptions |
| Fallback (3) | Handles unknown selectors | ERC-721/1155 receivers, extensions |
| Hook (4) | Pre/post execution checks | Spending limits, compliance, allowlists |
Backers: Rhinestone, ZeroDev (Kernel v3+), Biconomy (Nexus), Safe (via the Safe7579 adapter), OKX, Etherspot. ERC-7484 adds an on-chain module registry — accounts check audited-module attestations before installing.
9.2 ERC-6900 — Alchemy's richer, stricter spec
More prescriptive: plugins carry manifests; validation/execution/hooks are wired per function selector. Powers Alchemy's Modular Account. Heavier to implement and less ecosystem traction than 7579 — but influential (its lineage feeds ERC-7715 permission requests). Rule of thumb 2026: build modules against 7579 unless you live in Alchemy's stack.
10 · Wallet↔dApp Plumbing You'll Touch
| Standard | What it does |
|---|---|
EIP-5792 wallet_sendCalls | The JSON-RPC layer where a dApp says "batch these 3 calls, maybe with sponsored gas" and wallets advertise capabilities (atomic, paymasterService, auxiliaryFunds). Works identically over 4337 accounts, 7702 EOAs, and Safe — the dApp doesn't care which. Also wallet_getCapabilities, wallet_getCallsStatus. |
| ERC-7677 | Standard paymaster web service API (pm_getPaymasterStubData / pm_getPaymasterData) — how a dApp points any wallet at its gas sponsorship. |
| ERC-7715 (draft) | dApp requests granular permissions / session keys: "let this game move up to 10 USDC per day for a week." |
| EIP-6963 | Multi-wallet discovery in the browser — ends the window.ethereum fistfight between extensions. |
| CAIP-25 / WalletConnect | Session protocol between dApps and remote wallets (mobile, hardware). |
const caps = await provider.request({ method: 'wallet_getCapabilities', params: [account] })
// { "0x1": { atomic: { status: "supported" }, paymasterService: { supported: true } } }
const { id } = await provider.request({
method: 'wallet_sendCalls',
params: [{
version: '2.0.0', chainId: '0x1', from: account, atomicRequired: true,
calls: [
{ to: USDC, data: approveCalldata },
{ to: ROUTER, data: swapCalldata },
],
capabilities: { paymasterService: { url: 'https://paymaster.orbitxpay.com' } },
}],
}) // one popup, one atomic batch, gas sponsored — regardless of wallet type
11 · The Ecosystem: Who Builds What (2026)
11.1 Safe (formerly Gnosis Safe) — the elder statesman
Launched 2017–18, Safe predates every AA standard and still secures the most institutional value on EVM chains (DAO treasuries, protocol multisigs, exchanges). Architecture: a minimal proxy per user pointing at a shared singleton, with n-of-m ECDSA owner signatures checked over an EIP-712 SafeTx digest, plus its own pre-7579 extension points (Modules = executors, Guards = hooks, fallback handlers). It implements ERC-1271 (a Safe can "sign" for governance votes, orders, logins), gained ERC-4337 support via the Safe4337Module, and joins the modular world through the Safe7579 adapter (with Rhinestone). If you hear "smart wallet" in an institutional context, it's probably a Safe.
// SafeTx is an EIP-712 struct: to, value, data, operation, nonce, gas params…
bytes32 txHash = keccak256(abi.encodePacked(
hex"1901", domainSeparator(), safeTxStructHash));
checkNSignatures(txHash, signatures, threshold);
// each 65-byte slice: ECDSA owner sig, OR approved-hash flag, OR
// v=0 → nested ERC-1271: the "owner" is itself a contract wallet 🤯
11.2 Smart account implementations compared
| Account | Team | Standards | Signers | Notes |
|---|---|---|---|---|
| Safe | Safe | own + Safe7579 adapter, 4337 module | n-of-m ECDSA, nested 1271 | Battle-tested since 2018; institutional default; Modules/Guards predate 7579 |
| Kernel | ZeroDev | ERC-7579 native (v3+), 7739 | pluggable: ECDSA, passkeys, multisig, session keys | Most-deployed 4337 account family; 7702-ready |
| Nexus | Biconomy | ERC-7579, 7739 | pluggable validators | Successor to Smart Account v2; strong gas benchmarks; Rhinestone module ecosystem |
| Modular Account / Light Account | Alchemy | ERC-6900 / minimal | ECDSA + plugins, passkeys | Light = cheap minimal; Modular = full 6900 |
| Coinbase Smart Wallet | Coinbase | own + 7739 | passkeys (P-256/WebAuthn) first-class | Consumer flagship for passkey UX; heavy 1271+6492 user |
| MetaMask smart account | Consensys | 7702 + delegation framework | ECDSA root + granted delegations | Brings the upgrade to the largest EOA user base |
| Simple7702Account / OZ accounts | eth-infinitism / OpenZeppelin | 7702 + 4337 reference | ECDSA, P-256 variants | Audited starting points if you build your own |
11.3 Infra providers — Biconomy · ZeroDev · Alchemy · Pimlico
| Provider | Bundler | Paymaster | Account / SDK | How they differentiate |
|---|---|---|---|---|
| Pimlico | Alto (open-source, TS) | verifying + ERC-20 | permissionless.js — account-agnostic, viem-style | Neutral rails. Works with any account (Safe, Kernel, Nexus…); the popular self-host path |
| ZeroDev | own + provider aggregation | ✓ | Kernel + @zerodev/sdk | Best developer abstraction on its own account. Session keys, recovery, multi-chain ops, chain abstraction, 7702 |
| Biconomy | own | ✓ (pioneered pay-gas-in-ERC-20) | Nexus + AbstractJS / MEE | Cross-chain orchestration ("supertransactions"); strong sponsored-gas products |
| Alchemy | Rundler (open-source, Rust) | Gas Manager | Account Kit (aa-sdk) + Modular Account | Vertically integrated platform: node infra + bundler + paymaster + embedded wallets; ERC-6900's home |
| Others | Etherspot (Skandha), Candide (Voltaire), Particle, thirdweb, Openfort; Stackup was the early pioneer | Shared-mempool work; embedded-wallet niches | ||
One sentence each: Pimlico sells neutral rails, ZeroDev sells the nicest developer experience on its own account, Biconomy sells cross-chain execution products, Alchemy sells the integrated platform. Because all of them speak ERC-4337 + EIP-5792 + ERC-7677, accounts and infra are mix-and-match — that is the entire point of the standards.
// — Pimlico (permissionless.js): bring-your-own account
const account = await toSafeSmartAccount({ client, owners: [owner], version: '1.4.1' })
const smartClient = createSmartAccountClient({
account, bundlerTransport: http(PIMLICO_URL),
paymaster: pimlicoClient, // sponsorship policy checked server-side
})
await smartClient.sendUserOperation({ calls: [approveCall, swapCall] })
// — ZeroDev: Kernel + session keys in a few lines
const kernel = await createKernelAccount(client, { plugins: { sudo: ecdsaValidator } })
const kernelClient = createKernelAccountClient({ account: kernel, paymaster: zerodevPaymaster })
await kernelClient.sendUserOperation({ calls: [approveCall, swapCall] })
// — Alchemy Account Kit
const alchemyClient = await createModularAccountV2Client({
chain, transport: alchemy({ apiKey }), signer,
policyId: GAS_POLICY_ID, // Gas Manager sponsorship
})
await alchemyClient.sendUserOperation({ uo: [approveCall, swapCall] })
11.4 Choosing, quickly
- Institutional treasury / multisig governance → Safe (add the 4337 module if you need gasless).
- Consumer app, passkey onboarding, embedded wallets → Kernel (ZeroDev), Coinbase Smart Wallet, or Alchemy Account Kit.
- Existing EOA users you can't migrate → 7702 upgrade to a 7579 implementation (ZeroDev, Biconomy, MetaMask delegator, OZ).
- Zero vendor lock-in → account-agnostic permissionless.js + Alto, any 7579 account.
12 · End-to-End Flows: Putting It All Together
Flow A — "Sign in with your wallet" (any wallet type)
ecrecover path (viem verifyMessage).isValidSignature via eth_call / SignatureChecker.publicClient.verifyMessage() does all three automatically. Never hand-roll this.Flow B — Gasless USDC payment from a brand-new smart account
userOpHash — with EntryPoint v0.8 it's an EIP-712 digest, so the wallet can render it. Signer can be a passkey.initCode = factory call, callData = execute(usdc.transfer(merchant, 50e6)), paymasterAndData = verifying paymaster (the dApp sponsors gas).handleOps. EntryPoint deploys the account, the validator module checks the signature, the paymaster pays, the transfer executes.Flow C — Existing MetaMask user gets batching + sponsorship (7702)
[chain_id, implementation, nonce].0x04 transaction sets the designator 0xef0100‖impl on the EOA. Same address as 2019.wallet_getCapabilities → sees atomic + paymasterService.wallet_sendCalls([approve, swap]) → wallet builds one UserOp from the EOA's own address → bundler → EntryPoint v0.8 → done.13 · The EVM & Smart Contract Fundamentals
To understand proxies, 7702 delegation, and half the hacks in §15, you need three EVM primitives cold: storage slots, the four call types, and CREATE2.
13.1 The machine in one screen
Per-transaction (ephemeral) Per-account (persistent)
──────────────────────────── ────────────────────────
stack 1024 × 32-byte words storage 2²⁵⁶ slots × 32 bytes
memory byte array, grows, wiped code immutable after deploy*
calldata read-only input bytes balance / nonce
(* except selfdestruct†, 7702)
logs/events: write-only receipts — contracts can never read them back
- Function selectors: calldata starts with
bytes4(keccak256("transfer(address,uint256)"))— the ABI is a convention, not a protocol rule. This is where 1271's magic value and proxy selector-clash issues (§14) come from. - Storage layout (Solidity): state variables occupy slots 0,1,2… in declaration order (packed if they fit);
mappingvalues live atkeccak256(key ‖ slot); dynamic arrays atkeccak256(slot)+i. Slot assignment is per-source-code — two different contracts reading the same account's storage (delegatecall!) will happily interpret each other's slots. Remember this for §14.
13.2 The four call types — the heart of everything
The fourth type, CREATE/CREATE2: CREATE derives the new address from (deployer, nonce); CREATE2 from keccak256(0xff ‖ deployer ‖ salt ‖ keccak256(initcode)) — deterministic, nonce-independent, the enabler of counterfactual wallets (§5.4, §7) and of the "redeploy different code at the same address" trick used in the Metamorphic-contract attacks (§15).
CALL is hiring a caterer to cook in their kitchen and deliver the food. DELEGATECALL is inviting the caterer into your kitchen: your ingredients, your pantry gets rearranged, and anything they burn is your house. Proxies are a house that keeps no chef on staff — every meal is a delegatecall to a chef whose address is written on a note in the hallway (an EIP-1967 slot). Upgrading = replacing the note.14 · Upgradeability: Proxy Patterns in Solidity
Deployed bytecode is immutable, but storage is forever. Proxies split the two: a permanent address holding state, delegatecalling into replaceable logic. Here is the whole pattern family, how each works, and how each fails.
14.1 The core mechanism + EIP-1967
contract Proxy {
// EIP-1967: implementation pointer lives at a "random" slot so it can
// never collide with the implementation's own slot-0,1,2… variables:
// bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1)
bytes32 constant IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
fallback() external payable {
address impl;
assembly { impl := sload(IMPL_SLOT) }
assembly {
calldatacopy(0, 0, calldatasize())
let ok := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch ok case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
}
Constructors don't work through proxies — a constructor runs during the implementation's deployment, writing the implementation's storage, which the proxy never sees. Hence initializers:
contract VaultV1 is Initializable {
function initialize(address owner_) public initializer { // callable once
__Ownable_init(owner_);
}
constructor() { _disableInitializers(); } // ⚠️ brick the RAW implementation
} // so nobody can initialize it directly (§15)
14.2 The pattern family
| Pattern | Where upgrade logic lives | Mechanism | Use when | Sharp edges |
|---|---|---|---|---|
| EIP-1167 minimal proxy ("clone") | nowhere — NOT upgradeable | 55-byte bytecode that delegatecalls one fixed implementation | Mass-deploying identical cheap instances (one per user/pair) | Immutable forever; needs initializer discipline |
| Transparent proxy | in the proxy + external ProxyAdmin | if msg.sender == admin → route to admin functions, else always delegate | Classic choice; simple mental model | Solves selector clash (admin's upgradeTo() vs an implementation function with the same 4-byte selector) by never letting admin reach the implementation; costs extra gas per call + a ProxyAdmin contract |
| UUPS (EIP-1822) | in the implementation (upgradeToAndCall inherited from UUPSUpgradeable) | proxy is minimal; implementation checks _authorizeUpgrade | Today's default (OZ recommends it): cheaper calls, upgrade rules are custom code | Ship an implementation whose upgrade path is broken/absent → permanently bricked; the raw implementation must be initializer-locked (Wormhole, §15) |
| Beacon proxy | in a shared beacon contract | each proxy reads the implementation address from the beacon on every call; upgrade the beacon → every proxy upgrades at once | Fleets of identical contracts (all users' smart wallets, all vaults) | One beacon key controls everyone; an extra SLOAD+CALL of gas per call |
| Diamond (EIP-2535) | diamondCut facet | selector→facet routing table; one address, many implementation "facets" | Contracts too big for the 24KB limit (EIP-170); modular teams | Complexity tax: tooling, audits, storage discipline (AppStorage/namespaces) |
0xef0100‖impl) is literally a protocol-native proxy, and MetaMask/Kernel 7702 deployments often point it at… a beacon-style shared implementation.contract VaultV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable {
uint256 public totalDeposits; // slot 0 — FROZEN forever now
function initialize(address owner_) public initializer {
__Ownable_init(owner_); __UUPSUpgradeable_init();
}
function _authorizeUpgrade(address) internal override onlyOwner {}
constructor() { _disableInitializers(); }
}
contract VaultV2 is VaultV1 {
mapping(address => uint256) public rewards; // NEW slots APPENDED — never
function version() external pure returns (string memory) { return "2"; }
} // upgrade: proxy.upgradeToAndCall(address(new VaultV2()), "")
14.3 Storage-layout law (where upgrades die)
- Append-only: never reorder, remove, or change the type of existing state variables; never change inheritance order. New variables go at the end.
- Gaps: classic OZ contracts reserve
uint256[50] __gap;in bases so future base-class variables don't shift children. - ERC-7201 namespaced storage: the modern answer — each module keeps its state in a struct at
keccak256(namespace)-1-style slots, so unrelated components can't collide. This is also the mitigation for 7702 re-delegation collisions (§8.3) and what OZ v5 upgradeable contracts use internally. - Tooling:
openzeppelin-upgradesplugins diff layouts before letting you upgrade. Use them; humans are bad at this.
15 · Known Issues & Famous Hacks — the Scar-Tissue Catalog
Every rule in §14 and every "wallets must…" in this guide was paid for. Study the receipts.
15.1 The canon, chronologically
| Year | Incident | Loss | Root cause | The lesson it bought |
|---|---|---|---|---|
| 2016 | The DAO | 3.6M ETH (~$60M then) | Reentrancy: balance updated after the external call; attacker's fallback re-entered withdraw recursively | Checks-Effects-Interactions, ReentrancyGuard — and the ETH/ETC fork itself (which then necessitated EIP-155, §3.1) |
| 2017 | Parity multisig #1 | ~150k ETH ($30M) | Wallet delegatecalled a shared library; initWallet was reachable → attacker re-initialized and became owner | Access-control every init path; delegatecall exposes EVERYTHING public |
| 2017 | Parity freeze #2 | 513k ETH frozen forever | The shared library was itself an uninitialized contract; a user became its owner and selfdestructed it — every wallet delegatecalling it bricked | Initialize (or brick) implementations; don't put selfdestruct in library code. Direct ancestor of _disableInitializers() |
| 2020 | bZx, Harvest, & the flash-loan era | $10M+ each, repeatedly | Price oracles read from manipulable spot pools; flash loans made "whale for one tx" free | TWAP/Chainlink oracles; assume any caller can wield infinite capital for one transaction |
| 2022 | Wormhole uninitialized UUPS (whitehat, $10M bounty) | near-miss on the bridge | Raw implementation left uninitialized after an upgrade → anyone could initialize, take ownership, and selfdestruct it — freezing the proxy Parity-style | _disableInitializers() in every implementation constructor, forever |
| 2022 | Audius | $6M | Storage collision: proxy's own admin variables sat in slots the implementation also used → re-initialization became possible | EIP-1967 randomized slots exist for a reason; verify layouts (ERC-7201) |
| 2022 | Nomad bridge | $190M | An upgrade initialized the trusted-root to 0x00 → every unproven message verified; first crowd-sourced exploit | Review initializer values, not just code; upgrades are consensus-critical deploys |
| 2022 | Ronin bridge | $624M | 5-of-9 validator multisig; attacker spear-phished keys for 5 (4 run by one operator) | Multisig math is meaningless without key independence; §11's Safe threshold discipline |
| 2022 | Wintermute / Profanity | $160M | Vanity-address generator seeded with 32-bit entropy → private keys brute-forceable (§2's "entropy is everything") | Never use cute key generators; the mnemonic pipeline (§2.1) exists for a reason |
| 2023 | Curve read-only reentrancy wave | $70M+ ecosystem-wide | Protocols read an LP price during another contract's reentrancy window (view functions aren't guarded by the victim's lock) | Reentrancy isn't only about writes; check locks before trusting cross-contract reads |
| 2025 | Bybit / Safe UI | ~$1.5B — largest ever | Compromised Safe{Wallet} frontend showed signers a benign transfer while they actually signed a delegatecall that swapped the Safe's implementation (§14 + blind signing, §4.3) | Verify EIP-712 digests on the hardware device, not the web UI; treat operation=delegatecall in a Safe as radioactive; simulate before co-signing |
15.2 The recurring defect classes
| Class | One-line mechanics | Standard defense |
|---|---|---|
| Reentrancy (classic, cross-function, cross-contract, read-only) | External call hands control to attacker mid-state-change | Checks-Effects-Interactions; nonReentrant; pull-payments; snapshot reads |
| Delegatecall misuse | Untrusted/replaceable code run on your storage | Only delegatecall immutable, audited targets; EIP-1967 slots; 7702 implementation whitelists (§8.3) |
| Uninitialized implementation / re-initialization | Constructor ≠ initializer through a proxy | initializer modifier + _disableInitializers(); init in the same tx as deploy |
| Storage collision | Two code versions disagree about what slot N means | Append-only layouts, __gap, ERC-7201, upgrade-safety tooling |
| Selector clash | Two functions share a 4-byte selector; proxy routes the wrong one | Transparent-proxy admin split; tooling that checks clashes (it's why that pattern exists) |
| Signature bugs | Missing nonce/deadline/domain → replay; malleability; ecrecover(0); blind-signed 712 digests | The whole §4.3 checklist; hardware-verified signing |
| Approval abuse / permit phishing | Unlimited approve or a phished permit/Permit2 signature drains later, silently | Exact-amount approvals, allowance revoking, wallet simulation & warnings (§6) |
| Oracle / price manipulation | Spot price moved within one tx (flash loan) | TWAP, Chainlink, multi-source medians |
| Access control omissions | A public function that should not be (Parity's initWallet; Poly Network 2021, $611M) | Default-deny modifiers; negative tests; fuzz the "who can call this" matrix |
| Metamorphic contracts | CREATE2 + selfdestruct let code change at a "verified" address (largely closed by EIP-6780 in Dencun: selfdestruct now only works in the creation tx) | Check for the pattern when trusting addresses; post-Dencun this class is mostly historical |
Trace this chapter back through the guide: Parity is why implementations get initialized-and-locked (§14), which is why your 7702 delegate needs init-front-running protection (§8.3). The DAO's fork created the replay problem EIP-155 solved (§3.1). Blind signing enabled Bybit, which is the case for EIP-712 (§4.2) and hardware-verified digests. Profanity is why BIP-39 entropy discipline (§2.1) is non-negotiable. The standards are the scar tissue.
16 · Scaling: Sidechains vs Rollups
The block gas limit (§3.4) makes L1 blockspace scarce and expensive. Every scaling design answers one question differently: who do you trust when the operator misbehaves?
16.1 Sidechains — a different chain wearing an EVM costume
Polygon PoS, BNB Chain, Gnosis Chain: independent blockchains with their own validator set and consensus, connected to Ethereum only by a bridge contract. Security = their validators, full stop. If the sidechain's validators collude, your bridged funds are gone and Ethereum can't help — the bridge just holds locked tokens against the sidechain's word. Cheap, fast, EVM-compatible… and trust-them-shaped. (This trust surface is why bridges headline §15's loss tables — Ronin was a sidechain bridge.)
16.2 Rollups — execute elsewhere, settle on Ethereum
A rollup executes transactions on its own fast chain, then posts (a) the compressed transaction data and (b) a state commitment to L1. Ethereum stores the data (since Dencun, in cheap EIP-4844 blobs — §3.2), so anyone can reconstruct the rollup's state and check the operator. The two families differ in how the state commitment is checked:
| Optimistic | ZK | Sidechain | |
|---|---|---|---|
| State validity | fraud proofs (challenge window ~7 days) | validity proof per batch, verified by an L1 contract | trust the validator set |
| Withdrawal to L1 | ~7 days native (or instant via liquidity providers) | minutes–hours (prover latency) | bridge-dependent |
| EVM fidelity | near-perfect (same bytecode) | zkEVMs vary from bytecode-level to language-level | usually full EVM |
| Trust residue | ≥1 honest challenger + sequencer liveness | math + prover liveness | the validators, entirely |
16.3 What changes for wallet & contract developers
- Same keys, same address: every EVM L2 reuses coin type 60' — your EOA is identical everywhere, and only EIP-155's chainId (§3.1) separates the networks. Corollary: a 7702
chain_id=0authorization (§8) sweeps across all of them at once. - L2 fees are two-part: cheap L2 execution + your share of the L1 blob posting cost. That second component doesn't live in your
gasLimit— it's added by the sequencer's pricing contract, which is why L2 fee estimation feels "off" if you only think in §3.4 terms. - The sequencer is a centralization point (today): it orders transactions and can censor or go down (escape hatches let you force-include via L1, slowly). Decentralizing sequencers is the open workstream.
- L2s are where account abstraction actually lives: 4337's gas overhead (§7.5) is trivial at L2 prices, so smart-wallet UX (paymasters, session keys, passkeys) shipped on Base/Arbitrum/Optimism first. zkSync went further with native AA in the protocol — every account is a contract, no EntryPoint needed — a preview of EIP-7701 (§8.4).
- Contracts deploy mostly unchanged, but re-check assumptions:
block.timestamp/block.numbersemantics differ per rollup,PUSH0/newer opcodes may lag on some zkEVMs, and address aliasing applies to L1→L2 contract calls.
Ethereum L1 is a supreme court: absolute authority, slow, expensive per case. A rollup is a lower court that files complete transcripts with the supreme court — anyone can appeal with the transcript as evidence (fraud proof), or in ZK's case, every verdict arrives pre-notarized by a mathematician. A sidechain is a private arbitration firm across the street: fast and cheap, but if its judges collude, the supreme court never saw your case.
17 · Zero-Knowledge Proofs: SNARKs, STARKs & Friends
§16 waved at "validity proofs" — here's what's inside. A zero-knowledge proof lets a prover convince a verifier that a statement is true without revealing why it's true — and, just as importantly for Ethereum, without the verifier re-doing the work.
17.1 The idea, three ways
- The colorblind friend: you hold a red and a green ball; your colorblind friend can't tell them apart. He hides them behind his back, maybe swaps them, shows you one — you say "swapped" or "not swapped". One round is a 50% lucky guess; thirty rounds and he's convinced the balls differ… yet he never learns which is red. That's the whole shape: repeated challenges → overwhelming confidence → zero leaked knowledge.
- Where's Waldo: prove you found Waldo by covering the page with a huge board that has a Waldo-sized hole over him. The verifier sees Waldo — but learns nothing about where on the page he is.
- Sudoku: prove you solved the puzzle by letting the verifier check any one row/column/box of a shuffled, sealed copy — enough rounds convince them, no round reveals the solution.
Formally, a proof system needs three properties: completeness (true statements convince), soundness (false statements can't, except with negligible luck), and zero-knowledge (the verifier learns nothing beyond "it's true").
17.2 From riddles to circuits
Real systems prove statements of one canonical form:
"I know a secret witness w such that C(x, w) = true"
x = public inputs (everyone sees) w = private witness (never revealed)
C = an arithmetic circuit — the statement compiled into addition/multiplication
gates over a finite field (R1CS / PLONKish constraint systems)
Examples of C: "hash(w) equals this public commitment", "w is a valid Merkle path into this tree root", "w is a batch of 10,000 transactions whose execution moves state root A to state root B" — that last one is a ZK rollup (§16).
17.3 SNARK vs STARK vs the rest
| Groth16 (SNARK) | PLONK family (SNARK) | STARK | |
|---|---|---|---|
| Acronym | Succinct Non-interactive ARgument of Knowledge | Scalable Transparent ARgument of Knowledge | |
| Proof size | ~200 bytes (3 curve points) | ~400 B–1 KB | ~50–200 KB |
| Trusted setup | ⚠ per-circuit ceremony | universal, updatable ("powers of tau" — one ceremony, any circuit) | none — hashes only |
| Crypto assumptions | elliptic-curve pairings — not post-quantum | hash functions — plausibly post-quantum | |
| Verify cost on L1 | cheapest (~200k gas) | cheap | expensive → usually wrapped in a SNARK before landing on L1 |
| Used by | Tornado Cash, Zcash (orig.), many circom apps | zkSync, Scroll, Linea, Aztec (variants: Halo2, UltraPLONK…) | Starknet, StarkEx, zkVMs (RISC Zero, SP1) |
- Trusted setup, plainly: SNARKs need structured randomness ("toxic waste") generated once — if anyone who contributed destroys their share, the setup is safe (1-of-N honest). Hence giant public ceremonies (Zcash's, Ethereum's KZG ceremony for 4844 with >140k contributors). STARKs skip the problem entirely, paying with proof size.
- Recursion & folding (Nova, etc.): proofs that verify other proofs — how rollups aggregate thousands of transaction proofs into one L1-sized receipt, and how zkVMs prove long computations chunk by chunk.
- zkVMs (RISC Zero, SP1, Jolt): instead of hand-writing circuits, prove "this Rust/RISC-V program ran correctly." Slower proving, massively better developer ergonomics — the current direction of travel.
- Naming honesty: most "zk-rollups" use the succinctness of SNARKs, not the zero-knowledge part — the transactions are public. Privacy systems (Aztec, Zcash, Tornado) use both properties.
17.4 Production example #1 — Tornado Cash: the commitment/nullifier machine
The canonical ZK dApp, and the pattern half the ZK ecosystem reuses. The problem: deposit 1 ETH from address A, withdraw it to fresh address B, with no on-chain link between A and B — while making double-withdrawal impossible.
wallet generates two random 31-byte secrets: nullifier k, secret s
commitment C = PedersenHash(k ‖ s)
deposit(C) { 1 ETH in } → contract inserts C as a leaf in an on-chain
Merkle tree (height 20, ~1M leaves, MiMC hash)
// A's deposit is now one anonymous leaf among everyone else's.
public inputs: root (a recent Merkle root the contract knows)
nullifierHash = PedersenHash(k)
recipient, relayer, fee ← note these!
private witness: k, s, merklePath[20], pathIndices[20]
constraints:
C == PedersenHash(k ‖ s) // I know the secrets behind SOME leaf
MerkleVerify(C, merklePath, pathIndices) == root // that leaf is in the tree
nullifierHash == PedersenHash(k) // and this is ITS unique serial number
// recipient/relayer/fee are "squeezed" into the circuit as constrained
// public inputs — the proof is only valid for THIS exact payout
function withdraw(bytes calldata proof, bytes32 root, bytes32 nullifierHash,
address payable recipient, address payable relayer, uint256 fee) external {
require(!nullifierHashes[nullifierHash], "already spent"); // double-spend gate
require(isKnownRoot(root), "stale root"); // one of last 30 roots
require(verifier.verifyProof(proof,
[uint256(root), uint256(nullifierHash),
uint256(uint160(recipient)), uint256(uint160(relayer)), fee]), "bad proof");
nullifierHashes[nullifierHash] = true;
recipient.transfer(1 ether - fee);
if (fee > 0) relayer.transfer(fee);
}
Why each piece exists — this is the part worth internalizing:
- The nullifier is the double-spend defense: the contract never learns which leaf you're spending, so it can't mark leaves used. Instead you disclose
hash(k)— deterministic per deposit, unlinkable to the commitment (different hash inputs), and stored forever. Spend twice → same nullifierHash → rejected. World ID, Semaphore, Aztec, Privacy Pools and every private-airdrop scheme reuse exactly this trick. - recipient/relayer/fee inside the proof kills front-running: a withdraw tx sits in the public mempool; without this, any bot could copy your valid proof and swap in its own recipient. Because the proof constrains the payout, a tampered tx fails verification. (Compare §4.3's replay defenses — same instinct, ZK edition.)
- The relayer is gas abstraction, pre-4337: fresh address B has zero ETH to pay withdrawal gas — paying from A would deanonymize you. So a relayer (§6.2's concept!) submits the tx and takes
feefrom the note. Tornado needed a paymaster before paymasters existed. - Anonymity set = the tree: your privacy is exactly "one of the N depositors of this pool" — why fixed denominations (0.1 / 1 / 10 / 100 ETH pools) exist: variable amounts would fingerprint you.
17.4b Production example #2 — how a zk-rollup batch actually lands on L1
§16 said "validity proof per batch." Concretely, a zkSync/Scroll-style pipeline does this:
stateRoot_new from stateRoot_old.commitBatches) posts the compressed tx data as an EIP-4844 blob (§3.2) plus the claimed new root. Data is now available; the claim is not yet trusted.proveBatches(proof) — the L1 verifier contract checks the aggregated proof in one ~300–500k-gas call. A million L2 transactions just got verified for the L1 cost of a couple of Uniswap swaps. That division is the entire economics of ZK rollups.Where you can see it: zkSync Era's commitBatches/proveBatches/executeBatches on its L1 diamond contract, Scroll's ScrollChain.finalizeBatchWithProof — open Etherscan and watch the cadence: many commits, periodic proofs covering several batches each (proving lags sequencing by minutes to hours; that gap is "soft finality" and it's why bridges quote two different finality times).
• Zcash (2018): a subtle error in the BCTV14 setup parameters would have allowed infinite undetectable counterfeiting — found internally, patched silently, disclosed later. The proof system itself was the bug.
• Under-constrained circuits: a signal the circuit computes but never constrains lets the prover choose it freely — the class behind forged-deposit exploits on early Tornado forks and the 2022–23 audit wave in circom projects.
• "Frozen Heart" (2022): multiple libraries implemented Fiat–Shamir (the interactive→non-interactive transform) with incomplete transcripts, making forgeable proofs across several production codebases.
The lesson mirrors §15: the math is rarely wrong; the statement or the implementation is. Audit constraints like consensus code.
17.5 Where you'll meet ZK in the wild
| Use | The statement being proved | Examples |
|---|---|---|
| ZK rollups (§16) | "executing this batch moves state root A→B" | zkSync, Scroll, Linea, Starknet |
| Private payments | "I own an unspent note in this commitment tree" (Merkle membership + nullifier, without revealing which note) | Zcash, Tornado Cash, Aztec, Privacy Pools ("I'm in the tree AND not from these tainted deposits") |
| Identity / credentials | "a valid government/issuer signature exists over attributes satisfying P" — age, citizenship, uniqueness — without the document | zkPassport-style apps, World ID (one human, one proof), Semaphore anonymous voting/signaling |
| zkEmail / zkTLS | "this email/TLS session was genuinely signed by google.com's DKIM key and contains X" | proof-of-Twitter-handle account recovery, off-chain data oracles |
| Light clients & bridges | "this block header is valid under chain X's consensus" | zk light-client bridges (Succinct, Polyhedra) — replacing multisig bridges from §15's loss table |
| Wallet-adjacent | proof-of-reserves ("assets ≥ liabilities" without the ledger), private airdrop eligibility, zkML ("this model produced this output") | exchange PoR schemes, Worldcoin's iris pipeline |
A signature (§4) proves who said something. A zero-knowledge proof proves that something is true. Ethereum's endgame stacks them: you sign an intent (712), a smart account validates it (1271/4337), a rollup executes it, and a SNARK convinces L1 the whole thing happened correctly — one wax seal, one sealed envelope, one notarized transcript, each doing a different job.
18 · Consensus, Chain Security & the Quantum Question
Everything above assumed the chain itself is honest. This chapter is about why that's (economically) true — and about the one scheduled event that breaks the cryptography under every EOA on the planet.
18.1 What consensus actually does
A blockchain needs agreement on two things only: ordering (which tx came first — the double-spend question) and validity (state transitions follow the rules). Every consensus family is a different answer to "who gets to order, and what does cheating cost?"
| Family | Who orders | Finality | Cheating costs | Examples |
|---|---|---|---|---|
| PoW / Nakamoto | whoever finds the hash lottery ticket | probabilistic — 6 confirmations = "deep enough" | electricity + hardware, external to the chain | Bitcoin, pre-2022 Ethereum |
| PoS / Ethereum (Gasper = Casper-FFG + LMD-GHOST) | a validator (32 ETH stake) pseudo-randomly picked per 12s slot; ~1M validators attest | economic finality in 2 epochs (~13 min): reverting a finalized block requires burning ≥⅓ of all stake | slashing — provably contradictory votes destroy the validator's own capital, in-protocol | Ethereum today |
| BFT-style (Tendermint) | rotating proposer + ⅔ pre-commit votes per block | instant — a block is final or doesn't exist | slashing; but halts if >⅓ offline (liveness traded for safety) | Cosmos chains (§19) |
| DPoS / small committee | 27–100 elected block producers | fast, committee-final | reputation/stake of few entities | Tron (§19), BNB |
PoW is a lottery where tickets cost electricity — rewriting history means re-buying every winning ticket faster than the world combined. PoS is a security deposit system: validators post bail, and lying leaves cryptographic evidence that lets anyone burn their bail. BFT is a boardroom vote — instant and final, but the meeting stops if a third of the board doesn't show.
18.2 Attack economics — what "secure" quantitatively means
- 51% on PoW: majority hash power lets you reorg recent blocks and double-spend. Not theoretical — Ethereum Classic was 51%-attacked three times in August 2020 alone (thousands of blocks reorged) because its hash rate was small enough to rent. Security budget = cost of hash power, and small PoW chains are chronically underfunded.
- PoS thresholds: ⅓ of stake can halt finality (blocks still flow, finalization stops); ½ can control fork choice; ⅔ can finalize an invalid chain. But attacking stake is visible and slashable — the protocol can identify and burn it, and the community retains the nuclear option of socially forking the attacker out. PoW punishment is only "wasted electricity"; PoS punishment is confiscation.
- Long-range attacks: old validator keys (already-withdrawn stake) could re-sign an alternate history from way back. Defense: weak subjectivity — new nodes must start from a recent trusted checkpoint, not genesis alone. A real, accepted trade of PoS.
- The other 90% of real losses aren't consensus attacks at all — they're §15's application bugs, bridges, and key compromise. Consensus is the strongest layer of the stack; that's why attackers aim above it.
- MEV (maximal extractable value): orderers can front-run/sandwich/reorder within a block. Ethereum's live mitigation is PBS (proposer-builder separation via MEV-Boost) + private orderflow; full "enshrined PBS" and encrypted mempools are roadmap. For wallet devs this is why "send via private RPC" options exist.
18.3 Quantum computing vs your private keys
Blockchain cryptography stands on two different pillars, and quantum computers hit them very differently:
| Pillar | Quantum algorithm | Damage | Verdict |
|---|---|---|---|
| Hashes (keccak256, SHA-256, Pedersen/Poseidon…) — addresses, Merkle trees, PoW, commitments, BIP-39 seeds | Grover | only a quadratic speedup: 256-bit security → ~128-bit effective | ✅ fine — 128 bits is still unreachable; at most, double output sizes someday |
| Elliptic-curve signatures (secp256k1 ECDSA, ed25519) — every EOA, every §4 signature, BLS validator keys | Shor | solves the discrete-log problem outright: public key → private key in polynomial time | ❌ broken the day a large fault-tolerant quantum computer ("CRQC") exists |
So the question becomes: who has revealed their public key? Remember §2.6 — an address is keccak(pubkey)[12:], and a hash is quantum-safe. The pubkey itself only becomes public when you sign:
- Ethereum EOAs: every transaction you have ever sent revealed your pubkey (that's literally how
ecrecoverworks — the signature is pubkey recovery, §2.6). Any address that has ever transacted is harvestable today and crackable the day Shor-scale hardware arrives. A never-used address (funds received, nothing sent) hides behind the hash — until its first signature hits the mempool, where a fast quantum attacker could in principle race-forge before inclusion. - Bitcoin: same split — modern hashed addresses are protected until first spend (but address reuse re-exposes), while millions of early pay-to-pubkey coins, including Satoshi-era ones, sit permanently exposed. Estimates put ~a quarter of all BTC in quantum-vulnerable outputs — the canary everyone watches.
- Your seed phrase is not the weak point: BIP-39/BIP-32 (§2) are hash/HMAC constructions — Grover-only territory. It's the secp256k1 keys derived from the seed, once used, that are Shor-vulnerable. Harvest-now-decrypt-later applies to encrypted data, not signatures — but "harvest pubkeys now, forge signatures later" is the blockchain equivalent, and chains are a public, permanent pubkey archive.
The escape routes (and one you already know)
- NIST post-quantum standards (2024): ML-DSA (Dilithium, lattice), SLH-DSA (SPHINCS+, pure hash-based), FN-DSA (Falcon). Blockchain problem: signatures are huge (Dilithium ~2.4 KB, SPHINCS+ ~8–17 KB vs ECDSA's 65 bytes) — naively swapping them in would blow up block space. Hash-based one-time schemes (Winternitz/XMSS) are attractive precisely because their security is only hashes.
- STARKs are already post-quantum (§17.3 — hashes only): the research direction is wrapping many fat PQ signatures, or the whole state transition, inside one STARK. Aggregation turns "PQ signatures are too big" into "one proof per block."
- Account abstraction is the migration vehicle — the punchline of this whole guide: an EOA's ECDSA is hardcoded, but a smart account's validator is a module (§7
validateUserOp, §9's validator modules, §8's re-delegation). Rotating to a Dilithium or Winternitz validator is aninstallModulecall, not a hard fork. Ethereum's 7701 endgame plus a PQ precompile is the orderly path; Vitalik has also sketched the disorderly one — an emergency hard fork where users prove key ownership from the quantum-safe part (a STARK over the BIP-32 seed derivation) to migrate frozen accounts. - Timelines: current devices are orders of magnitude away from Shor-scale (thousands of logical, error-corrected qubits ≈ millions of physical). Serious forecasts cluster in the 2030s, with wide error bars — the industry posture is "no panic, but design escape hatches now," which is exactly what AA quietly is.
An address is a mailbox with your face behind frosted glass (the hash). Every signature you make hands out a clear photo (the pubkey). Today, nobody can pick a lock from a photo. Shor's algorithm is a machine that carves a working key from the photo — and the blockchain is a permanent public photo album. The fix isn't better photos; it's replaceable locks — which is what account abstraction turned wallets into.
19 · Beyond the EVM: Solana, Tron, Cosmos
Everything so far assumed Ethereum's model: secp256k1 keys, account-state, gas markets, contract storage. Other major chains made different bets — and each difference changes how "wallets" and "signing" even work.
13.1 Solana — everything is an account, and storage pays rent
Keys & signing. Solana uses ed25519, not secp256k1. Derivation is SLIP-0010, all-hardened (m/44'/501'/0'/0'), so there are no xpub watch-only trees. The address is simply the base58-encoded public key — no hashing step, no 0x, no checksum casing (base58 excludes look-alike characters instead). Signing is plain ed25519 over the serialized message — fast, deterministic, no malleability drama, and no ecrecover: verification takes the pubkey as input rather than recovering it.
The account model — inverted from Ethereum. On Ethereum, a token contract owns a giant mapping(address => uint256) inside its own storage. On Solana, programs are stateless: code lives in one executable account, and every piece of state lives in its own separate account owned by that program. A transaction must declare up front every account it will touch (which is what lets the runtime execute non-overlapping transactions in parallel — think EIP-2930 access lists made mandatory and turned into a scheduler).
ATAs — Associated Token Accounts
Because state is externalized, your USDC balance is not an entry in the USDC program — it's a separate token account holding {mint, owner, amount}. The Associated Token Account program standardizes where it lives: a PDA (program-derived address) computed deterministically from seeds:
ATA = find_program_address(
seeds = [wallet_pubkey, TOKEN_PROGRAM_ID, mint_pubkey],
program_id = ASSOCIATED_TOKEN_PROGRAM_ID )
// PDAs are addresses WITHOUT private keys — bumped off the ed25519 curve.
// Only the owning program can "sign" for them. (Compare: a contract wallet
// with no key that ERC-1271 lets speak — same idea, protocol-native.)
- One ATA per (wallet, mint) pair — deterministic, so senders can compute your USDC ATA without asking you.
- The ATA must be created before first receipt, and someone must fund its rent (below). Wallets/dApps usually create it silently in the same transaction — that "account creation fee" when receiving a new token is this.
Rent — storage as a leased resource
Every account must keep a lamport balance proportional to its data size — the rent-exempt minimum (~0.002 SOL for a token account). In practice "rent" is a refundable storage deposit: close the account and the lamports come back. Ethereum makes you pay gas once to write storage forever; Solana makes you escrow for as long as the bytes exist. This is why Solana wallets have a "close empty token accounts, reclaim SOL" button — and why dust-token spam literally locks up attacker capital.
Other wallet-relevant differences: fees are tiny and mostly fixed (plus priority fees); the fee payer is just the first signer — someone else can pay for your transaction natively, no ERC-4337 apparatus needed; replay protection uses a recent blockhash (~60–90s validity) instead of a nonce, with "durable nonces" as the escape hatch; and there's no protocol distinction between "EOA" and "contract account" to abstract away in the first place.
13.2 Tron — the EVM cousin with a staking-based gas model
- Keys: same secp256k1 + keccak256 pipeline as Ethereum (path
m/44'/195'/…). The 20-byte hash is prefixed with0x41and base58check-encoded — that's why every Tron address starts with T. Under the hood, a Tron address and an Ethereum address from the same key contain the same 20 bytes. - Contracts: the TVM is an EVM fork — Solidity compiles for it; TRC-20 is a byte-for-byte ERC-20 clone. This is why Tron became the USDT settlement rail: familiar contracts + cheap transfers.
- The big difference — resources instead of a gas market: transactions consume Bandwidth (bytes) and Energy (computation), which you earn by staking (freezing) TRX. Stake enough and your transfers are effectively free, forever; unstaked users burn TRX at fixed rates. It's a prepaid capacity model versus Ethereum's per-transaction auction — great UX for high-frequency payment flows, at the cost of capital lockup.
- Consensus: DPoS with 27 elected "Super Representatives" — fast blocks and cheap fees, materially more centralized than Ethereum's validator set. Signing is ordinary ECDSA over sha256 of the raw transaction (txID = that hash).
13.3 Cosmos — not a chain, a kit for building chains
- One key, many addresses: most Cosmos-SDK chains share coin type
118'and secp256k1; the address isbech32(ripemd160(sha256(pubkey)))with a per-chain human prefix — the same key renders ascosmos1…,osmo1…,juno1…. Wallets like Keplr show them as different "accounts", but it's one keypair wearing different name tags. - Signing: transactions are protobuf messages signed under
SIGN_MODE_DIRECT(legacy Amino JSON still around for hardware wallets). Replay protection = account number + sequence (a per-account nonce), and the chain-id is inside the signed doc — EIP-155's lesson, learned independently. - App-chains, not contracts-first: each Cosmos chain is a sovereign application (dYdX v4, Osmosis, Celestia…) with its own validator set and its own fee token; smart contracts (CosmWasm, Rust) are an optional module rather than the foundation. Cross-chain composability runs over IBC — light-client-verified message passing, protocol-level rather than bridge-contract-level.
- Fee model: gas exists, but prices are set per-chain (often near-zero, sometimes with fee-grant modules that let another account pay — their native paymaster analog).
13.4 Side-by-side: what "different from EVM" actually means
| Ethereum / EVM | Solana | Tron | Cosmos SDK | |
|---|---|---|---|---|
| Curve | secp256k1 | ed25519 | secp256k1 | secp256k1 (mostly) |
| HD path | 44'/60'/0'/0/i | 44'/501'/0'/0' all-hardened | 44'/195'/0'/0/i | 44'/118'/0'/0/i shared |
| Address | keccak(pubkey)[12:] → 0x | pubkey itself, base58 | 0x41+hash → base58check "T…" | bech32, per-chain prefix |
| Token balances live… | inside the token contract's mapping | in your own ATA (rent-funded) | in the TRC-20 contract (EVM-style) | native bank module / CW20 |
| Fees | 1559 auction, burned base fee | tiny fixed + priority; fee payer = any signer | staked Bandwidth/Energy | per-chain gas prices, fee grants |
| Replay protection | account nonce + chainId | recent blockhash (or durable nonce) | tx expiration + ref block | account sequence + chain-id in sign doc |
| "Smart wallet" story | ERC-4337 / EIP-7702 retrofit (this guide) | native fee-payer split, PDAs, multisig programs | EVM-style contracts, little AA activity | authz/feegrant modules, ICA over IBC |
Ethereum keeps everyone's balances in the bank's ledger book (contract storage). Solana hands you a safe-deposit box per asset (ATA) and charges a refundable deposit for the box (rent). Tron runs an EVM-style bank but sells prepaid transit passes (frozen TRX) instead of charging per trip. Cosmos doesn't give you a bank at all — it gives every community a kit to open its own bank, plus a courier network between them (IBC).
20 · Cheat Sheet — Every Number, One Memory Hook
| Number | Say it out loud | Memory hook |
|---|---|---|
EIP-155 | chainId replay protection | cheque with the country written on it |
EIP-2718 | typed tx envelope | one byte to rule the formats |
EIP-1559 | fee market | surge pricing, but the surge is burned |
EIP-191 | personal_sign | 0x19 makes messages ≠ transactions |
EIP-712 | typed data signing | signing a labeled form, not a napkin |
ERC-1271 | isValidSignature → 0x1626ba7e | call the company's front desk |
ERC-6492 | sigs for undeployed wallets | cheque + incorporation papers stapled |
ERC-7739 | defensive rehashing | 1271, replay-hardened |
EIP-2612 | permit() | signed letter of authorization |
ERC-4337 | AA via alt-mempool | restaurant: slips, waiter, kitchen pass |
EIP-7702 | set EOA code (type 0x04) | programmable chip in your old debit card |
ERC-7579 | minimal modular accounts | app store API for wallets |
ERC-6900 | Alchemy's modular accounts | the stricter app store |
EIP-5792 | wallet_sendCalls | the dApp↔wallet batching language |
ERC-7677 | paymaster web service | the gas-sponsorship phone line |
EIP-3074 | withdrawn AUTH/AUTHCALL | the road not taken |
EIP-7701 | native AA (future) | the endgame |
Suggested learning order
- §2–3 (keys, transactions) then §4 (191/712) — the foundation everything sits on
- §5 (1271 / 6492 / SignatureChecker) — your original question; every dApp needs it
- §7 (4337) — the big one; internalize the restaurant
- §8 (7702) — short, but it changes assumptions everywhere
- §6, §9–11 — permit, modules, ecosystem, as your build requires
- §13–15 (EVM, proxies, hacks) — before you ship or audit any contract
- §16–19 — scaling, ZK, consensus/quantum, multi-chain, as your product grows