Skip to main content

PPRouter contract

PPRouter is the yield router: the one contract that appears on both sides of a yield-share note. Going in, depositExactShares wraps an underlying token into PPYieldToken shares and shields them in one transaction. Coming out, withdrawToUnderlying is the PoolVault.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, 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.

Withdrawing: the router as processor

A yield withdrawal is a standard transact proof with two bindings: processor = PPRouter and tokenIdOut = VAULT, with a PayoutRouting 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: 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 (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 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