# PPRouter contract

> The yield router: wraps an underlying into an exact number of ppUSDC shares on deposit, and acts as the transact processor that unwraps shares to the underlying on withdrawal, in ERC-20 or native mode.

PPRouter is the yield router: the one contract that appears on both sides of a [yield-share](/concepts/yield-shares) note. Going in, `depositExactShares` wraps an underlying token into [PPYieldToken](/protocol/contracts/pp-yield-token) shares and shields them in one transaction. Coming out, `withdrawToUnderlying` is the [`PoolVault`](/protocol/contracts/pool-vault)`.transact` processor: the vault pays it the withdrawn shares, and it unwraps them to the underlying for the recipient. The pool is unchanged by any of this; the router is an adapter that happens to be named as `processor` in the proof.

Every Aave interaction goes through the wrapper's [zap](/protocol/contracts/pp-yield-token-zap), never through the router, which is what keeps the router protocol-agnostic. The router is stateless apart from at most two units of aToken dust per deposit, swept lazily.

## Two modes, fixed at construction

The eighth constructor argument, `_nativeMode`, sets the immutable `NATIVE_MODE`:

-   **ERC-20 mode** (`false`): the underlying is a plain token such as USDC. Deposits pull it from the caller; withdrawals pay it to the recipient. This is the audited flow.
-   **Native mode** (`true`): the underlying is a wrapped-native token (WETH, for a ppETH wrapper over aWETH), and the router adds native-coin edges. Deposits accept ETH and wrap it; withdrawals unwrap WETH and pay the native coin. The middle is the same wrapper, zap, Entrypoint, and PoolVault, statement for statement; only the rim changes. A native-mode constructor probes the underlying with a zero-value `IWETH9.deposit`, so deploying native mode over a non-WETH token fails at construction rather than on the first user.

`receive()` accepts ETH only in native mode and only from the wrapped-native token itself (the push during unwrap); every other sender reverts `PPRouter_DirectEthTransfer`. No ETH can be stranded in the router by an accidental send, and its ETH balance is transient within a transaction.

## Immutables

`VAULT() → address` The PPYieldToken wrapper the router shields into. The only asset either entry point accepts as `tokenId` / `tokenIdOut`.
`UNDERLYING() → address` The token supplied to Aave (USDC, or WETH in native mode).
`A_TOKEN() → address` `VAULT.asset()`, the aToken; the dust `skim` sweeps.
`ZAP() → address` The PPYieldTokenZap for this wrapper. The constructor requires `ZAP.VAULT() == VAULT` and `ZAP.UNDERLYING() == UNDERLYING`, so a mismatched pairing cannot deploy.
`ENTRYPOINT()`, `POOL_VAULT()` The privacy-pool contracts deposits go through and withdrawals are processed against.
`DUST_RECIPIENT() → address` Fixed recipient of swept aToken dust.
`NATIVE_MODE() → bool` Which of the two modes this instance runs.
`POOL() → address` The Aave pool. Retained for reference but no longer used in logic; the zap is the sole Aave touchpoint.

## Depositing: an exact share count

A yield deposit is a standard deposit proof whose `tokenId` is the wrapper. The proof commits to a *share* value; the router's job is to buy exactly that many shares and hand them, with the proof, to the Entrypoint.

`depositExactShares(proof, noteData, aspCiphertext, uint256 _maxUnderlyingIn) → underlyingIn` ERC-20 path. Requires `proof.tokenId == VAULT` and a non-zero value. Quotes `total = value + vettingFee` (the fee the Entrypoint will pull, from `ENTRYPOINT.assets(VAULT).vettingFeeBPS`) and `underlyingIn = ZAP.quoteBuy(total)`; reverts `PPRouter_SlippageExceeded` if that exceeds `_maxUnderlyingIn`. Pulls `underlyingIn` from the caller, has the zap mint exactly `total` shares to the router, approves the Entrypoint for `total`, and calls `Entrypoint.deposit(proof, noteData, aspCiphertext)` with the arguments unchanged, so the proof's `context` still matches. Finally asserts the router's share balance is back to its pre-call baseline (`PPRouter_SharesStranded`), so a deviating Entrypoint reverts instead of stranding the payer's shares.
`depositExactSharesNative(proof, noteData, aspCiphertext) → underlyingIn` (payable) Native-mode path (`PPRouter_NotNativeMode` otherwise). Same quote, but `msg.value` is both the funding and the spend cap, so the two can never disagree. Wraps exactly `underlyingIn` of the sent ETH via `IWETH9.deposit`, runs the identical zap-mint-shield sequence, and refunds `msg.value - underlyingIn` to the caller in the same transaction as its last statement. Over-funding for rate drift therefore costs nothing.

Why exact shares rather than exact underlying: the note commits to the share count, so that is the leg that must be hit precisely, and the underlying is what floats. Rate drift between quote and execution moves the underlying spent, never the committed value. The SDK sizes the cap from the router's own `quote` plus a small headroom; see [Yield deposit](/operations/yield-deposit).

## Withdrawing: the router as processor

A yield withdrawal is a standard transact proof with two bindings: `processor = PPRouter` and `tokenIdOut = VAULT`, with a [`PayoutRouting`](/protocol/contracts/privacy-pool-relay) in `transactParams.data`. Anyone may submit it; the payout is bound in the proof.

`withdrawToUnderlying(proof, transactParams, noteData[], uint256 _minUnderlyingOut) → (toRecipient, fee)` (payable) The steps, in order:

