A transaction has no sender

COMMIT5c5dd91HEAD → main
PUBLISHEDDec 6, 20223y ago
UPDATEDJul 25, 20265d ago
READING14 min2,059 words
#ethereum#cryptography·by santiago toscanini
Ethereum·Part 6 of 6

Nine posts ago this series started with a single Bitcoin block and a promise to read every byte that mattered. Ownership turned a signature into algebra — an (r, s) pair over secp256k1, verified live. The Ethereum half rebuilt the state: accounts with four fields, a trie that folds all of them into 32 bytes, an EVM that runs bytecode one opcode at a time, and tokens that are nothing but a mapping — where "sending" is a SLOAD and two SSTOREs, and the only thing that travels on-chain is the tx.data calldata. The audit ran my own buggy homework and watched it drain.

One question is still open, and it's the one a beginner asks first: what does MetaMask actually do when you click "Confirm"? This post answers it byte by byte, the same way What clone actually says read a git clone off the wire — its spiritual sibling one series over. And the answer reframes the whole thing: MetaMask doesn't send your transaction. It signs it. The sending is a separate, boring HTTP request that anyone could make. And the transaction it signs has no from field anywhere in it — your address is recovered from the signature, never transmitted.

Nothing here is a mockup. The transactions below are built and signed in your browser by this series' own secp256k1 — the same curve that verified those 2010 Bitcoin spends — using the real functions from my SSEI-Token exam dApp (an NFT-collateralized loan app with a React/MetaMask frontend). The signing key is the public Hardhat/Anvil test account #0, so I can show it without lying about custody. And the eth_call responses are real captures from a public mainnet node (ethereum-rpc.publicnode.com) taken on 2026-07-24 — because a signature you can't verify isn't worth reading.

01 · Read and write are two different verbs

A wallet talks to the chain over JSON-RPC: a plain HTTP POST carrying {"jsonrpc":"2.0","method":…,"params":[…],"id":…}. That's the whole protocol — the same request/response shape my old course notes described, method and params and an id to match the answer to the question. There is no persistent socket, no special port. curl is a perfectly good Ethereum wallet if you don't mind doing the signing yourself.

But the methods split cleanly into two kinds, and the split is the entire mental model:

  • Reads use eth_call. "What's this balance? Who owns token 7? What's the total supply?" A node answers from its own copy of the state. No signature, no account, no gas, no change to anything. Free.
  • Writes use eth_sendRawTransaction. "Move these tokens. Take out this loan." This one changes the shared state, so it costs gas and — crucially — it must be signed. The parameter is a single blob: a transaction you already signed yourself.

That asymmetry is why reading Ethereum is free and anonymous while writing costs money and proves who you are. A read is a question; a write is a signed fact. Everything else in this post is just those two requests, read closely.

02 · A transaction is a list, not a message

Before you can sign a transaction you have to build one, and it is not a message addressed to anyone. It's a list of fields, RLP-encoded — the same Recursive Length Prefix that encodes accounts and trie nodes. In 2026 the default is the EIP-1559 "type 2" transaction: a leading 0x02 byte, then

rlp([chainId, nonce, maxPriorityFeePerGas, maxFeePerGas,
     gasLimit, to, value, data, accessList])

