Skip to main content

Yield deposit

A yield deposit puts USDC to work on Aave while it sits in the pool. You pay USDC; you hold a note denominated in ppUSDC shares whose value in USDC rises as interest accrues. The pool sees an ordinary ERC-20 deposit of the share token. PPRouter does the wrapping in the same transaction, and the proof you generate is a standard deposit proof whose tokenId is the wrapper.

How a yield deposit works

  1. Size in shares. The note commits to a share count, so that is the fixed leg. Choose it from a mint principal (sharesForUnderlying, which is previewDeposit exactly) or from an all-in wallet budget (sizeSharesForBudget, which reserves a small rate headroom and fits the router's full price inside the rest).
  2. Prove. The SDK builds the normal deposit proof with tokenId = ppUSDC and value = shares, then re-targets the same (proof, noteData, aspCiphertext) at PPRouter.depositExactShares. The proof's context is keccak(noteData), which the router forwards unchanged, so it stays valid.
  3. Cap the spend. maxUnderlyingIn bounds the USDC the router may pull: the caller's figure, or the router's quote plus DEPOSIT_RATE_HEADROOM_PPM (1 ppm, roughly seventeen minutes of accrual at 3% APR). A deposit that outlives its cap reverts PPRouter_SlippageExceeded and is retried with a fresh quote; it never pays more than the cap.
  4. Submit. Approve the router for the cap, then send the deposit. The router buys exactly shares + vettingFee shares through the zap, hands them to the Entrypoint with your proof, and asserts its own share balance is back to baseline.

How to deposit

Yield methods live on the namespace session.yieldFor(token) returns for a configured wrapper or its underlying:

const toHex = (value: bigint): `0x${string}` => `0x${value.toString(16)}`;

const yieldSessions = session.yieldFor(ppUsdcAddress);
if (!yieldSessions) throw new Error("no yield deployment configured for this chain");

// Size from what the wallet is willing to spend all-in: 100 USDC (6 decimals).
const sized = await yieldSessions.deposit.sizeSharesForBudget(100_000_000n);
console.log("shares:", sized.shares, "cap:", sized.maxUnderlyingIn, "headroom:", sized.headroom);

const prepared = await yieldSessions.deposit.prepareYieldDeposit({
value: toHex(sized.shares),
maxUnderlyingIn: sized.maxUnderlyingIn,
});

// Submit prepared.approvalTxs in order (a zero-reset precedes the approval when a
// residual allowance exists; mainnet USDT reverts otherwise), then prepared.depositTx.
// Persist prepared.pendingNote once the deposit is mined.

prepareYieldDeposit returns pendingNote, approvalTxs, depositTx, and the sizing it settled on (sharesValue, underlyingNeeded, maxUnderlyingIn). A caller cap below the router's quoted underlyingNeeded is refused with InvalidYieldAmount rather than producing a transaction that will revert.

The headroom starts being consumed the moment you size: it has to cover proof generation, the wallet prompts, the approval confirmation, and inclusion. If the budget no longer fits by the time you prepare, size and prepare again.

Constraints

  • Shares, not USDC, are what you hold. Show a user previewRedeem(shares) as the position's value and never the raw share count; the SDK's yield token interactor exposes the ERC-4626 reads.
  • Bucket in shares for privacy. Round USDC amounts produce rate-dependent share counts, which partition the anonymity set by deposit time. Offer standard share amounts; see Yield shares.
  • The vetting fee is charged in shares. The router mints value + vettingFee shares and the Entrypoint keeps the fee, so the underlying you spend covers both. Because ppUSDC has 18 decimals, its Entrypoint minAmount and maxRelayFee are share-denominated.
  • Native mode. A ppETH router accepts ETH directly through depositExactSharesNative, where msg.value is both the funding and the cap and the unspent remainder is refunded in the same transaction. The SDK encodes that call on the swap-shaped deposit path when the target router is native-mode.
  • Aave is a counterparty. Your underlying is supplied to Aave. A failure there is a loss there; the pool cannot insulate you from it, and unwrapping later depends on Aave liquidity.

Availability

Yield is live in production: Ethereum mainnet carries ppUSDC, ppUSDT, and ppETH (the last through a native-mode router, so users deposit ETH directly), and BNB Chain carries ppUSDT. There is none on Citrea or Sepolia.

The SDK's canonical YIELD_DEPLOYMENTS table is nonetheless empty, and LOCAL_FORK_YIELD_DEPLOYMENT exists for the anvil Sepolia fork only. A session learns about the live deployments either explicitly (withYieldDeployment / withYieldDeployments with the addresses from the chain pages) or from the production relayer's /v1/details, whose yield[] entries carry the wrapper, underlying, router, zap, and decimals per deployment; the builder derives deployments from that feed when none were set explicitly. Check session.yieldFor(token) for undefined before offering the option.

Behind the scenes

SDK call session.yieldFor(token).deposit.sizeSharesForBudget(...) or .sharesForUnderlying(...), then .prepareYieldDeposit(...).
Contract method PPRouter.depositExactShares(proof, noteData, aspCiphertext, maxUnderlyingIn), which calls PPYieldTokenZap.buyFixedYieldToken and then Entrypoint.deposit.
Sizing constants DEPOSIT_RATE_HEADROOM_PPM = 1, ZAP_SUPPLY_BUFFER_UNITS = 2 (the zap's rounding buffer, already inside the router's quote), CROSS_CHAIN_DEPOSIT_RATE_HEADROOM_PPM = 500 for a share deposit that lands after a bridge.
Circuit deposit with tokenId = ppUSDC and value in shares.

What's next

  • Yield withdraw: unwrap back to USDC for a recipient.
  • Manage notes: the yield note is attested and discovered like any deposit.
  • Reshield: land a swap as a yield share instead of a plain token.