Pendle Finance — #2 SY Token, Yield Contract Implementation

Louis Noh
23 min readMay 21, 2024

--

이번 글에서는 SY Token, Yield 컨트랙트의 구현체를 살펴봄으로써, SY Token (EIP-5115)와 SY 토큰을 어떻게 PT와 YT로 토큰을 분리하는지, 그리고 PT 토큰이 어떻게 다시 SY 토큰으로 변환되는지 확인하도록 한다.

Backgrounds

ERC-5115 [1]

ERC-5115는 Pendle 팀에서 제안한 SY 토큰 표준으로, 이는 수익을 생성하는 자산($stETH, $aUSDC, $GLP, $LOOKS)에 대한 입출금 및 잔액확인 기능을 ERC-20에 추가하였다. ERC-4626의 기능도 포함하며, AMM 유동성 토큰 및 보상 토큰과 같은 다양한 수익 생성 매커니즘을 지원한다.

ERC-4626은 특정 vault 기반의 수익성 자산에 초점을 맞춘다. 즉, ERC-4626은 특정 vault에 대한 자산 하나에 대해서만 예치, 인출을 지원한다. 하지만, ERC-5115는 다양한 수익성 자산을 받아 SY 토큰으로 래핑함으로써, 동일한 인터페이스를 사용할 수 있도록 한다.

SY Token의 주요기능

이번 절에서는 SY Token의 주요기능인 deposit(), redeem(), exchagneRate()을 살펴보면서, SY Token이 유동성 풀과 어떻게 상호작용하는지 살펴보도록 하겠다.

deposit()

function deposit(
address receiver,
address tokenIn,
uint256 amountTokenToDeposit,
uint256 minSharesOut
) external payable nonReentrant returns (uint256 amountSharesOut) {
if (!isValidTokenIn(tokenIn)) revert Errors.SYInvalidTokenIn(tokenIn);
if (amountTokenToDeposit == 0) revert Errors.SYZeroDeposit();

_transferIn(tokenIn, msg.sender, amountTokenToDeposit);

amountSharesOut = _deposit(tokenIn, amountTokenToDeposit);
if (amountSharesOut < minSharesOut) revert Errors.SYInsufficientSharesOut(amountSharesOut, minSharesOut);

_mint(receiver, amountSharesOut);
emit Deposit(msg.sender, receiver, tokenIn, amountTokenToDeposit, amountSharesOut);
}

deposit함수는 특정 토큰을 받아, 발행하고자 하는 SY Token의 양을 계산후 계산된 양 만큼 토큰을 발행하는 함수이다. 먼저, 예치하고자하는 토큰의 주소와 예치할 양에 대해서 검증을 한다. 검증을 통과하고 나면, 얼마만큼의 SY Token을 발행할 건지 _deposit 함수에서 계산한다. _deposit함수는 override되는 함수로, 각각 SY Token마다 구현이 다르다. 가령, PendleArbitrumStakedEthSY 컨트랙트는 deposit 입력으로 받은 양을 그대로 발행하는가 반면에, sAPE 컨트랙트는 vault와 같이 shares를 계산하여 shares만큼을 반환한다.

//PendleArbitrumStakedEthSY.sol
function _deposit(address, uint256 amountDeposited) internal pure override returns (uint256 /*amountSharesOut*/) {
return amountDeposited;
}

//sAPE.sol
function _deposit(address, uint256 amountDeposited) internal virtual override returns (uint256 amountSharesOut) {
// Respecting APE's deposit invariant
if (amountDeposited < MIN_APE_DEPOSIT) {
revert Errors.SYApeDepositAmountTooSmall(amountDeposited);
}

_harvestAndCompound();

// As SY Base is pulling the tokenIn first, the totalAsset should exclude user's deposit
if (totalSupply() == 0) {
amountSharesOut = amountDeposited - MINIMUM_LIQUIDITY;
_mint(address(1), MINIMUM_LIQUIDITY);
} else {
uint256 priorTotalAssetOwned = getTotalAssetOwned() - amountDeposited;
amountSharesOut = (amountDeposited * totalSupply()) / priorTotalAssetOwned;
}
}

발행하는 토큰의 양을 계산하는 방식이 다른 이유는 underlying token의 특성을 고려하기 때문이다. underlying token이 이미 shares를 포함하는 경우에는 SY Token을 1:1로 발행하는 것이 적절하나, 그렇지 않은 경우에는 sAPE 처럼 shares를 따로 계산하여, 발행하는 것이 SY Token의 가치를 보전하기에 좋을 수 있다.

redeem()