Read the fields like a sentence. chainId pins this to one network (so a mainnet signature can't be replayed on a testnet). nonce is your account's transaction counter — it makes each tx unique and forces them into order. The two fee fields are the 1559 model: a maxFeePerGas ceiling and a maxPriorityFeePerGas tip, with the base fee burned. to is 20 bytes (empty means "deploy a contract"). value is wei to send. And data is the calldata — the four-byte selector plus padded arguments that part eight built by hand.

Look at what is not in that list: your address. There is no from field. The transaction never says who is sending it — and yet the network will know. Hold that thought.

03 · Sign it locally, and prove it

Here is the whole ceremony, and it happens entirely in your browser — no network touched. Pick a real SSEI-Token call, edit any field, and watch four things recompute: the unsigned RLP bytes, the keccak256 hash of them, the signature (r, s, yParity) produced by the real secp256k1, and the final signed transaction — 0x02 followed by the same fields plus the signature, RLP-encoded again. That last blob is exactly what eth_sendRawTransaction carries.

The default call is requestLoan(uint256) on my dApp's LoanContract — selector 8d5d3429, borrowing ETH against an NFT you deposited. Switch to safeTransfer(address,uint256) (423f6cef) on the NFTContract to hand the NFT away, or to a plain ETH send with no data at all. Sign one, then read its bytes:

Things worth watching for:

  • The signing hash is the tail of everything the series built. keccak256 of the RLP bytes is the 32-byte message that gets signed — the Ethereum analogue of Bitcoin's sighash, but with keccak instead of sha256d and a cleaner preimage. Change the nonce by one and the hash, the signature, and every byte downstream change completely. Sign the same fields twice here and you get the same signature: my secp256k1, like most modern libraries, derives ECDSA's internal nonce deterministically from the key and the message (RFC 6979), so there's no randomness to leak. That's a library choice, not a protocol rule — a wallet that signs with a genuinely random nonce produces a different signature that verifies just as well, and a wallet that reuses one leaks its key.
  • r and s are the same pair from the Bitcoin post. Identical curve, identical algebra — and if you want that algebra rather than my word for it, it's a post of its own, recovery bit included. What's new here is the third number: yParity, a single recovery bit (0 or 1). It exists so a verifier can rebuild the public key from the signature alone. Bitcoin didn't need it because the spender hands over their pubkey explicitly; Ethereum recovers it, which is why there's no from to transmit.
  • The last panel is the honest proof. It takes (hash, r, s, yParity) and runs ecrecover — the inverse of signing — to reconstruct the signer's address, then checks it equals the demo account. Green means the math closed the loop. Tamper with s in your head: flip one bit and ecrecover returns a different address, not an error. That's the deep point — a signature doesn't get "rejected," it just recovers whoever actually signed. Corrupt it and you've simply signed as someone else.

The type toggle switches to the legacy (EIP-155) form for contrast: no 0x02 byte, fees collapse to a single gasPrice, and the recovery bit is folded into v = recovery + chainId·2 + 35 instead of standing alone as yParity. Same signature, older envelope. Type-2 is the 2026 reality; I keep legacy around because its canonical test vector is how I checked the recovery math is correct.

04 · The from field is a fiction the network computes

Section two left a thread hanging: the signed transaction has no sender in it, yet every block explorer shows you a from. Where does it come from?

It's recovered. When a node receives your raw transaction, it hashes the unsigned fields, takes your (r, s, yParity), and runs the exact ecrecover the widget just ran — reconstructing the public key that produced the signature, then hashing it to an address (keccak256(pubkey), last 20 bytes). That recovered address is the from. It was never in the bytes; it falls out of the signature, and it can't be forged, because only the private key holder can produce a signature that recovers to their address.

This is why "A transaction has no sender" is literally true and completely fine. The sender isn't declared, it's proven. Claiming to be someone is free; the only way to make a transaction recover to an address is to hold that address's key. Your identity on Ethereum is not a field you fill in — it's a consequence of the one number you never reveal.

(A 2026 footnote the exam long predates: since the Pectra upgrade, EIP-7702 lets an ordinary account temporarily delegate to contract code while still signing plain transactions like this one. It complicates "an account either is or isn't a contract," but not this: the signature still recovers to the signer, and the sender is still computed, not sent.)

05 · What actually crosses the wire

Now put the two verbs side by side. The read is a real capture; the write carries the transaction you signed above. Toggle between them and read the actual JSON:

Things worth watching for:

  • The read has no signature and reveals nothing. eth_call sends params: [{to, data}, "latest"] — a contract address, some calldata, and which block to read at. That's it. The captured response is a raw 32-byte word, which the widget decodes live: a real USDC balance, a real total supply, straight off mainnet. No key was involved because reading changes nothing. Anyone can ask; the node just reads its state and answers.
  • The write carries exactly one thing: your signed blob. eth_sendRawTransaction's params are a single hex string — the 0x02… bytes from the signer above. The node re-derives the sender by recovery, checks the nonce and the fees, and (if it's willing) gossips it to the mempool. The signing already happened, on your machine; this request is just delivery. That's the honest shape of "sending a transaction": your wallet does the cryptography, and any node can do the mailing.
  • The response to a write is just a hash. A node answers eth_sendRawTransaction with the transaction's hash — which is simply keccak256 of the raw bytes, computable before anyone sees it. The widget shows the real hash this exact transaction would have. It is not, however, broadcast: nothing on this page touches a live network. Which is the honest disclaimer this whole post rests on.

06 · The honest edges, and the end of the road

Gathered in one place, because this is where the toy meets the real thing. Nothing is broadcast — every transaction here is built and signed locally and shown, never sent; the SSEI-Token contract addresses are the real ones from the dApp's Utils.js, but they lived on Rinkeby, a testnet deprecated in 2022 and sunset for good in the summer of 2023, so there is nothing there to send to anyway. The eth_call responses are genuine mainnet captures (USDC, ethereum-rpc.publicnode.com) shown to demonstrate the true wire shape, because a dead testnet address can't answer; the write's response hash is real but the send is representative. The signing key is the public Hardhat test account, safe to show precisely because it guards nothing. And I show type-2 as current with legacy for contrast — both are real, both verified against known vectors.

The mental model, compressed: a wallet speaks JSON-RPC, and its verbs come in two flavors — eth_call reads state for free with no signature, eth_sendRawTransaction writes state with a signed transaction that costs gas. That transaction is an RLP list of fields with no sender in it. You keccak256 it, sign the hash with secp256k1 to get (r, s, yParity), and RLP-encode the whole thing again into the blob that crosses the wire. The network recovers your address from the signature — the from is computed, never transmitted — and that recovery is the entire security model of who you are.

And that closes the arc. Ten posts, from a genesis block to a MetaMask click, and the through-line held the whole way: it's bytes, and you can read them. A Bitcoin coin is a locking script; an Ethereum account is four RLP fields; a token transfer is two storage writes; a signature is algebra you can re-run; and a transaction is a list you hash and sign. No magic anywhere — just formats, hashes, and one elliptic curve doing the same job in Satoshi's chain and Vitalik's.

If you want to keep pulling threads, everything here is real code you can run: the browser cryptography is my minichain library — the Ethereum companion to the py-git and py-docker projects that seeded the git and container series — and the contracts are my actual SSEI-Token exam dApp and its sibling exam repos, bugs and all. Clone them, sign a transaction, recover your own address. It's bytes all the way down, no magic, and now you can read every one of them.

Thanks for reading the whole thing.

$git log --oneline public/posts/ethereum-jsonrpc/
5c5dd91Elliptic curves: the cryptography the crypto series kept deferringtoday
536605dNeural networks from scratch: a third post on pixels and vectorisationtoday
© 2026 · v2.0 · santiago toscanini