Skip to main content

Stealth addresses

A normal withdrawal pays a public address the sender chose. If a recipient hands out one address per counterparty or reuses one, the payments become linkable on-chain. Stealth addresses let a recipient publish a single static meta-address while every payment lands at a fresh, one-time stealthAddress that only the recipient can compute the private key for. The sender also posts an announcement so the recipient can discover the payment by scanning, with no interactive channel.

Privacy Pools v2 uses this for the last hop out of the pool: a stealth withdrawal is an ordinary withdrawal whose recipient is a derived one-time address, submitted atomically with its ERC-5564 announcement through PrivacyPoolRelay.relayAndAnnounce.

Meta-address, ephemeral key, announcement

  1. The recipient publishes a meta-address. For the canonical scheme that is two compressed secp256k1 public keys, a spending key and a viewing key. For the FluidKey scheme it is a single uncompressed spending key.
  2. The sender derives. They generate a fresh ephemeral key pair, perform ECDH against the recipient's key, hash the shared secret, and derive the one-time stealthAddress from the recipient's spending key plus that hash. The first byte of the hash is the view tag.
  3. The sender announces. announce(schemeId, stealthAddress, ephemeralPubKey, metadata) on the canonical ERC-5564 Announcer, with the view tag as metadata.
  4. The recipient scans. For each announcement in their scheme, recompute the shared hash from their own key and the ephemeral key; compare its first byte to the view tag (a 1-in-256 prefilter that skips most announcements cheaply); on a match, reconstruct the candidate address and compare it to the announced stealthAddress. A hit yields the stealth private key, which spends the funds.

Two schemes

The SDK ships both recipes side by side and dispatches on the meta-address's scheme tag.

SCHEME_CANONICALSCHEME_FLUIDKEY
schemeId0x0001 (the EIP-5564 registered id for secp256k1)0xfffe (an unallocated placeholder until the FluidKey variant is registered)
ECDH againstrecipient viewing pubkeyrecipient spending pubkey
Point operationaddition, S = B + hash·Gmultiplication, S = B·hash
Ephemeral pubkey on the wire33-byte compressed65-byte uncompressed
Who can scanthe viewing key alonethe spending key (it must be online to scan)

The two are not interchangeable: a canonical scanner applied to FluidKey bytes silently finds nothing. An earlier phase shipped the FluidKey recipe under 0x0001; the multi-scheme module moved it to 0xfffe and restored the true canonical recipe at 0x0001, without changing any FluidKey derivation bytes.

The canonical announcer

All announcements go to the one ERC-5564 Announcer singleton, deployed at the same address on every supporting chain: 0x55649E01B5Df198D18D95b5cc5051630cfD45564. The SDK refuses any other announcer address, because announcing through a different deployment narrows the recipient's anonymity set to "users of that other announcer". The monorepo ships a minimal ERC5564Announcer only for local and anvil deployments where the singleton is absent; live deployments resolve the canonical address.

Why the announcement is bound on-chain

An announcement is advisory by nature: nothing in ERC-5564 ties it to a payment. If a relayer could announce one stealth address while paying another, the recipient would scan a hit and find nothing there. PrivacyPoolRelay.relayAndAnnounce closes that gap: it decodes the payout recipient from the proof-bound PayoutRouting and requires announcement.stealthAddress to equal it (PrivacyPoolRelay_AnnouncementRecipientMismatch), then relays and announces in one transaction. Only the address is enforced. A wrong ephemeral key would only break discovery of a correctly funded payment, which is recoverable by re-announcing. On the recipient side the SDK offers the mirror check, verifying that an announcement sits in the same transaction as a matching withdrawal.

Announcements are public

Posting to the announcer marks the withdrawal as a stealth payment for anyone watching the chain. That is the right trade when sender and recipient have no other channel. When discovery can be cooperative, the SDK has a quieter carrier: the ephemeral public key and view tag can be packed, with the encrypted note payload, into a deposit's opaque noteData.data and scanned from there instead of from a public announcement. The cross-chain reshield dust-rescue path uses this, which is why an earlier design that announced rescue deposits publicly was withdrawn.

What this is not

Stealth addresses are separate from viewing keys. A viewing key decrypts notes inside the pool; a stealth meta-address controls where a withdrawal lands outside it. Stealth withdrawal is a standard-relay feature: it does not compose with batch withdrawal or with a yield unwrap, both of which bind a different processor.

Source: v2-monorepo/packages/sdk/src/stealth/ and packages/contracts/src/contracts/PrivacyPoolRelay.sol