function redeem(
address receiver,
uint256 amountSharesToRedeem,
address tokenOut,
uint256 minTokenOut,
bool burnFromInternalBalance
) external nonReentrant returns (uint256 amountTokenOut) {
if (!isValidTokenOut(tokenOut)) revert Errors.SYInvalidTokenOut(tokenOut);
if (amountSharesToRedeem == 0) revert Errors.SYZeroRedeem();

if (burnFromInternalBalance) {
_burn(address(this), amountSharesToRedeem);
} else {
_burn(msg.sender, amountSharesToRedeem);
}

amountTokenOut = _redeem(receiver, tokenOut, amountSharesToRedeem);
if (amountTokenOut < minTokenOut) revert Errors.SYInsufficientTokenOut(amountTokenOut, minTokenOut);
emit Redeem(msg.sender, receiver, tokenOut, amountSharesToRedeem, amountTokenOut);
}

redeem()함수는 SY Token을 받아 소각하고, 원래의 underlying token을 가져갈 수 있도록 하는 함수이다. 한가지 재미있는 점은 burnFromInternalBalance에 따라, 두가지 모드 방식으로 redeem()을 할 수 있다. burnFromInternalBalance이 true가 되면, 사용자가 컨트랙트에 SY Token을 보내고, redeem을 요청하는 방식이고, false를 하게 되면 사용자의 주소에 직접 burn을 하는 방식이다. 위에서 설명한 바와 마찬가지로, 받는 underlying token의 양은 SY Token 마다 다르다.

exchangeRate()

exchangeRate함수는 1 SY Token이 underlying token으로 교환될 때의 교환비를 반환한다. 이 역시 SY Token 별로 값을 반환하는 방법이 다르다. PendleAaveV3SY 컨트랙트는aavePool에서 underlying token에서 NormalizedIncome을 가져온다. PendleL2RTSY 컨트랙트는 설정해둔 exchange oracle에서 비율을 가져온다. PendleArbitrumStakedEthSY는 체인링크 피드에서 값을 가져오는 방식을 채택했다.

//PendleAaveV3SY
function exchangeRate() public view virtual override returns (uint256) {
return _getNormalizedIncome() / 1e9;
}

function _getNormalizedIncome() internal view returns (uint256) {
return IAaveV3Pool(aavePool).getReserveNormalizedIncome(underlying);
}

//PendleL2RTSY
function exchangeRate() public view override returns (uint256) {
return IPExchangeRateOracle(exchangeRateOracle).getExchangeRate();
}

//PendleArbitrumStakedEthSY
function exchangeRate() public view override returns (uint256) {
return IChainlinkAggregator(chainlinkFeed).latestAnswer().Uint();
}

Reward가 있는 SY Token [2]

이번 절에서는 reward가 있는 SY Token이 reward를 어떻게 분배하는지에 대해 살펴보도록 하겠다. [2]에 따르면, 리워드는 t시점과 t* 시점(t > t*) 사이에 발생한 리워드에 대해 shares만큼 유저에게 돌아가는 구조이다. 사용자는 clainReward를 호출함으로써, 자신에게 할당된 reward를 수령할 수 있다.

//SYBaseWithRewards
function claimRewards(
address user
) external virtual override nonReentrant whenNotPaused returns (uint256[] memory rewardAmounts) {
_updateAndDistributeRewards(user);
rewardAmounts = _doTransferOutRewards(user, user);

emit ClaimRewards(user, _getRewardTokens(), rewardAmounts);
}

//RewardManagerAbstract
function _updateAndDistributeRewards(address user) internal virtual {
_updateAndDistributeRewardsForTwo(user, address(0));
}

function _updateAndDistributeRewardsForTwo(address user1, address user2) internal virtual {
(address[] memory tokens, uint256[] memory indexes) = _updateRewardIndex();
if (tokens.length == 0) return;

if (user1 != address(0) && user1 != address(this)) _distributeRewardsPrivate(user1, tokens, indexes);
if (user2 != address(0) && user2 != address(this)) _distributeRewardsPrivate(user2, tokens, indexes);
}

// should only be callable from `_updateAndDistributeRewardsForTwo` to guarantee user != address(0) && user != address(this)
function _distributeRewardsPrivate(address user, address[] memory tokens, uint256[] memory indexes) private {
assert(user != address(0) && user != address(this));

uint256 userShares = _rewardSharesUser(user);

for (uint256 i = 0; i < tokens.length; ++i) {
address token = tokens[i];
uint256 index = indexes[i];
uint256 userIndex = userReward[token][user].index;

if (userIndex == 0) {
userIndex = INITIAL_REWARD_INDEX.Uint128();
}

if (userIndex == index) continue;

uint256 deltaIndex = index - userIndex;
uint256 rewardDelta = userShares.mulDown(deltaIndex);
uint256 rewardAccrued = userReward[token][user].accrued + rewardDelta;

userReward[token][user] = UserReward({index: index.Uint128(), accrued: rewardAccrued.Uint128()});
}
}

