# PPYieldToken contract

> The non-rebasing ERC-4626 wrapper that turns an Aave aToken into a pool asset (aUSDC to ppUSDC): fixed 18-decimal shares, a live exchange rate, and a protocol fee charged on yield only.

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](/concepts/notes) stays fixed while the exchange rate from shares to the aToken rises as Aave interest accrues. [Yield shares](/concepts/yield-shares) explains why that shape matters for privacy; [PPRouter](/protocol/contracts/pp-router) 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`](/protocol/contracts/yield-factories), 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](/protocol/contracts/pp-router), 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](/protocol/contracts/yield-factories) only wraps aTokens of one trusted Aave pool.

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