# PrivacyPoolRelay contract

> PrivacyPoolRelay is the fee-splitting processor that [relayers](/operations/relaying) call into: it wraps [`PoolVault`](/protocol/contracts/pool-vault)`.transact`, takes the relayer's fee from the public output, and forwards the rest. The deployed relayer routes both [transfers](/operations/transfer) and [withdrawals](/operations/withdraw) through it, so relayed proofs bind it as their `processor`.

PrivacyPoolRelay is the fee-splitting processor that [relayers](/operations/relaying) call into: it wraps [`PoolVault`](/protocol/contracts/pool-vault)`.transact`, takes the relayer's fee from the public output, and forwards the rest. The deployed relayer routes both [transfers](/operations/transfer) and [withdrawals](/operations/withdraw) 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](/protocol/contracts/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_ZeroWithdrawal`** if the proof's `amountOut` is not positive, such as a zero-output pure transfer.
-   **`PrivacyPoolRelay_NativeGasMismatch`** if the attached `msg.value` does not equal the committed `nativeGas`, 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](#why-the-processor-is-not-the-relayer-wallet)). `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](/operations/stealth-withdraw). 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](/operations/batch-withdraw). 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](/deployments) (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](/deployments/sepolia) 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](../../operations/self-relay).

Source: `v2-monorepo/packages/contracts/src/contracts/PrivacyPoolRelay.sol`
