You could have built: Pendle

Jacob Frantz,crypto

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:

  1. Understand the mechanism
  2. Put that mechanism into code
  3. 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:

  1. Principal, aka the deposit you leave in the protocol while you’re earning yield
  2. Yield, everything you get back above and beyond the principal when you eventually redeem, withdraw, or unwrap.
alt
Splitting a yield-bearing asset into Principal Token (PT) and Yield Token (YT). From Pendle’s docs. You can ignore “SY” for this writeup.

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:

  1. 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.
  2. 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.

YieldUtils.sol
// 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.

PrincipalToken.sol
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.

alt
If you don’t hold yield for the whole time, you should only get the yield accumulated while you held the Yield Token.

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.

YieldToken.sol
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).

YieldToken.sol
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:

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:

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