1.  Require a non-zero `amountOut` (the shares leaving the pool), `tokenIdOut == VAULT`, and `transactParams.processor == address(this)` (`PPRouter_InvalidProcessor`), so the vault's payout lands here.
2.  Decode `PayoutRouting`. Require a non-zero `recipient` (`PPRouter_ZeroRecipient`; stricter than PrivacyPoolRelay by design, since a zero recipient would burn the unwrapped underlying), `msg.value == nativeGas`, `feeAmount <= ENTRYPOINT.assets(VAULT).maxRelayFee` (a configured `0` means no cap), and `feeAmount <= amountOut`. A zero `feeRecipient` resolves to `msg.sender`.
3.  Waive a dust fee: if `PPYieldToken.previewRedeem(feeAmount)` is zero, the fee leg is dropped and its shares go to the recipient instead of failing the whole withdrawal on the wrapper's `PPYieldToken_ZeroAssets` guard. A withdrawal never depends on the relayer's fee policy to survive.
4.  Call `PoolVault.transact` and require the router's share balance rose by exactly `amountOut` (`PPRouter_SharesNotReceived`).
5.  Redeem the recipient's shares through the zap and require the underlying out is at least `_minUnderlyingOut` (`PPRouter_BelowMinUnderlying`). Redeem the fee leg separately. In ERC-20 mode each redeem pays its party directly.
6.  In native mode both redeems land on the router, which unwraps once with `IWETH9.withdraw` and then pays the recipient their underlying **plus** `msg.value` in a single native transfer and the fee recipient their fee, both as immediately spendable ETH. In ERC-20 mode `msg.value` is forwarded to the recipient as ETH after the token transfers.

`_minUnderlyingOut` is a router parameter, not a proof signal, so it protects the recipient leg against a rate move or a mid-flight fee change without being fixed at proving time. The SDK derives it from `quoteWithdraw` minus a slippage allowance.

Because the proof binds `processor = PPRouter`, a yield-to-underlying withdrawal can never be an item in a [batch withdrawal](/operations/batch-withdraw): `relayBatch` calls the vault with PrivacyPoolRelay as `msg.sender`, and the vault requires `processor == msg.sender`. The two paths are mutually exclusive by construction.

## Sizing views and maintenance

`quote(uint256 _value) → (underlyingNeeded, total)` The underlying to supply and the total shares (value plus vetting fee) the Entrypoint will pull to shield `_value` shares now. The same function the deposit paths use, so a quote and its execution cannot drift.
`quoteWithdraw(uint256 _shares) → underlyingOut` `PPYieldToken.previewRedeem(_shares)`; the aToken is 1:1 with the underlying, so this is the underlying a withdrawal of that many shares yields.
`skim() → amount` Permissionless. Sweeps whatever aToken dust has accumulated (at most two units per deposit, from the zap's supply buffer) to `DUST_RECIPIENT`, emitting `Skimmed` only when the amount is non-zero.

## Errors you'll see

`PPRouter_AssetMismatch(address)` The proof's `tokenId` (deposit) or `tokenIdOut` (withdrawal) is not this router's wrapper. Each router serves exactly one share token.
`PPRouter_SlippageExceeded(uint256 required, uint256 max)` The exact mint needs more underlying than the cap (or than `msg.value` in native mode). The rate moved between quote and execution; re-quote and retry. Nothing was pulled.
`PPRouter_NotNativeMode()` `depositExactSharesNative` called on an ERC-20-mode router.
`PPRouter_DirectEthTransfer()` ETH sent to the router by anything other than the wrapped-native unwrap in native mode.
`PPRouter_InvalidProcessor()` The proof's `transactParams.processor` is not this router. Build the proof with the router as processor.
`PPRouter_ZeroRecipient()` The bound `PayoutRouting.recipient` is the zero address.
`PPRouter_NativeGasMismatch()` / `PPRouter_FeeExceedsMax()` / `PPRouter_FeeExceedsWithdrawal()` The same payout guards as PrivacyPoolRelay: `msg.value` must equal the committed `nativeGas`, and the fee must respect the asset's `maxRelayFee` and the withdrawn amount.
`PPRouter_BelowMinUnderlying(uint256 out, uint256 min)` The recipient leg redeemed for less than `_minUnderlyingOut`.
`PPRouter_SharesStranded()` / `PPRouter_SharesNotReceived()` Defensive baseline checks: the Entrypoint did not pull exactly `total` shares, or the vault did not pay exactly `amountOut`. Neither is expected against the trusted core; both fail closed.
`PPRouter_ZeroValue()` / `PPRouter_ZeroWithdrawal()` / `PPRouter_ZeroAddress()` A zero committed value, a zero `amountOut`, or a zero constructor address.

## Events

`Deposited(address payer, address asset, uint256 value, uint256 underlyingIn, uint256 total)` Emitted by both deposit paths: the committed share value, the underlying actually spent, and the total shares minted (value plus vetting fee).
`WithdrawnToUnderlying(address asset, address underlying, address recipient, uint256 sharesOut, uint256 underlyingToRecipient, address feeRecipient, uint256 feeShares, uint256 underlyingFee, uint256 gasFunding)` Emitted by `withdrawToUnderlying`; `feeShares` is the fee actually charged after any dust waiver.
`Skimmed(address to, uint256 amount)` Emitted by a non-empty `skim`.

Routers are immutable and deployed one per wrapper by [`PPRouterFactory`](/protocol/contracts/yield-factories) (`createRouter` for ERC-20 mode, `createRouterNative` for native mode). Notes are not bound to a router, so a new router for the same wrapper serves existing notes unchanged. In production, [mainnet](/deployments/mainnet) runs ERC-20-mode routers for ppUSDC and ppUSDT and a native-mode router for ppETH; the two ERC-20 routers are the earlier build without the `NATIVE_MODE()` getter, and all three answer `withdrawToUnderlying` with the same ABI.

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