Skip to main content

PPYieldToken contract

PPYieldToken is the non-rebasing ERC-4626 wrapper that turns an Aave v3 aToken into a pool asset: aUSDC becomes ppUSDC. The pool itself never talks to Aave. It shields ppUSDC shares exactly as it shields any other ERC-20, and the share count inside a note stays fixed while the exchange rate from shares to the aToken rises as Aave interest accrues. Yield shares explains why that shape matters for privacy; PPRouter is the contract that wraps and unwraps around it on the way in and out.

What it does

The wrapper holds the aToken as its ERC-4626 asset() and mints shares against it. Three properties define it:

  • Shares are always 18 decimals. The constructor fixes _decimalsOffset = 18 - assetDecimals (the aToken must have at most 18 decimals, else PPYieldToken_UnsupportedDecimals). For a 6-decimal asset like aUSDC that is a virtual-share offset of 10^12, so the ERC-4626 inflation buffer is largest exactly where low-decimal assets need it most.
  • The exchange rate is derived live, never stored. totalAssets() is the wrapper's current asset.balanceOf(this) minus the protocol fees it owes, clamped at zero. Nothing else feeds the rate, so a share is worth whatever the backing is worth right now.
  • The protocol fee is charged on yield only. feeBps applies to the increase in the aToken balance since the last checkpoint, never to principal, and is hard-capped at MAX_FEE_BPS = 2000 (20%).

Every wrapper is deployed through PPYieldTokenFactory, which seeds it with permanently locked dead shares so it is never empty.

State

asset() → address The wrapped Aave aToken (ERC-4626 underlying asset). This is the backing; it is the one token rescueToken can never move.
feeBps() → uint256 Current protocol fee on yield, in basis points. At most MAX_FEE_BPS (2000).
accumulatedFees() → uint256 Fees already checkpointed and owed to the admin, in aToken units. Excluded from totalAssets().
lastCheckpointBalance() → uint256 The aToken balance at the last checkpoint. Any balance above it is treated as yield the next time a checkpoint runs.
claimableFees() → uint256 Checkpointed fees plus the fee on yield that has accrued since the last checkpoint, against the live balance.
exchangeRate() → uint256 convertToAssets(10 ** decimals()): the aToken value of one whole share.
rewardsController() → address Admin-settable Aave RewardsController used by claimRewards. Zero until set.
admin() → address The Ownable2Step owner, who claims fees and sets the fee.

Fee model

Fees are checkpointed lazily. On every deposit, mint, withdraw, redeem, claimFees, and setFeeBps, the wrapper first runs _checkpoint(): it reads the live aToken balance and, if it is above lastCheckpointBalance, adds feeBps of the difference to accumulatedFees. After the action moves tokens, lastCheckpointBalance is reset to the new balance, so newly deposited principal is never mistaken for yield.

Two consequences follow from that ordering:

  • A fee change is never retroactive. setFeeBps settles the yield accrued so far at the old rate before switching, so raising the fee only affects yield that has not happened yet.
  • Depositors cannot be diluted by fees. totalAssets() already excludes claimableFees(), so convertToAssets prices shares on net backing. Claiming the fee moves tokens the shares were never entitled to.

The share rate therefore only moves with Aave interest and is designed to be non-decreasing under normal accrual. If the aToken balance ever fell below the checkpoint, _pendingFees returns zero and totalAssets() clamps at zero rather than underflowing.

Main functions

Standard ERC-4626 deposit, mint, withdraw, and redeem are inherited. Both internal hooks are nonReentrant, reject a dust deposit that rounds to zero shares (PPYieldToken_ZeroShares) or a dust redeem that rounds to zero assets (PPYieldToken_ZeroAssets), and run the checkpoint described above.

setFeeBps(uint256 _newFeeBps) Owner only. Requires _newFeeBps <= MAX_FEE_BPS. Checkpoints at the old rate first, then switches. Emits FeeUpdated.
claimFees() / claimFees(address _recipient) Owner only. Checkpoints, transfers accumulatedFees in the aToken to the recipient (the owner for the no-argument form), zeroes the counter, and re-baselines. Emits FeesClaimed. Never touches principal.
setRewardsController(address) Owner only. Points claimRewards at an Aave RewardsController. Emits RewardsControllerUpdated.
claimRewards(address[] _assets, address _to) Owner only, nonReentrant. Calls claimAllRewards on the configured controller, which can only move reward tokens the wrapper has accrued for holding aTokens, never the aToken backing itself. Reverts PPYieldToken_RewardsControllerNotSet when unset.
pause() / unpause() Owner only. Pausing makes maxDeposit and maxMint return 0, which closes deposit and mint through the standard ERC-4626 caps. withdraw and redeem stay open, so holders can always exit under a pause.
rescueToken(IERC20 _token, address _to, uint256 _amount) Owner only, nonReentrant. Moves any stray token except asset() (PPYieldToken_CannotRescueAsset), so backing is untouchable. The wrapper's own shares are rescuable, since moving stray shares does not change backing.
renounceOwnership() Always reverts (PPYieldToken_RenounceDisabled). Ownership transfers in two steps but can never be dropped, so fee and reward collection can never be stranded.

Errors you'll see

PPYieldToken_FeeTooHigh() Constructor or setFeeBps received a fee above MAX_FEE_BPS.
PPYieldToken_UnsupportedDecimals() The aToken has more than 18 decimals, so the fixed 18-decimal share scheme cannot represent it.
PPYieldToken_ZeroShares() A deposit or mint so small it rounds to zero shares. Deposit more, or go through PPRouter, which mints an exact share count.
PPYieldToken_ZeroAssets() A withdraw or redeem so small it rounds to zero aToken. PPRouter.withdrawToUnderlying waives a relay fee that would trip this rather than failing the whole withdrawal.
PPYieldToken_CannotRescueAsset() rescueToken was pointed at the backing aToken.
PPYieldToken_RewardsControllerNotSet() claimRewards called before setRewardsController.
PPYieldToken_RenounceDisabled() Someone called renounceOwnership.

Events

FeeUpdated(uint256 oldFeeBps, uint256 newFeeBps) Emitted by setFeeBps.
FeesClaimed(address recipient, uint256 amount) Emitted by claimFees, including when the claimed amount is zero.
RewardsControllerUpdated(address previous, address next) Emitted by setRewardsController.
TokenRescued(address token, address to, uint256 amount) Emitted by rescueToken.

Plus the standard ERC-4626 Deposit and Withdraw events and the ERC-20 Transfer.

Trust model

The owner is a trusted operator with a bounded surface: it can set the fee (capped at 20%, never retroactive), claim fees that have already accrued on yield, choose the rewards controller, rescue stray tokens other than the backing, and pause new deposits. None of those powers reach depositor principal. The wrapper is immutable (no proxy), so the only way to change its code is to deploy a new wrapper through the factory and re-point the routers and relayer configuration at it; notes are not bound to a router, so existing positions are unaffected.

Aave v3 is a trusted external protocol. The wrapper assumes its aToken is a standard, hook-free token whose balance grows monotonically under normal accrual, which is why the factory only wraps aTokens of one trusted Aave pool.

Source: v2-monorepo/packages/contracts/src/contracts/PPYieldToken.sol