(This post is a collaboration with Andrew Hong, follow his Twitter account so that you won’t miss his upcoming analysis on Pendle protocol.)
Pendle is a protocol that enables the trading of tokenized future yield on an AMM system. It builds on top of first-degree DeFi protocols including Aave and Compound. DeFi users who hold yield-bearing tokens from Aave and Compound can deposit them into Pendle, which in return mints ownership tokens (OT) and yield tokens (YT). Yield tokens can be immediately sold for USDC in Pendle’s AMM pools, or they can be added to the AMM pools so that token holders can earn trading fees and other incentives (stake LP token, earn $PENDLE). The value of a yield token decays as it gets closer to the expiry because claimable yield drops with time. There is an expiry because YT buyers are buying the right to access yield without owning the underlying asset. If there is no expiry, that means YT buyers have access to the yield perpetually by only paying a fixed price. There isn’t a way on the blockchain to enforce the buyers to make regular payments to the yield-bearing asset holders and hence an expiry is necessary.
People who will use Pendle fall into 3 groups.
The sellers are people who own yield-bearing tokens, predict that the current yield will drop and want to lock in the current yield. It is done so through minting OTs and YTs and then selling the YTs. The sellers can redeem their underlying assets upon expiry with their OT tokens.
The buyers are people who want to gain yield exposure in a capital-efficient way. Buying YT tokens to gain yield exposure means the buyers do not have to own the underlying assets. Buyers who predict the yield will go up can buy YTs at a discounted price. With Pendle being capital-efficient, will allow the buyers to gain more yield exposure over owning the underlying tokens.
Arbitrageurs who find an arbitrage opportunity between 2 lending protocols can borrow from the protocol with a lower borrowing rate, then deposits the borrowed assets into the protocol with a higher lending rate to receive yield-bearing tokens. Pendle will then accept the yield-bearing tokens and the arbitrageurs can sell the YTs right away. Assuming the YTs can be sold at a higher value than the arbitrageurs’ borrowed assets from the first protocol, the arbitrageurs will make a profit.
The flow
Sellers mint OTs and YTs by depositing their yield-bearing assets into Pendle and selecting the desired expiry date.
Sellers sell YTs for USDC in Pendle’s AMM pools, or
Sellers add YTs (single or double sided) to the AMM pool to earn trading fees or stake PendleLP to earn $PENDLE.
YT buyers can claim the yield generated with their newly acquired YTs.
Sellers who still have the OTs redeem their underlying assets after the contract expires. The underlying assets can also be redeemed before contract expiry if the redeemer has an equal amount of OTs and YTs.
Sellers who do not want to redeem their underlying assets can renew their expired contracts.
The code
The protocol is mainly orchestrated by the contract PendleRouter.sol and it is controlled by the governance manager. The router does not hold any funds and the data is stored in PendleData.sol. We will walk through the lifecycle of an Aave USDC yield contract that expires on June 20, 2021.
Creating a new yield contract
Creating a yield contract is the first thing to do before any other steps can be taken. The creator can do this by calling PendleRouter#newYieldContracts
. It accepts 3 arguments:
forge ID - a forge is the contract that the router redirects calls to, there is a forge for every integration (
PendleCompoundForge
,PendleAaveV2Forge
).underlying asset - it accepts any address since validation is not possible
expiry - contract expiry, it has to be divisible by
expiryDivisor
to avoid creating too many contracts (default value is 1 day)
The forge must exist, otherwise the call cannot move forward. A forge can only be added to the datastore by governance.
IPendleForge forge = IPendleForge(data.getForgeAddress(_forgeId));
require(address(forge) != address(0), "FORGE_NOT_EXISTS");
The OT and YT contracts must not have been created already.
ot = address(data.otTokens(_forgeId, _underlyingAsset, _expiry));
xyt = address(data.xytTokens(_forgeId, _underlyingAsset, _expiry));
require(ot == address(0) && xyt == address(0), "DUPLICATE_YIELD_CONTRACT");
The router goes ahead and creates a new yield contract.
(ot, xyt) = forge.newYieldContracts(_underlyingAsset, _expiry);
Inside the forge, it deploys the OT and YT contracts. The tickers are OT-aUSDC-20June2021
and YT-aUSDC-20June2021
.
// Token class is PendleOwnershipToken or PendleFutureYieldToken.
address(
new Token(
address(forge.router()),
address(forge),
_underlyingAsset,
forge.getYieldBearingToken(_underlyingAsset),
_name,
_symbol,
_decimals,
block.timestamp,
_expiry
)
);
A token holder (PendleAaveV2YieldTokenHolder
) will also be deployed. The contract holds the yield and reward tokens. It has an interface that allows claiming rewards.
yieldTokenHolders[_underlyingAsset][_expiry] = yieldContractDeployer.deployYieldTokenHolder(yieldToken, _expiry);
Finally, the data is stored in PendleData
. There is a mapping that keeps track of the deployed token contract addresses with their associated forge ID, underlying asset and expiry.
data.storeTokens(forgeId, ot, xyt, _underlyingAsset, _expiry);
Tokenizing yield tokens
If there is an existing yield contract for the underlying asset and the expiry in the forge already, anyone who wants to enter into this contractual arrangement can call PendleRouter#tokenizeYield
.
The function checks the contract already exists.
require(data.isValidXYT(_forgeId, _underlyingAsset, _expiry), "INVALID_YT");
It transfers the underlying asset to the token holder contract and then mints OT and YT for the user.
(ot, xyt, amountTokenMinted) = forge.mintOtAndXyt(
_underlyingAsset,
_expiry,
_amountToTokenize,
_to
);
Creating a market
A market has to be first created for a YT token to be tradable against USDC. It’s done by calling PendleRouter#createMarket
.
It uses the PendleMarketFactory
to add a market. The market factory + forge pair has to be valid. It is determined by governance.
IPendleMarketFactory factory = IPendleMarketFactory(data.getMarketFactoryAddress(_marketFactoryId));
require(address(factory) != address(0), "ZERO_ADDRESS");
bytes32 forgeId = IPendleForge(IPendleYieldToken(_xyt).forge()).forgeId();
require(data.validForgeFactoryPair(forgeId, _marketFactoryId), "INVALID_FORGE_FACTORY");
The factory creates a PendleAaveMarket
and it is added to the datastore.
market = address(new PendleAaveMarket(_governanceManager, _xyt, _token));
data.addMarket(marketFactoryId, _xyt, _token, market);
With the market being created, initial liquidity can be added to the market by calling PendleRouter#bootstrapMarket
. It allows the liquidity provider to set the initial price of the YT. I will not go into details as it is similar to adding liquidity in the next section. Just note that it can only be bootstrapped once and a minimum amount of liquidity (10 ** 3
) is minted to address 0x1
to prevent emptying the market.
Adding liquidity to the market (YT + USDC)
Liquidity can be added to the market by calling PendleRouter#addMarketLiquidityDual
(We can add single sided liquidity by calling addMarketLiquiditySingle
, but I will not cover it).
It works like adding liquidity to a DEX, where the function asks for the desired amounts and the minimum amounts to provide. The math behind the AMM can be found here.
A market’s reserve data, which includes YT balance (bit 148-255), USDC balance (bit 40-147), YT weight (bit 0-39) and USDC weight (1 - YT weight) are stored in a single uint256 reserveData
to save gas.
xytBalance = (reserveData & MASK_148_TO_255) >> 148;
tokenBalance = (reserveData & MASK_40_TO_147) >> 40;
xytWeight = reserveData & MASK_0_TO_39;
tokenWeight = Math.RONE - xytWeight;
If there is a protocol swap fee and if the constant k
is nonzero, the market mints LP tokens to the treasury based on the difference between the current k
and the last k
. It is done in _mintProtocolFees
.
The function then calculates the amount of LP tokens to mint by checking the amount of USDC to use (This is based on the contract’s calculation and not provided by the user).
uint256 amountTokenUsed = _desiredXytAmount.mul(tokenBalance).div(xytBalance);
There are two scenarios. If amountTokenUsed
is not greater than the _desiredTokenAmount
(USDC), it calculates the LP tokens to mint based on the desired YT amount. Otherwise, it calculates the LP tokens to mint based on the desired USDC amount. Generally speaking, it is equal to
LP tokens to mint = desired token amount x total LP supply ÷ token balance
It always makes sure the amount of USDC used is not more than the desired amount of USDC used.
After knowing the amount of liquidity to add, it sets the pending transfers’ amounts. The pending transfers will be returned to the router so that it can settle the transfers by transferring the tokens from the liquidity provider to the contract (PendleRouter#_settlePendingTransfers
).
// totalBalance is xytBalance and tokenBalance,
// amountUsed is amountXytUsed and amountTokenUsed
totalBalance = totalBalance.add(amountUsed);
transfers[x].amount = amountUsed;
transfers[x].isOut = false;
Having all the information it needs, the reserve balances and weights are updated, k
is updated, and the LP tokens are minted.
writeReserveData(initialXytLiquidity, initialTokenLiquidity, Math.RONE / 2);
_updateLastParamK();
_mint(user, lpOut);
One thing to note is that if the router does not have sufficient allowances from the liquidity provider to transfer his liquidity, the contract automatically approves the transfer amount for him by calling PendleFutureYieldToken#approveRouter
. Only the Pendle router can call this function, so I will assume it’s safe (I have not investigated enough to tell if there can be exploits).
require(msg.sender == address(router), "NOT_ROUTER");
_approve(user, address(router), type(uint256).max);
Buying or selling YT tokens
With liquidity in the pool, YT tokens can be freely traded. It can be done by calling PendleRouter#swapExactIn
or PendleRouter#swapExactOut
(we will look at this). The function identifies the correct pool by token addresses, calculates the token amount to take from the user, updates the pool’s balances and weights, and settles the transfers by swapping tokens with the user.
The input amount has to be calculated. The formula can be found in The Math Behind Pendle’s AMM.
inAmount = MarketMath._calcExactIn(
inTokenReserve,
outTokenReserve,
outAmount,
data.swapFee()
);
The token reserve balances are updated.
inTokenReserve.balance = inTokenReserve.balance.add(inAmount);
outTokenReserve.balance = outTokenReserve.balance.sub(outAmount);
updateReserveData(inTokenReserve, inToken);
updateReserveData(outTokenReserve, outToken);
The final settlement is done by PendleRouter#_settlePendingTransfers
.
Redeeming due interests
With the newly acquired YT tokens, the YT holder can redeem any due interests by calling PendleRouter#redeemDueInterests
.
Before transferring the YT holder his due interests, it first has to update the due interests. The function gets the reserve normalized income directly from Aave and compares it with the YT holder’s last normalized income. If there are any unclaimed interests (there is a difference between the 2 numbers), it calculates the interests accrued based on the ratio between the current normalized income and the last normalized income. The ratio should always be growing if the contract has not expired.
function _updateDueInterests(
uint256 _principal,
address _underlyingAsset,
uint256 _expiry,
address _user
) internal override {
uint256 lastIncome = lastNormalisedIncome[_underlyingAsset][_expiry][_user];
uint256 normIncomeBeforeExpiry =
getReserveNormalizedIncomeBeforeExpiry(_underlyingAsset, _expiry);
// if the XYT hasn't expired, normIncomeNow = normIncomeBeforeExpiry
// else, get the current income from Aave directly
uint256 normIncomeNow =
block.timestamp > _expiry
? getReserveNormalizedIncome(_underlyingAsset)
: normIncomeBeforeExpiry;
// first time getting XYT
if (lastIncome == 0) {
lastNormalisedIncome[_underlyingAsset][_expiry][_user] = normIncomeNow;
return;
}
uint256 interestFromXyt;
if (normIncomeBeforeExpiry > lastIncome) {
interestFromXyt = _principal.mul(normIncomeBeforeExpiry).div(lastIncome).sub(
_principal
);
// the interestFromXyt has only been calculated until normIncomeBeforeExpiry
// we need to calculate the compound interest of it from normIncomeBeforeExpiry -> now
interestFromXyt = interestFromXyt.mul(normIncomeNow).div(normIncomeBeforeExpiry);
}
dueInterests[_underlyingAsset][_expiry][_user] = dueInterests[_underlyingAsset][_expiry][
_user
]
.mul(normIncomeNow)
.div(lastIncome)
.add(interestFromXyt);
lastNormalisedIncome[_underlyingAsset][_expiry][_user] = normIncomeNow;
}
The forge will also charge a fee.
uint256 forgeFee = data.forgeFee();
uint256 forgeFeeAmount;
if (forgeFee > 0) {
forgeFeeAmount = amountOut.rmul(forgeFee);
amountOut = amountOut.sub(forgeFeeAmount);
_updateForgeFee(_underlyingAsset, _expiry, forgeFeeAmount);
}
The yield token holder is the vault that holds the tokens and it is instructed to transfer the interests to the YT holder.
address yieldTokenHolder = yieldTokenHolders[_underlyingAsset][_expiry];
_amount = Math.min(_amount, _yieldToken.balanceOf(yieldTokenHolder));
_yieldToken.safeTransferFrom(yieldTokenHolder, _user, _amount);
Redeeming underlying assets (before expiry)
OT owners can redeem their underlying assets before the contract expires by calling PendleRouter#redeemUnderlying
. The function asks for _amountToRedeem
, which requires the user to have the same amount of OT and YT tokens. The forge burns the tokens, calculates the due interests, then returns the principal + due interests to the user through the token holder vault (similar to the example above).
redeemedAmount = _calcUnderlyingToRedeem(_underlyingAsset, _amountToRedeem).add(
_beforeTransferDueInterests(tokens, _underlyingAsset, _expiry, _user, true)
);
redeemedAmount = _safeTransfer(
yieldToken,
_underlyingAsset,
_expiry,
_user,
redeemedAmount
);
Redeeming underlying assets (after expiry)
OT owners can redeem their underlying assets after the contract expires by calling PendleRouter#redeemAfterExpiry
. It does not require the OT owner to also hold YTs as the contract has already expired. It burns the OT and then sends the redeemable amount back to the user. The redeemable amount is equal to the current normalized income times the expired OT amount divided by the last normalized income before expiry. Due interests will also be updated and paid.
uint256 expiredOTamount = tokens.ot.balanceOf(_user);
tokens.ot.burn(_user, expiredOTamount);
uint256 currentNormalizedIncome = getReserveNormalizedIncome(_underlyingAsset);
uint256 redeemedAmount = currentNormalizedIncome.mul(expiredOTamount).div(lastNormalisedIncomeBeforeExpiry[_underlyingAsset][_expiry]);
redeemedAmount = redeemedAmount.add(
_beforeTransferDueInterests(tokens, _underlyingAsset, _expiry, _user, false)
);
redeemedAmount = _safeTransfer(
yieldToken,
_underlyingAsset,
_expiry,
_user,
redeemedAmount
);
Renewing a yield contract
OT owners can renew their yield contracts by calling PendleRouter#renewYield
.
It redeems whatever is left in the expired contract, and then re-tokenize the underlying asset. The OT owner can decide whether to fully tokenize the underlying asset or only partially by setting _renewalRate
.
require(0 < _renewalRate, "INVALID_RENEWAL_RATE");
redeemedAmount = redeemAfterExpiry(_forgeId, _underlyingAsset, _oldExpiry);
amountRenewed = redeemedAmount.rmul(_renewalRate);
(ot, xyt, amountTokenMinted) = tokenizeYield(
_forgeId,
_underlyingAsset,
_newExpiry,
amountRenewed,
msg.sender
);
Closing thoughts
Pendle finance opens up a brand new market of future yield trading. Yield-bearing token holders have the option to lock into a fixed yield before the yield is even accrued. This is useful when the yield-bearing token holders predict a fall in yield or when they need cash upfront. YT buyers have the option to long yield by buying YT tokens in the open market. It is also beneficial for them in terms of capital efficiency as they do not need to own the underlying assets in order to earn yield.