Yield Tokenization Smart Contracts
Overview
This guide explains how Pendle tokenizes yield by splitting assets into PT (Principal Token) and YT (Yield Token), and how the YT contract handles minting and redeeming, index accounting, and the distribution of interest and rewards. This documentation is for developers and partners who want a deep dive into the Pendle yield mechanism and how it works under the hood.
Key Concepts
Yield tokenization takes a yield-bearing asset, then splits that value into two claims with a fixed expiry:
- PT (Principal Token): represents the principal of the underlying yield-bearing token.
- YT (Yield Token): represents entitlement to all yield, rewards, and points of the asset until expiry.
Example: A user stakes 100 USDe in Ethena and, via Pendle, tokenizes it into 100 PT-USDe and 100 YT-USDe with a 3-month expiry. They can sell the YT-USDe to someone who wants the next three months of yield and points while keeping the PT-USDe to redeem the principal at maturity; assuming a 12% APY (~3% over three months), the position would accrue about 3 USDe - so at expiry the YT-USDe holder is entitled to ~3 USDe of accrued yield (plus any program points earned during that period), and the PT-USDe holder redeems the 100 USDe principal.
Technical Details
The Pendle yield-tokenization architecture comprises three core components:
- Standardized Yield (SY): A unified wrapper interface for yield-bearing assets; underlying yield and rewards accrue to the SY contract.
- YT contract: The core logic that splits SY into PT and YT, maintains index accounting, and accrues/distributes yield and rewards.
- PT contract: The ERC-20 representing principal; PT is minted/burned by the YT contract and is redeemable for principal at/after maturity.

