Yield withdraw
A yield withdrawal spends yield-share notes (ppUSDC) and lands their value at a public address as the underlying (USDC). It is a standard withdrawal with one substitution: the proof binds PPRouter as its processor instead of PrivacyPoolRelay, so the vault pays the shares to the router, which unwraps them and pays the recipient.
How a yield withdrawal works
- Prove. The SDK builds the normal withdrawal proof against the router, binding a
PayoutRouting(recipient,feeRecipient,feeAmount,nativeGas) as the transact params.amountOutis the shares leaving the pool;recipientShares = amountOut - feeAmount. - Size the floor. The session reads
PPRouter.quoteWithdraw(recipientShares)and setsminUnderlyingOutto that figure minus a slippage allowance (WITHDRAW_SLIPPAGE_BPS, 1% by default, overridable per call). The floor protects the recipient leg against a rate move or a fee change between quote and execution. It is a router parameter, not a proof signal. - Submit. Either you submit
withdrawToUnderlyingyourself, or a relayer does. The router requiresprocessor == address(this), pulls the shares from the vault, unwraps the recipient's shares (reverting if the underlying out is below the floor), unwraps the fee leg for the fee recipient, and forwards anynativeGasto the recipient. A fee that would redeem to zero underlying is waived rather than failing the withdrawal.
In native mode (a ppETH wrapper) the router unwraps WETH once and pays the recipient their underlying plus the gas top-up as ETH in a single transfer.
How to withdraw
Yield methods live on a per-deployment namespace rather than on PoolSession directly. session.yieldFor(token) returns the YieldSessions for the wrapper or underlying you pass (or undefined when no yield deployment is configured):
const yieldSessions = session.yieldFor(ppUsdcAddress);
if (!yieldSessions) throw new Error("no yield deployment configured for this chain");
// Self-submit: one router transaction, gas paid by you.
const prepared = await yieldSessions.withdraw.prepareYieldWithdraw({
inputCommitments: [yieldNote.commitment],
amount: sharesToRelease, // Hex, in ppUSDC shares
amountOut: sharesLeavingThePool, // Hex, in ppUSDC shares (includes any fee)
recipient: publicDestination,
slippageBps: 100n, // optional, default WITHDRAW_SLIPPAGE_BPS
});
console.log("recipient gets at least", prepared.minUnderlyingOut, "USDC units");
// submit prepared.withdrawTx from your wallet (value = nativeGas), then
// persist prepared.changePendingNotes and mark prepared.spentNotes spent once mined
For a gasless, relayed withdrawal, take a withdrawal quote from the relayer for the share asset, then bind it:
const relayed = await yieldSessions.withdraw.prepareYieldWithdrawRelay({
inputCommitments: [yieldNote.commitment],
amount: sharesToRelease,
amountOut: sharesLeavingThePool,
recipient: publicDestination,
signedFeeCommitment: quote.feeCommitment, // from the relayer's withdrawal quote
extraGas: false,
});
// relayed.relayParams carries processorAddress = PPRouter and minUnderlyingOut;
// hand it to the relayer exactly as you would a plain withdrawal.
const txHash = await relayerInteractor.relayWithdrawal(relayerInfo, relayed.relayParams);
The prepared result also exposes recipientShares, quotedUnderlyingOut, and minUnderlyingOut, so a UI can show what the recipient will get before submitting.
Constraints
- One wrapper per router.
tokenIdOutmust be the router's own share token; a note in a different asset takes the plain withdraw path. - Cannot be batched. A yield-to-underlying withdrawal binds the router as processor, and a batch item must bind PrivacyPoolRelay. To move many yield notes at once, withdraw them as shares through the plain relay and unwrap afterwards.
- Depends on Aave liquidity. The unwrap ends in Aave's
withdraw. If the reserve is paused, frozen, or fully utilised, the router reverts and the note is not spent; withdraw the shares as shares instead and unwrap later, or exit to the aToken withPPYieldToken.redeem, which never calls Aave. - The floor is not proof-bound.
minUnderlyingOutis set by whoever submits, so on the relayed path the SDK sizes it from an honest quote and passes it in the typed relay params; the relayer forwards it unchanged. - Fee in shares. On the relayed path the relayer quotes the share asset through its
Underlyingprice alias, scaling the underlying's price by the wrapper's livepreviewRedeem, so the committedfeeAmountis denominated in shares and tracks accrued yield.
Behind the scenes
session.yieldFor(token).withdraw.prepareYieldWithdraw(...) for self-submit, or .prepareYieldWithdrawRelay(...) then relayerInteractor.relayWithdrawal(...) for the relayed path.PPRouter.withdrawToUnderlying(proof, transactParams, noteData, minUnderlyingOut), which calls PoolVault.transact and then PPYieldTokenZap.redeemToUnderlying once per leg.processorAddress set to the router. The relayer requires that address to be in its allowed_processors and routes the request to withdrawToUnderlying, appending minUnderlyingOut. Discover the router from /v1/details (yield[].router).transact_NxM with non-zero amountOut and tokenIdOut = ppUSDC; nothing yield-specific is proven.Loose shares after a ragequit
Ragequit pays the note's tokenId, so a yield note ragequits to ppUSDC shares in the owner's wallet, not USDC. Two exits exist for loose shares: PPYieldTokenZap.redeemToUnderlying(shares, receiver) unwraps to USDC through Aave in one call, and PPYieldToken.redeem(shares, receiver, owner) exits to the aToken without touching Aave. The SDK exposes both ABIs; neither is a pool operation.
What's next
- Yield deposit: the inbound half.
- Withdraw and swap: spend a yield note into a different token by unwrapping through
RelaySwapsinstead. - Manage notes: the change note, if any, is an ordinary yield-share note.