PrivacyPoolRelay contract
PrivacyPoolRelay is the fee-splitting processor that relayers call into: it wraps PoolVault.transact, takes the relayer's fee from the public output, and forwards the rest. The deployed relayer routes both transfers and withdrawals through it, so relayed proofs bind it as their processor.
What it does
PrivacyPoolRelay calls PoolVault.transact and splits its public output: the feeRecipient takes feeAmount (defaulting to msg.sender, the relayer, when set to the zero address), and the recipient gets the rest. What the recipient actually receives depends on the asset:
| Asset | Recipient receives |
|---|---|
| ERC-20 | recipientAmount in the token; any attached msg.value is forwarded to them separately as ETH gas funding |
| Native ETH | recipientAmount + msg.value, with the attached ETH folded into the payout |
The fee is capped: feeAmount must be at most the per-asset maxRelayFee from the Entrypoint asset config (ENTRYPOINT.assets(_asset).maxRelayFee) and at most amountOut. There is no fee-commitment proof signal.
relay reverts when its preconditions fail:
PrivacyPoolRelay_ZeroWithdrawalif the proof'samountOutis not positive, such as a zero-output pure transfer.PrivacyPoolRelay_NativeGasMismatchif the attachedmsg.valuedoes not equal the committednativeGas, which keeps the gas funding the relayer attaches honest.
POOL, ENTRYPOINT, and ANNOUNCER (the ERC-5564 announcer used by relayAndAnnounce) are immutables set in the constructor, which reverts with PrivacyPoolRelay_ZeroPool(), PrivacyPoolRelay_ZeroEntrypoint(), or PrivacyPoolRelay_ZeroAnnouncer() if any is zero. The constructor also requires MAX_BATCH < POOL.rootHistorySize() (PrivacyPoolRelay_RootHistoryTooSmall()), the coupling relayBatch depends on. And because the relay contract is what calls transact, it is the msg.sender that PoolVault.transact sees, and the processor the proof must bind to (see below). receive() accepts ETH from the pool only (PrivacyPoolRelay_OnlyPoolCanSendETH()).
Why the processor is not the relayer wallet
The relayer.address is the EOA wallet that pays gas and signs the quote, while the processor is the PrivacyPoolRelay contract, which calls PoolVault.transact on the relayer's behalf and is therefore the msg.sender the proof must bind to. The proof binds to processor, and PoolVault enforces require(processor == msg.sender), reverting with PoolVault_InvalidProcessor if they differ.
Main functions
Four entry points, each a composition of the same core _relay. PayoutRouting is { recipient, feeRecipient, feeAmount, nativeGas }, ABI-encoded into transactParams.data and therefore bound in the proof's context.
relay(proof, transactParams, noteData[]) Requires amountOut > 0 and msg.value == nativeGas, decodes PayoutRouting from transactParams.data, requires a non-zero recipient, enforces feeAmount <= maxRelayFee (a configured 0 means no cap) and feeAmount <= amountOut, calls PoolVault.transact, then distributes the payout and fee by asset type.registerAndRelay(account, authDigest, nullifyingKeyHash, deadline, signature, proof, transactParams, noteData[]) Cold start in one transaction. Step 1 registers account in the keystore the pool reads (POOL.keystore()) via setAuthPolicyWithSig, unless the account already carries exactly the requested nullifyingKeyHash, in which case registration is skipped. Step 2 runs relay on a proof built against the post-registration keystore root. The skip makes the bundle robust to a front-runner who submits the account's own signed registration first: the bundle degrades to a plain relay instead of reverting on Keystore_AuthPolicyAlreadySet. A mismatched existing key still reverts.relayAndAnnounce(proof, transactParams, noteData[], announcement) A stealth withdrawal. Decodes the same PayoutRouting and requires announcement.stealthAddress == recipient (PrivacyPoolRelay_AnnouncementRecipientMismatch), relays, then calls ANNOUNCER.announce(schemeId, stealthAddress, ephemeralPubKey, metadata) as the last action. Only the address is enforced; the other announcement fields are forwarded as-is. The contract holds no state, so there is no reentrancy surface.relayBatch(proofs[], transactParams[], noteData[][]) A batch withdrawal. Requires 1 <= n <= MAX_BATCH and equal array lengths. The first item fixes the asset, recipient, and raw feeRecipient for the whole batch and every item must match; every item needs amountOut > 0, a fee within maxRelayFee, and feeAmount <= amountOut. Loops PoolVault.transact over the items, requires msg.value == Σ nativeGas, then makes one distribution of Σ amountOut − Σ fee to the recipient and Σ fee to the fee recipient. Emits RelayedBatch.MAX_BATCH() → 10 The batch cap. Held strictly below the pool's rootHistorySize (16 on the deployed configuration) because every batch item anchors to the same pre-batch state root and each transact advances the root buffer by one; a longer batch would evict its own snapshot. Exposed as a getter rather than a public constant.Errors you'll see
PrivacyPoolRelay_ZeroWithdrawal() The proof's amountOut is zero (a pure transfer). This contract only relays withdrawals.PrivacyPoolRelay_NativeGasMismatch() msg.value does not equal the committed nativeGas (or, for a batch, their sum).PrivacyPoolRelay_ZeroRecipient() The bound PayoutRouting.recipient is the zero address.PrivacyPoolRelay_FeeExceedsMax() / PrivacyPoolRelay_FeeExceedsWithdrawal() The committed fee is above the asset's maxRelayFee or above amountOut.PrivacyPoolRelay_AnnouncementRecipientMismatch() relayAndAnnounce was given an announcement whose stealth address is not the payout recipient.PrivacyPoolRelay_EmptyBatch() / PrivacyPoolRelay_BatchTooLarge() / PrivacyPoolRelay_BatchLengthMismatch() relayBatch shape checks: zero items, more than MAX_BATCH, or arrays of different lengths.PrivacyPoolRelay_InconsistentAsset() / PrivacyPoolRelay_InconsistentRecipient() A batch item's tokenIdOut, recipient, or feeRecipient differs from the first item's.Deployed versions differ
The production relay on Ethereum mainnet, BNB Chain, and Citrea (0x01a8Fa4dbb60187aD8d3c3d08E7Fb8C14198A461) is the full contract: MAX_BATCH() answers 10 and ANNOUNCER() is the canonical ERC-5564 announcer. The Sepolia V9 relay in the Sepolia page predates registerAndRelay, relayAndAnnounce, relayBatch, and MAX_BATCH, so it exposes only relay. Read the relay address the relayer publishes in /v1/details and probe MAX_BATCH() before offering batch or stealth withdrawal on a given deployment.
Self-relay alternative
If you're calling PoolVault.transact directly from your own wallet (no fee splitting), you set processorAddress = yourWallet at proof-build time. See self-relay.
Source: v2-monorepo/packages/contracts/src/contracts/PrivacyPoolRelay.sol