Before splitting, yield-bearing assets are wrapped into SY. To tokenize yield, users deposit SY into the YT contract, which mints PT and YT. The YT contract tracks yield and rewards accrued to the SY and distributes them to YT holders. At maturity, PT holders can redeem their principal from the YT contract.
Core Logic
mintPY
/**
* @notice Tokenize SY into PT + YT of equal qty. Every unit of asset of SY will create 1 PT + 1 YT
* @dev SY must be transferred to this contract prior to calling
*/
function mintPY(address receiverPT, address receiverYT) external returns (uint256 amountPYOut);
Purpose: Mints equal amounts of PT and YT by depositing SY into the YT contract.
How it works:
- The YT contract mints using its current SY balance. Therefore, you must transfer SY into the YT contract before calling the function. The amount of PT and YT minted is calculated as:
- The YT contract mints equal quantities of PT and YT to the specified recipient addresses.
Example:
- If
1 SY-sUSDe = 1.2 USDe(PY index = 1.2) and a user deposits100 SY-sUSDe, the contract mints120 PT-sUSDeand120 YT-sUSDe. - Since
100 SY-sUSDecorresponds to120 USDeof underlying value, the user receives120 PT-sUSDe(principal exposure) and120 YT-sUSDe(pre-expiry yield claim).
redeemPY
/**
* @notice converts PT(+YT) tokens into SY, but interests & rewards are not redeemed at the
* same time
* @dev PT/YT must be transferred to this contract prior to calling
*/
function redeemPY(address receiver) external returns (uint256 amountSyOut);
Purpose: Redeem SY by burning PT and YT. Think of this as converting back to the principal accounting unit, while interest and rewards are claimed separately.
How it works:
- You have to provide equal amounts of PT and YT to the YT contract before calling the function.
- The contract burns the tokens and returns SY according to the current PY index:
- The redeemed SY is sent to the specified receiver. Note that interest and rewards accrued to YT are not included in this redemption.
Notes:
- Pre-expiry: both PT and YT are required to redeem SY.
- Post-expiry: only PT is required (YT has no value after maturity).
Example:
Pre-expiry
- Continuing the prior example: if the user holds
120 PT-sUSDeand120 YT-sUSDe, and now the PY index is1.25, thenSY_out = 120 / 1.25 = 96 SY-sUSDe(which corresponds to 120 USDe). Interest and rewards accrued to YT are not included in this redemption.
Post-expiry
- After maturity, with
120 PT-sUSDeand PY index1.25,SY_out = 120 / 1.25 = 96 SY-sUSDe(again equal to 120 USDe). The user receives96 SY-sUSDe, which can be unwrapped or swapped back to the underlying 120 USDe principal.
redeemDueInterestAndRewards
/**
* @notice Redeems interests and rewards for `user`
* @param redeemInterest will only transfer out interest for user if true
* @param redeemRewards will only transfer out rewards for user if true
* @dev With YT yielding interest in the form of SY, which is redeemable by users, the reward
* distribution should be based on the amount of SYs that their YT currently represent, plus
* their dueInterest. It has been proven and tested that _rewardSharesUser will not change over
* time, unless users redeem their dueInterest or redeemPY. Due to this, it is required to
* update users' accruedReward STRICTLY BEFORE transferring out their interest.
*/
function redeemDueInterestAndRewards(
address user,
bool redeemInterest,
bool redeemRewards
) external returns (uint256 interestOut, uint256[] memory rewardsOut);
Purpose: Allows a YT holder to claim accrued earnings: interest (in SY) and any external reward tokens. Interest for YT is always paid in SY, but it can be swapped into your preferred token through the router.
Behavior notes:
-
Interest unit: Always SY. If you want the underlying/base asset, unwrap or swap through the router.
-
Pre- vs post-expiry:
- Pre-expiry: interest and rewards continue accruing; this function pays whatever is due up to the call.
- Post-expiry: YT no longer earns new yield. Calling still pays any remaining pre-expiry interest/rewards, if any.
-
Zero-flag calls: If both flags are
false, the call reverts withYCNothingToRedeem. At least one flag must betrue. -
Token order:
rewardsOut[i]corresponds togetRewardTokens()[i]. Always read the list first.
Examples:
- Claim both:
User has accrued
2.5 SYof interest and[10 X, 0.3 Y]rewards. Calling with(true, true)returns(2.5, [10, 0.3]), transfers those amounts, and resets baselines. - Claim rewards only:
Calling
(false, true)transfers only rewards. Due interest remains in SY terms and continues to count toward reward-share until it's eventually claimed or the user redeems PY.
mintPYMulti
/**
* @notice Tokenize SY into PT + YT for multiple receivers in a single transaction.
* @dev SY must be transferred to this contract prior to calling.
* The sum of `amountSyToMints` must not exceed the floating SY balance.
*/
function mintPYMulti(
address[] calldata receiverPTs,
address[] calldata receiverYTs,
uint256[] calldata amountSyToMints
) external returns (uint256[] memory amountPYOuts);
Purpose: Batch version of mintPY. Mints PT and YT for multiple receivers in a single transaction, saving gas when distributing to many addresses.
How it works:
- Transfer the total required SY to the YT contract before calling.
- Pass parallel arrays: each
(receiverPTs[i], receiverYTs[i])pair receivesamountPYOuts[i]PT and YT minted fromamountSyToMints[i]SY. - The sum of
amountSyToMintsmust equal the floating SY balance (total SY transferred in).
redeemPYMulti
/**
* @notice Redeem PT (+YT) for multiple users in a single transaction.
* @dev PT/YT must be transferred to this contract prior to calling.
*/
function redeemPYMulti(
address[] calldata receivers,
uint256[] calldata amountPYToRedeems
) external returns (uint256[] memory amountSyOuts);
Purpose: Batch version of redeemPY. Redeems PT (and YT pre-expiry) for multiple users in one transaction.
How it works:
- Transfer the total required PT (and YT, if pre-expiry) to the YT contract before calling.
- Each
receivers[i]receives SY from redeemingamountPYToRedeems[i]PY.
pyIndexCurrent
/**
* @notice updates and returns the current PY index
* @dev this function maximizes the current PY index with the previous index, guaranteeing
* non-decreasing PY index
* @dev if `doCacheIndexSameBlock` is true, PY index only updates at most once per block,
* and has no state changes on the second call onwards (within the same block).
* @dev see `pyIndexStored()` for view function for cached value.
*/
function pyIndexCurrent() external returns (uint256 currentIndex);
-
Purpose: Returns the current PY index, updating it if needed. The PY index tracks the SY exchange rate and is stored monotonically (never decreases).
-
Behavior notes:
-
The PY index is non-decreasing:
pyIndexCurrent = max(SY.exchangeRate(), pyIndexStored). -
If
doCacheIndexSameBlockis enabled, the index is updated at most once per block; subsequent calls in the same block are read-only (no further state changes). -
If
SY.exchangeRate()falls below the stored index (negative yield), the PY index does not move down. Consequences:- Pre-expiry redemptions return less SY per PY until
SY.exchangeRate()recovers above the stored index. - YT accrual effectively pauses (no new interest) until recovery.
- In sustained drawdowns, even PT's eventual redemption (valued in the accounting asset) can be less than previously expected because the SY backing has shrunk. See Negative Yield.
- Pre-expiry redemptions return less SY per PY until
-
-
Examples:
- Up move: Last stored
SY.exchangeRate()=1.20; it rises to1.25. CallingpyIndexCurrent()updates the PY index to1.25. - Down move: Last stored index =
1.20;SY.exchangeRate()drops to1.15. The PY index stays at1.20. If a user minted 120 PT when the index was1.20, their claim on SY is120 / 1.20 = 100 SY. At maturity, if each SY equals1.15 USDe, they redeem100 × 1.15 = 115 USDe(less than 120 USDe), reflecting the underlying negative yield.
- Up move: Last stored
setPostExpiryData
/**
* @notice Triggers the post-expiry data initialization if the market has expired.
* @dev Has no effect if called pre-expiry.
*/
function setPostExpiryData() external;
Purpose: Manually triggers the post-expiry settlement. Normally this is called automatically on the first interaction after expiry (via the updateData modifier), but this can be called explicitly to ensure the post-expiry index is snapshotted before further redemptions.
pyIndexStored
/// @notice returns the last-updated PY index (view function, no state changes)
function pyIndexStored() external view returns (uint256);
Purpose: Returns the last cached PY index without updating state. Use this for read-only queries when you don't need the latest value. For the current (possibly updated) value, use pyIndexCurrent().
getPostExpiryData
/**
* @notice Returns the post-expiry data snapshot.
* @dev Reverts if post-expiry data has not been set yet (see `setPostExpiryData()`).
*/
function getPostExpiryData()
external
view
returns (
uint256 firstPYIndex,
uint256 totalSyInterestForTreasury,
uint256[] memory firstRewardIndexes,
uint256[] memory userRewardOwed
);
Purpose: Returns the post-expiry data snapshot. Useful for understanding what index was locked in at expiry and verifying post-expiry redemption amounts.
Returns:
firstPYIndex: The PY index snapshotted at expiry. PT redemptions post-expiry use this index.totalSyInterestForTreasury: Accumulated SY interest accrued after expiry (goes to treasury, not users).firstRewardIndexes: Per-reward-token indexes snapshotted at expiry.userRewardOwed: Total unclaimed user reward balances at time of snapshot.
Integration Example
The snippets below are simplified for illustration and are not audited. Do not use them in production or with real funds. If you adapt any example, conduct a full review, add comprehensive tests, and obtain an independent security audit.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
IPYieldContractFactory factory; // PendleYieldContractFactoryUpg
address SY;
uint32 expiry;
address receiver;
// --- Step 1: Look up or create a PT/YT pair ---
// Check if the pair already exists
address PT = factory.getPT(SY, expiry);
address YT = factory.getYT(SY, expiry);
if (PT == address(0)) {
// Create a new yield contract if it doesn't exist yet
(PT, YT) = factory.createYieldContract(SY, expiry, true);
}
IPYieldToken yt = IPYieldToken(YT);
IStandardizedYield sy = IStandardizedYield(SY);
IPrincipalToken pt = IPrincipalToken(PT);
// --- Step 2: Mint PT + YT by depositing SY ---
IERC20(address(sy)).transfer(address(yt), 100e18); // deposit 100 SY
uint256 amountPYOut = yt.mintPY(receiver, receiver); // receive PT + YT
// --- Step 3a: Redeem SY by burning PT + YT (pre-expiry) ---
IERC20(address(pt)).transfer(address(yt), amountPYOut); // send PT
IERC20(address(yt)).transfer(address(yt), amountPYOut); // send YT
uint256 amountSyOut = yt.redeemPY(receiver); // receive SY
// --- Step 3b: Redeem SY by burning PT only (post-expiry) ---
// After expiry, YT has no remaining value; only PT is required.
IERC20(address(pt)).transfer(address(yt), amountPYOut); // send PT only
uint256 amountSyOut = yt.redeemPY(receiver); // receive SY
// --- Step 4: Claim accrued interest (in SY) and rewards ---
(uint256 interestOut, uint256[] memory rewardsOut) = yt.redeemDueInterestAndRewards(
receiver,
true, // claim interest
true // claim rewards
);
FAQ
When the underlying asset's exchange rate increases, does Pendle buy more of the asset on the market and distribute it to YT holders?
No. Pendle's accounting is index-based: yield accrues inside the SY balance held by the contracts as exchangeRate rises. YT holders are entitled to the yield portion of that existing SY collateral (paid in SY), while PT holders claim principal at/after maturity; users can then unwrap or swap SY to the base asset if they wish. No open-market purchases are required.
Is 1 SY always equal to 1 PT + 1 YT?
No. PT is a principal claim in units of the accounting asset at maturity, whereas SY is a wrapper whose value floats with exchangeRate; YT represents the pre-expiry yield claim. The amounts of PT and YT you mint depend on the current index - they collectively replicate the economic exposure of the underlying, but 1 SY ≠ 1 PT + 1 YT except in edge cases (e.g., exchangeRate == 1).