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​