claimRewards는 처음에 이전 t*에서 현재 t시점까지 누적된 reward의 값을 _updateAndDistributeRewards를 호출하여 업데이트한다. 함수 내부에서_distributeRewardsPrivate을 호출하게 되는데, 위의 함수는 특정 리워드 토큰 i에 리워드의 양을 누적된 양을 가져와 마지막으로 claim된 사용자의 리워드를 빼서 delatIndex를 계산한다. 이 값을 사용자의 지분만큼 곱하여, 사용자에게 줄 reward를 계산한다(rewardDelta). 마지막으로, 사용자의 리워드의 상태값을 업데이트한다.

function _doTransferOutRewards(
address user,
address receiver
) internal virtual override returns (uint256[] memory rewardAmounts) {
address[] memory tokens = _getRewardTokens();
rewardAmounts = new uint256[](tokens.length);
for (uint256 i = 0; i < tokens.length; i++) {
rewardAmounts[i] = userReward[tokens[i]][user].accrued;
if (rewardAmounts[i] != 0) {
userReward[tokens[i]][user].accrued = 0;
rewardState[tokens[i]].lastBalance -= rewardAmounts[i].Uint128();
_transferOut(tokens[i], receiver, rewardAmounts[i]);
}
}
}

다음으로 _doTransferOutRewards()를 호출하는데, 이는 이전에 업데이트된 사용자의 리워드를 가져와 사용자에게 reward를 전송한다. 구현체를 보면 알수 있지만, reward 토큰은 하나 이상이 될 수 있고, 이는 사용자의 shares만큼 분배된다는 것을 알 수 있다.

Yield Contract

PendleYieldTokenV2는 유동화된 수익을 관리하고 분배하는 역할을 수행한다. 이 컨트랙트는 사용자가 특정 자산 (SY)를 예치하여 PT Token과 YT Token을 발행하고, 만기 이후에는 다시 SY로 교환할수 있도록 한다.

주요 변수

constructor(
address _SY,
address _PT,
string memory _name,
string memory _symbol,
uint8 __decimals,
uint256 _expiry,
bool _doCacheIndexSameBlock
) PendleERC20(_name, _symbol, __decimals) {
SY = _SY;
PT = _PT;
expiry = _expiry;
factory = msg.sender;
doCacheIndexSameBlock = _doCacheIndexSameBlock;
}

Yield Contract가 배포될때는 SY Token의 주소, PT Token의 주소, 그리고 만기일을 받는다. _doCacheIndexSameBlock도 생성자로 받는데, 이는 현재 블록에서 캐시된 _pyIndexStored를 사용할지 말지에 대한 것을 결정하는 변수이다. 해당 변수는 PT를 발행할때 사용되는 변수로, PY 인덱스를 사용하여, 발행할 양을 계산한다.

PT, YT 발행

function mintPY(
address receiverPT,
address receiverYT
) external nonReentrant notExpired updateData returns (uint256 amountPYOut) {
address[] memory receiverPTs = new address[](1);
address[] memory receiverYTs = new address[](1);
uint256[] memory amountSyToMints = new uint256[](1);

(receiverPTs[0], receiverYTs[0], amountSyToMints[0]) = (receiverPT, receiverYT, _getFloatingSyAmount());

uint256[] memory amountPYOuts = _mintPY(receiverPTs, receiverYTs, amountSyToMints);
amountPYOut = amountPYOuts[0];
}

function _getFloatingSyAmount() internal view returns (uint256 amount) {
amount = _selfBalance(SY) - syReserve;
if (amount == 0) revert Errors.YCNoFloatingSy();
}

PY를 발행하는 함수는 위와 같다. PT와 YT를 수령할 주소를 입력으로 받고, 컨트랙트가 가지고 있는 SY 토큰의 양을 가져온다. 이때, syReserve 값을 빼게되는데, syReserve값은 만기일까지 쌓인 리워드와 이자를 treasury가 받을 수 있도록 하는 함수인 redeemInterestAndRewardsPostExpiryForTreasury가 호출될 때 업데이트 된다. 또한, 컨트랙트가 가지고 잇는 SY 잔액이 변경될 때 업데이트 된다. 이는 _updateSyReserve를 통해 이루어진다. 즉, 마지막에 SY 잔액이 업데이트 될때와 현재의 SY 잔액의 차를 amountSyToMints에 저장한다.

