You could have built: Pendle
Pendle (pendle.finance (opens in a new tab)) is a protocol that enables interest rate trading on crypto assets, converting variable yield into fixed yield, and leveraged airdrop farming. It is basically the crypto equivalent of STRIPS (opens in a new tab) (a US utility for trading bonds), it has almost $4 $6 $3 billion in total value locked, and you could have built it yourself.
In this post we’ll make it from scratch, focusing just on Staked Ether (stETH) and its wrapped form, Wrapped Staked Ether (wstETH). The resulting code should be taken as instructive, not as production-ready or complete or bug-free.
We’ll go through three steps:
- Understand the mechanism
- Put that mechanism into code
- Demonstrate how that creates use-cases like fixed yield, leveraged airdrop farming, and interest rate speculation.
1. Splitting Assets into Principal + Yield
The core insight of Pendle is that you can split yield bearing tokens, such as staked Ether, Yearn vault tokens, Compound’s cTokens, LP tokens and more, into two components:
- Principal, aka the deposit you leave in the protocol while you’re earning yield
- Yield, everything you get back above and beyond the principal when you eventually redeem, withdraw, or unwrap.
We’ll formalize this concept and turn it into code, using wstETH as an example. wstETH is a “vault” or “wrapped” version of staked Ether. When you wrap 1 stETH, you get back 1 wstETH, which accumulates staking yields over time so you could redeem it for ≥1 stETH in the future.
To start, we’ll make a few assumptions:
- Principal and Yield have “maturity dates” (here called expiry dates). How much would you pay for the staking yield generated by 1 wstETH? Depends on how long it’s accumulating! Here we are talking about the yield generated until the expiry date.
- We only care about the wstETH use-case, and will hold off on the abstractions needed to make this work everywhere for simplicity. I also won’t include things that take a lot of boiler plate without much instructive value, like initializing contracts or factory contracts.
2. Putting Principal and Yield Tokens into Code
We know we’re splitting one token (wstETH) into two tokens, PrincipalToken (PT) and YieldToken (YT). We’ll start by making some utils so we can access those from each of those tokens.
// a utility class with variables we will want to access.
// we intentionally will not focus on initializing this. use your imagination
abstract contract YieldUtils {
PrincipalToken public PT;
YieldToken public YT;
address public constant wstETH = 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0;
address public constant stETH = 0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84;
uint256 expiryTime;
function isExpired() public view returns (bool) {
return (block.timestamp > expiryTime);
}
// amount of stETH for 1 wstETH
function exchangeRate() public view returns (uint256) {
return IWStETH(wstETH).stEthPerToken();
}
}
Let’s set up our Principal and Yield tokens and allow people to mint 1 PrincipalToken and 1 YieldToken from 1 wstETH:
contract PrincipalToken is ERC20, YieldUtils {
// 1 wstETH = 1 PrincipalToken + 1 YieldToken
function mintPrincipalAndYield(uint256 amount) public {
IERC20(wstETH).transferFrom(msg.sender, address(this), amount);
_mint(msg.sender, amount);
YT.mint(msg.sender, amount);
}
}
contract YieldToken is ERC20, YieldUtils {
function mint(address to, uint256 amount) public {
require(msg.sender == address(PT), "can only be called by PT");
_mint(to, amount);
}
}
Now that they can be separated. Let’s make it so that people can claim their PT after the date of expiry.
contract PrincipalToken is ERC20, YieldUtils {
// 1 wstETH = 1 Principal Entitlement for 1stETH...
function mintPrincipalAndYield(uint256 amount) {
IERC20(wstETH).transferFrom(msg.sender, address(this), amount);
_mint(msg.sender, amount);
YT.mint(msg.sender, amount);
}
// redeem your stETH principal back (but no yield!)
function redeemAtExpiry() public {
require(isExpired(), "cant redeem until expiry");
// x wstETH * exchangeRate() = y stETH
uint256 amountStEthOwed = balanceOf(msg.sender);
uint256 amountToTransfer = amountStEthOwed / exchangeRate();
// for simplicity, transfer the stETH equivalent back in wstETH
IERC20(wstETH).transferFrom(address(this), msg.sender, amountToTransfer);
_burn(msg.sender, amountToTransfer);
}
}
Alright! We’re already done making PrincipalTokens: When you deposit 1 stETH’s worth of wstETH, you get 1 Principal Token, which (per the above) can be redeemed for 1 stETH once things expire.
Now, we’ll let people claim yield from their YieldToken. To state the obvious, yield token holders should get more yield if they owned the token for longer. If you held a yield token for a fraction of a second, you probably didn’t accumulate much yield. We will formalize this by keeping track of how long people hold their YieldTokens, and will calculate their yield entitlement based on how the yield generated while they held the YieldToken.
To keep track of that state, we need to keep track of how much yield we already had when someone got their Yield Token. And then we need to give them the delta if they get rid of their yield token, or if it’s time for them to claim their yield.
contract YieldToken is ERC20, YieldUtils {
struct UserInterest {
uint256 amountInterestOwed;
uint256 lastCalculatedBlocktime;
uint256 lastExchangeRate;
}
mapping(address => UserInterest) userInterestOwed;
function mint(address to, uint256 amount) external {
require(msg.sender == address(PT), "can only be called by PT");
if (userInterestOwed[msg.sender].lastCalculatedBlocktime == 0) {
userInterestOwed[msg.sender] = UserInterest({
amountInterestOwed: 0,
lastCalculatedBlocktime: block.timestamp,
lastExchangeRate: exchangeRate()
});
}
_mint(to, amount);
}
// sync the amount of interest owed before we
// change anyone's YT balance (_update hook is built-in in OpenZeppelin)
function _update(address from, address to, uint256 amount) internal virtual override {
if (from != address(0) && from != address(this)) _updateInterestOwed(from);
if (to != address(0) && to != address(this)) _updateInterestOwed(to);
super._update(from, to, amount)
}
function _updateInterestOwed(address who) internal {
UserInterest storage u = userInterestOwed[who];
// see chart. yield has grown by exchangeRate() - u.lastExchangeRate
uint256 newAmountOwed = (exchangeRate() - u.lastExchangeRate) * balanceOf(user);
u.lastExchangeRate = exchangeRate();
u.lastCalculatedBlocktime = block.timestamp;
u.amountInterestOwed += newAmountOwed;
}
}
That’s about it. Now we can build a claimYield
function to let people claim their yield (bottom of following file).
contract YieldToken is ERC20, YieldUtils {
struct UserInterest {
uint256 amountInterestOwed;
uint256 lastCalculatedBlocktime;
uint256 lastExchangeRate;
}
mapping(address => uint256) userInterestOwed;
function mint(address to, uint256 amount) external {
require(msg.sender == PT, "can only be called by PT");
if (userInterestOwed.lastCalculatedBlocktime == 0) {
userInterestOwed = UserInterest({
amountInterestOwed: 0,
lastCalculatedBlocktime = block.timestamp,
lastExchangeRate = exchangeRate();
})
}
_mint(to, amount);
}
function claimYield() external {
_updateInterestOwed(msg.sender);
UserInterest storage u = userInterestOwed[msg.sender];
uint256 amountStETHOwed = u.amountInterestOwed;
uint256 amountWstEthOwed = amountStETHOwed / exchangeRate();
IERC20(wstETH).transferFrom(address(this), msg.sender, amountWstEthOwed);
u.amountInterestOwed = 0;
}
// sync the amount of interest owed before we
// change anyone's YT balance
function _update(address from, address to, uint256 amount) internal virtual override {
if (from != address(0) && from != address(this)) _updateInterestOwed(from);
if (to != address(0) && to != address(this)) _updateInterestOwed(to);
super._update(from, to, amount);
}
function _updateInterestOwed(address user) internal {
UserInterest storage u = userInterestOwed[user];
// see chart. yield has grown by exchangeRate() - u.lastExchangeRate
uint256 newAmountOwed = (exchangeRate() - u.lastExchangeRate) * balanceOf(user);
u.lastExchangeRate = exchangeRate();
u.lastCalculatedBlocktime = block.timestamp;
u.amountInterestOwed += newAmountOwed;
}
}
That’s it! We:
- Accept yield-bearing tokens (here, wstETH, which is a yield-bearing form or “bond” for stETH)
- Mint two tokens:
- PrincipalToken, which lets you claim 1 stETH at the end of a maturity period.
- YieldToken, which lets you claim the interest/yield generated by 1 stETH over the maturity period.
- To account for YieldTokens switching hands over the course of that period, we keep track of how long each user held how much token.
Here’s how this lets you lock in a fixed yield… and leverage-farm airdrops
Since PrincipalTokens and YieldTokens are normal tokens, they can be independently bought/sold on any exchange at whatever price the market lands on. It is this fact — that there are separate market rates for just the yield and just the principal — plus the fact that you can always create a new PrincipalToken and YieldToken from the original wstETH token using the code above, that creates a number of valuable use-cases. Let’s explore with an example.
If traders believe that 1 wstETH will yield 0.2 stETH over the course of a year, they’d be willing to pay just under 0.2 stETH for 1 YieldToken.
If you want fixed yield, you can take advantage of that! Suppose you have 1 stETH now. You can split it into one Principal Token and one Yield Token. The principal token will be worth 1 stETH in a year as we showed, and you can sell your Yield Token to the traders above for just under 0.2 stETH. Boom — that’s fixed yield. Instead of a variable yield (”whatever stETH yield I get each day”) you have a fixed yield (0.2 stETH from the trader, and I still get my principal back)
For leveraged airdrop farming… suppose that traders thought that holding stETH for a year wasn’t just going to yield more stETH, but would also get them a fat new airdrop (which is a form of yield!) valued at $10k. Now they might be willing to pay 3 stETH for 1 yield token, so that they can get the airdrop that will get paid to holders during that time. It’s cheaper for them to only buy the yield if they don’t really care about the principal. And if you have the principal, you could lock in a more favorable rate.
Conclusions and extensions
I hope this demystified how Pendle uses financial engineering to allow interest rate trading and enable trading fixed ↔ variable yields.
There are plenty of extensions that aren’t listed here but could be good exercises:
- Supporting arbitrary yield-bearing tokens. wstETH is a fairly simple example, but you could imagine applying this to Yearn Vaults, lending receipts (eg cTokens), LP tokens, and more. You’d need to create a generic interface for a “Yield Bearing Token”, which is exactly what the Pendle team did (opens in a new tab).
- Redeeming early. In the example above, we allow Principal and Yield tokens to be created at any time, but redeemed only after expiry. But sometimes you might want to get back your wstETH before then (so you can gamble it away on memecoins instead). To enable this, we could add a function that lets people burn one PrincipalToken + one YieldToken to get back a wstETH.
- Airdropped tokens as yield. Our example assumes that all yields are denominated in stETH, but with airdrops, yields could come in other tokens (eg USDe yields $ENA token; Uni LP tokens yield both pool tokens). We could extend the
userInterest
logic to keep track of rewards in other tokens in addition
Thanks to darkkcyan (opens in a new tab) and Long Vuong / unclegrandpa925 (opens in a new tab) from Pendle, mil0x (opens in a new tab) from Yearn, and Zak Salmon (opens in a new tab) from Skill Issue for their valuable feedback on this writeup.
The full code from the above can be found on github (opens in a new tab)
For discussion, find me on Twitter (opens in a new tab)
jacob frantzRSS