ephemeral

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

SymbolMeaning
G, nThe generator of secp256k1 and its order
p_spend, P_spendYour spending key pair, P_spend = p_spend · G
p_view, P_viewYour viewing key pair, P_view = p_view · G
p_e, P_eThe sender's one-time key pair, fresh for every payment
s, s_hThe 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.

step 1
st:eth:0x ‖ P_spend ‖ P_view

Shared secret. The sender draws a fresh random p_e and multiplies it into the viewing key.

step 2
s = p_e · P_view

Hash and tag. The hash of the shared secret becomes a scalar. Its first byte is the view tag.

step 3
s_h = keccak256(s)
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.

step 4
P_stealth = P_spend + s_h · G
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.

step 5
Announcer.announce(1, stealth, P_e, viewTag ‖ …)

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.

receive

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:

proof

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 takes p_e, which only the sender knows, or p_view, which only you know. Computing s from the public P_e and P_view alone is the elliptic-curve Diffie–Hellman problem, the same assumption behind every Ethereum key exchange.
  • Spending needs p_spend as 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.

P_spend0x023be430d8668638a68ff14b443d0751feb962baac1ef8b19f7689b98ab7372672P_view0x021e1d22fe68906d331fbf56d8d377da2c5ee96af9ff18773cdd62ba0e997282a3P_e0x0379cc9a6af9d816bc02cdad69aea1eb9f55b43e5fa0331e86f99c3698442d555ds0x027b6a27cf4d658b7d07c4d0a3a078bec7eae0664b25e8ed9f74ee8ce738c7f5d1s_h0xc17d8e71241c4fefb2a5c06f3080e359ec67ce42f3123736d6381ce5af956076view tag0xc1stealth0xF236126F35184De906A7Cdeb00432a682F8352F4checkaddr((p_spend + s_h) · G) = 0xF236126F35184De906A7Cdeb00432a682F8352F4 ✓

Reproduce it with @noble/curves and viem:

worked-example.ts
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 address

Test 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

PartyKnowsCan compute
Senderp_e, P_spend, P_viewThis one stealth address, not its key
ObserverP_e, view tag, stealth addressNothing that links the address to you
Viewing-key holderp_view, P_spendEvery payment to you; cannot spend
Youp_spend, p_viewEvery payment and every key

What can go wrong

What can go wrong

  • A reused p_e. The same p_e for the same receiver gives the same address twice, and the two payments link. The client draws p_e from 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.

On this page