function _mintPY(
address[] memory receiverPTs,
address[] memory receiverYTs,
uint256[] memory amountSyToMints
) internal returns (uint256[] memory amountPYOuts) {
amountPYOuts = new uint256[](amountSyToMints.length);

uint256 index = _pyIndexCurrent();

for (uint256 i = 0; i < amountSyToMints.length; i++) {
amountPYOuts[i] = _calcPYToMint(amountSyToMints[i], index);

_mint(receiverYTs[i], amountPYOuts[i]);
IPPrincipalToken(PT).mintByYT(receiverPTs[i], amountPYOuts[i]);

emit Mint(msg.sender, receiverPTs[i], receiverYTs[i], amountSyToMints[i], amountPYOuts[i]);
}
}

function _pyIndexCurrent() internal returns (uint256 currentIndex) {
if (doCacheIndexSameBlock && pyIndexLastUpdatedBlock == block.number) return _pyIndexStored;

uint128 index128 = PMath.max(IStandardizedYield(SY).exchangeRate(), _pyIndexStored).Uint128();

currentIndex = index128;
_pyIndexStored = index128;
pyIndexLastUpdatedBlock = uint128(block.number);
}

_mintPY에서 본격적으로 PT토큰과 YT 토큰을 발행한다. 발행 프로세스는 아래와 같다. 이때, 입력으로 받은 SY 토큰을 교환비율을 적용하여, 발행할 PT와 YT 토큰 수량을 결정한다.

  1. index 계산(SY 토큰의 교환비율을 가져옴)
  2. index를 기반으로 발행할 PY 토큰 계산
  3. 같은 수량의 YT와 PT 토큰 발행

PT, YT 소각

function redeemPY(address receiver) external nonReentrant updateData returns (uint256 amountSyOut) {
address[] memory receivers = new address[](1);
uint256[] memory amounts = new uint256[](1);
(receivers[0], amounts[0]) = (receiver, _getAmountPYToRedeem());

uint256[] memory amountSyOuts;
amountSyOuts = _redeemPY(receivers, amounts);

amountSyOut = amountSyOuts[0];
}

function _getAmountPYToRedeem() internal view returns (uint256) {
if (!isExpired()) return PMath.min(_selfBalance(PT), balanceOf(address(this)));
else return _selfBalance(PT);
}

PT, YT는 redeemPY를 통해서 자산을 수령할 수 있다. SY 토큰으로 변환된다. 우선 _getAmountPYToRedeem함수를 통해서 우선 만기일이 지났는지 아닌지 판단한다. 만기일이 지나지 않은 경우 YT 컨트랙트가 가지고 있는 PT와 컨트랙트의 YT를 비교하여, 작은 값을 리턴하고, 만기일이 지난 경우에는 컨트랙트가 가지고 있는 PT 값을 반환한다.

function _redeemPY(
address[] memory receivers,
uint256[] memory amountPYToRedeems
) internal returns (uint256[] memory amountSyOuts) {
uint256 totalAmountPYToRedeem = amountPYToRedeems.sum();
IPPrincipalToken(PT).burnByYT(address(this), totalAmountPYToRedeem);
if (!isExpired()) _burn(address(this), totalAmountPYToRedeem);

uint256 index = _pyIndexCurrent();
uint256 totalSyInterestPostExpiry;
amountSyOuts = new uint256[](receivers.length);

for (uint256 i = 0; i < receivers.length; i++) {
uint256 syInterestPostExpiry;
(amountSyOuts[i], syInterestPostExpiry) = _calcSyRedeemableFromPY(amountPYToRedeems[i], index);
_transferOut(SY, receivers[i], amountSyOuts[i]);
totalSyInterestPostExpiry += syInterestPostExpiry;

emit Burn(msg.sender, receivers[i], amountPYToRedeems[i], amountSyOuts[i]);
}

address treasury = IPYieldContractFactory(factory).treasury();
_transferOut(SY, treasury, totalSyInterestPostExpiry);
}

function _calcSyRedeemableFromPY(
uint256 amountPY,
uint256 indexCurrent
) internal view returns (uint256 syToUser, uint256 syInterestPostExpiry) {
syToUser = SYUtils.assetToSy(indexCurrent, amountPY);
if (isExpired()) {
uint256 totalSyRedeemable = SYUtils.assetToSy(postExpiry.firstPYIndex, amountPY);
syInterestPostExpiry = totalSyRedeemable - syToUser;
}
}

반환될 PT(혹은 YT) 토큰의 양만큼 PT를 소각하고, 만기일이 지난 경우에는 YT 토큰도 소각한다. 다음으로 교환비율을 가져와 변환될 SY 토큰의 수량을 계산한다. 만기일이 지나게 되면, totalSyRedeemablesyInterestPostExpiry을 계산하여 반환한다. 다음으로 SY 토큰을 전송하고, treasury로 수수료를 전송하여 마무리한다.

Reference

  1. https://eips.ethereum.org/EIPS/eip-5115
  2. V. Nguyen, and L. Vuong, “Standardize Yield — A token standard for yield generating mechanism,” Pendle, Oct. 14, 2022.

--

--