Skip to main content

Stealth withdraw

A stealth withdrawal is a withdrawal whose destination is a fresh address derived from the recipient's published meta-address, plus an ERC-5564 announcement that lets the recipient find it. On-chain the two land in one transaction through PrivacyPoolRelay.relayAndAnnounce, which refuses an announcement whose stealth address differs from the proof-bound payout recipient. Read Stealth addresses first for the derivation and the two schemes.

How it works

  1. Resolve the recipient. Turn what the recipient gave you into a MetaAddress: a canonical identifier (compressed spending and viewing public keys) selects the EIP-5564 scheme at 0x0001; a raw identifier (one uncompressed spending key) selects the FluidKey scheme at 0xfffe.
  2. Derive. Generate an ephemeral key pair and derive the one-time stealthAddress and its Announcement (scheme id, address, ephemeral public key, view-tag metadata).
  3. Prove. Build the withdrawal exactly as usual, with recipientAddress = stealthAddress and PrivacyPoolRelay as processor.
  4. Relay and announce. Hand the relayer the withdrawal relay params plus the announcement fields. It submits relayAndAnnounce, the contract checks announcement.stealthAddress == payout.recipient, relays, then announces to the canonical Announcer as the last action.

How to withdraw to a stealth address

The stealth module is a separate SDK subpath. The relayed, atomic path uses RelayerInteractor.relayWithAnnounce:

import {
CANONICAL_ANNOUNCER_ADDRESS,
deriveStealthAddress,
generateEphemeralKeyPair,
resolveRecipient,
toStealthAnnouncementFields,
} from "@privacy-pools-v2/sdk/stealth";

// 1–2. Resolve and derive.
const metaAddress = resolveRecipient({
kind: "canonical",
spendingPubKey: recipientSpendingPubKey, // 33-byte compressed secp256k1
viewingPubKey: recipientViewingPubKey, // 33-byte compressed secp256k1
});
const ephemeral = generateEphemeralKeyPair(metaAddress.scheme);
const derivation = deriveStealthAddress(metaAddress, ephemeral, metaAddress.scheme);

// 3. Prove a normal withdrawal to the one-time address.
const prepared = await session.prepareWithdraw({
inputCommitments: [noteToSpend.commitment],
amount: withdrawAmount,
tokenId: NATIVE_ETH,
recipientAddress: derivation.stealthAddress,
});

// 4. Relay atomically with the announcement. `withdrawalRelayParams` is the
// relay params you would pass to relayWithdrawal for prepared.relayerOptions[0].
const txHash = await relayerInteractor.relayWithAnnounce(relayerInfo, {
...withdrawalRelayParams,
announcement: toStealthAnnouncementFields(derivation),
});

If you are submitting yourself, prepareStealthWithdrawal composes the pair for you: give it the recipient identifier, a callback that returns your withdrawal call for a given stealth address, and CANONICAL_ANNOUNCER_ADDRESS, and it returns a StealthWithdrawalBundle with withdraw and announce as two EvmCalls. Submit both; without relayAndAnnounce the binding between them is only what you enforce, which is why the relayed path is preferred.

Recipient side

The recipient scans announcements in their scheme with scanAnnouncement(announcement, credentials), which returns the stealth address on a match and null otherwise, using the one-byte view tag to skip the vast majority cheaply. deriveStealthPrivateKey then produces the key that spends the funds. verifyAnnouncementBindsToWithdrawal confirms an announcement sits in the same transaction as a matching withdrawal before the recipient trusts it.

Constraints

  • Canonical announcer only. The SDK refuses any announcer other than the CREATE2 singleton at 0x55649E01B5Df198D18D95b5cc5051630cfD45564 (assertCanonicalAnnouncer), because announcing elsewhere narrows the anonymity set to that other deployment's users.
  • Only the address is bound. The contract enforces stealthAddress == recipient. A wrong ephemeral key breaks discovery of a correctly funded payment, which is recoverable by re-announcing; it cannot misdirect funds.
  • Scheme mismatch is silent. A canonical scanner never finds a FluidKey announcement and vice versa. Resolve with the identifier kind the recipient actually published.
  • Standard relay only. relayAndAnnounce is a PrivacyPoolRelay function with no PPRouter branch and no batch form, so a stealth withdrawal cannot unwrap a yield note or be a batch item.
  • Announcements are public. Anyone can see that this withdrawal was a stealth payment. For cooperative discovery inside a deposit, the module's encodeStealthDepositPayload / scanDepositNoteData carry the same material in the note's encrypted data instead; the cross-chain reshield dust rescue uses that.
  • Availability. relayAndAnnounce exists only on relay contracts deployed after it was added. The production relay on mainnet, BNB Chain, and Citrea has it (its ANNOUNCER() is the canonical announcer); the Sepolia V9 relay in the Sepolia page predates it. Confirm the relayer publishes a relay that exposes it before offering the option.

Behind the scenes

SDK call resolveRecipient, generateEphemeralKeyPair, deriveStealthAddress, toStealthAnnouncementFields from @privacy-pools-v2/sdk/stealth; session.prepareWithdraw(...); relayerInteractor.relayWithAnnounce(relayer, params).
Contract method PrivacyPoolRelay.relayAndAnnounce(proof, transactParams, noteData, announcement), which calls PoolVault.transact, distributes the payout, and then Announcer.announce(schemeId, stealthAddress, ephemeralPubKey, metadata).
Relayer route POST /v1/relay/evm/{chainId}/relay-and-announce: an ordinary withdrawal relay request plus a nested announcement. The withdrawal fee quote is reused; the announcement transfers no value.
Circuit transact_NxM; nothing stealth-specific is proven. The stealth address is just the bound recipient.

What's next