Stealth address math
How a sender computes an address that only you can find and spend from. Five steps, the proof, and a worked example you can check line by line.
In short
A stealth address is an ordinary Ethereum address. Its private key is your spending key plus a secret that only you and the sender can compute. The sender can compute the address, but only you can compute its key.
ephemeral uses scheme 1 of ERC-5564: the secp256k1 curve that Ethereum accounts use, with keccak256 and view tags. Other ERC-5564 wallets use the same scheme, which is why payments work between them.
Notation
| Symbol | Meaning |
|---|---|
G, n | The generator of secp256k1 and its order |
p_spend, P_spend | Your spending key pair, P_spend = p_spend · G |
p_view, P_view | Your viewing key pair, P_view = p_view · G |
p_e, P_e | The sender's one-time key pair, fresh for every payment |
s, s_h | The shared secret point (33 bytes, compressed) and keccak256(s), read as a number mod n |
addr(P) | The Ethereum address of P: the last 20 bytes of keccak256 of P, uncompressed, without its 0x04 prefix |
The five steps
Publish once. The receiver shares the meta-address: both public keys, compressed, 66 bytes in total.
Shared secret. The sender draws a fresh random p_e and multiplies it into the viewing key.
Hash and tag. The hash of the shared secret becomes a scalar. Its first byte is the view tag.
view tag = s_h[0]
One-time key. The spending key is shifted by s_h. The address of the result is the stealth address.
stealth = addr(P_stealth)
Pay and announce, in one transaction, so that the receiver can never miss the payment. The view tag is the first byte of the metadata.
Receiving: find, then spend
Your client reads every announcement and repeats the sender's work from the other side. Only the viewing key is needed to find payments; the spending key is needed only to move them.
s′ = p_view · P_e
keccak256(s′)[0] ≠ view tag → not yours, stop (255 of 256)
addr(P_spend + keccak256(s′) · G) = stealth → yours
p_stealth = (p_spend + keccak256(s′)) mod n
Why it works
Both sides reach the same point, because scalar multiplication on the curve commutes:
s′ = p_view · P_e = p_view · p_e · G = p_e · P_view = s
p_stealth · G = (p_spend + s_h) · G = P_spend + s_h · G = P_stealth
So the key the receiver computes controls exactly the address the sender paid.
Why only the receiver
- Finding a payment needs
s. That takesp_e, which only the sender knows, orp_view, which only you know. Computingsfrom the publicP_eandP_viewalone is the elliptic-curve Diffie–Hellman problem, the same assumption behind every Ethereum key exchange. - Spending needs
p_spendas well. Someone with your viewing key can see every payment to you, but cannot move any of them. That is why the viewing key can be shared for read-only access.
Worked example
These demo keys are derived from fixed labels, so anyone gets the same numbers: p_spend = keccak256("ephemeral demo key: spend") mod n, p_view from "ephemeral demo key: view" and p_e from "ephemeral demo key: payment 1". Never send funds to them.
Reproduce it with @noble/curves and viem:
import { secp256k1 } from '@noble/curves/secp256k1';
import { keccak256, toBytes, hexToBytes } from 'viem';
import { publicKeyToAddress } from 'viem/accounts';
const n = secp256k1.CURVE.n;
const key = (label: string) => BigInt(keccak256(toBytes(label))) % n;
const hex32 = (x: bigint) => hexToBytes(`0x${x.toString(16).padStart(64, '0')}`);
const p_spend = key('ephemeral demo key: spend');
const p_view = key('ephemeral demo key: view');
const p_e = key('ephemeral demo key: payment 1');
const P_spend = secp256k1.getPublicKey(hex32(p_spend), true);
const P_view = secp256k1.getPublicKey(hex32(p_view), true);
// sender
const s = secp256k1.getSharedSecret(hex32(p_e), P_view, true);
const s_h = keccak256(s); // 0xc17d…6076, view tag 0xc1
const P = secp256k1.ProjectivePoint.fromHex(P_spend).add(
secp256k1.ProjectivePoint.fromPrivateKey(hexToBytes(s_h)),
);
publicKeyToAddress(`0x${P.toHex(false)}`); // 0xF236126F35184De906A7Cdeb00432a682F8352F4
// receiver
const p_stealth = (p_spend + BigInt(s_h)) % n;
publicKeyToAddress(`0x${secp256k1.ProjectivePoint.fromPrivateKey(hex32(p_stealth)).toHex(false)}`); // same addressTest vectors
Sixteen more vectors, plus one for key derivation from a signature, are in erc5564-scheme1.json. Each one lists every intermediate value (shared secret, hashed secret, view tag, stealth address, stealth key) and was checked against the ScopeLift stealth-address SDK. Use them to test any ERC-5564 implementation.
What each side sees
| Party | Knows | Can compute |
|---|---|---|
| Sender | p_e, P_spend, P_view | This one stealth address, not its key |
| Observer | P_e, view tag, stealth address | Nothing that links the address to you |
| Viewing-key holder | p_view, P_spend | Every payment to you; cannot spend |
| You | p_spend, p_view | Every payment and every key |
What can go wrong
What can go wrong
- A reused
p_e. The samep_efor the same receiver gives the same address twice, and the two payments link. The client drawsp_efrom the system's secure random source for every payment. - A leaked viewing key. Every incoming payment and amount becomes visible to whoever holds it, but nothing can be moved. Publish a new meta-address to rotate.
- Your next move. The math hides the link; a careless withdrawal can reveal it. See Withdraw without leaking.
- Tag collisions. One in 256 announcements that are not yours pass the view tag. The full check then rejects them. That costs time, not privacy.