Continuing from the previous post, this article examines the rewards earned by Lenders. In Part 1, it was mentioned that LoopFi features positions not only for borrowers, but also for Lenders who supply WETH or ETH, as well as for dLP Lockers who hold the governance token dLP.
Since no contract appears to have been deployed to handle rewards for the dLP Locker position, it is presumed that rewards for them are not yet active. Therefore, this article will focus solely on the rewards earned by Lenders.
Pool Overview
Lenders provide WETH or ETH to the pool and receive lpETH as a reward. Although it’s called a “pool”, it is not a typical swap pool but rather more akin to a vault that holds WETH. Pool V3 is a Vault that follows ERC4626 standard, with WETH as its asset and lpETH as its share. However, when examining the function that calculates the asset-to-share value, unlike typical ERC4626 vaults where the asset value can fluctuate, the ratio between asset and share is fixed at 1:1.
What is an ERC4626 Vault?
It is a vault standard in which depositing an asset yields a share as a reward. Previous implementations of supply-reward vaults were inconsistent, so this unified standard was proposed.
Asset values can change over time, and when withdrawing assets, users receive an amount that corresponds to both the current asset value and their share. In other words, users may end up receiving either less or more than their original deposit, depending on asset value fluctuations.
In ERC4626, the functions _convertToShares and _convertToAssets are used to calculate asset and share values. PoolV3 also includes functions with the same names, but since they do not override those in ERC4626, using ERC4626's convertToShares would invoke the parent’s internal function, potentially leading to asset values being displayed differently than expected. Therefore, in PoolV3, instead of using convertToShares during share issuance and redemption, the two functions below are called directly.
function _convertToShares(uint256 assets) internal pure returns (uint256 shares) {
// uint256 supply = totalSupply();
return assets; //(assets == 0 || supply == 0) ? assets : assets.mulDiv(supply, totalAssets(), rounding);
}
function _convertToAssets(uint256 shares) internal pure returns (uint256 assets) {
//uint256 supply = totalSupply();
return shares; //(supply == 0) ? shares : shares.mulDiv(totalAssets(), supply, rounding);
}
Solidity
복사
2024-10-loopfi/src/PoolV3.sol
Lender Reward Distribution: mint lpETH
Lenders can either receive a share corresponding to the amount of WETH they supply to the pool or specify the exact number of shares they wish to obtain, thereby providing the necessary liquidity. However, since the asset-to-share ratio is fixed at 1:1, the amount of WETH required to obtain a given share remains the same regardless of the approach.
The most common method—issuing shares in proportion to the amount supplied—is executed through the deposit, depositWithReferral, or depositETH functions. The first two functions are used when a Lender directly supplies WETH, while depositETH is employed when a Lender supplies ETH that must be converted to WETH by the pool.
function depositETH(address receiver)
public
payable
whenNotPaused // U:[LP-2A]
nonReentrant // U:[LP-2B]
nonZeroAddress(receiver) // U:[LP-5]
returns (uint256 shares)
{
//...
// Convert ETH to WETH
WETH.deposit{value: msg.value}();
// Calculate the amount of underlying received after the fee
uint256 assetsReceived = _amountMinusFee(msg.value); // U:[LP-6]
shares = _convertToShares(assetsReceived); // U:[LP-6]
// The weth is already in the contract, so we can directly register the deposit
_registerDeposit(receiver, msg.value, assetsReceived, shares); // U:[LP-6]
}
Solidity
복사
2024-10-loopfi/src/PoolV3.sol : depositETH
When a user supplies WETH or ETH, the _deposit function is called to issue shares, and _mint mints lpETH to the provider in an amount equal to the shares. In the case of supplying ETH, as shown in the code above, _registerDeposit is called instead of _deposit because ETH has already been sent via msg.value, so the safeTransferFrom for obtaining WETH is omitted; otherwise, it functions identically to _deposit.
Generating Yield with lpETH
Lenders can generate additional yields with lpETH. LoopFi offers two options:
1.
Deposit lpETH into the StakingLPEth contract for a set period to receive a portion of pool earnings.
2.
Lock lpETH in the Locking contract for a designated lock-up period to earn points.
Since merely holding lpETH can’t create
Since merely holding lpETH does not yield any tangible returns, lenders must choose one of the two methods above. The points system was updated after the contest; therefore, this discussion focuses on generating returns through StakingLPEth, which was fully operational at the time of the contest.
Earnings Distribution via StakingLPEth
StakingLPEth is an ERC4626-compliant vault where the asset is lpETH and the share is slpETH. When users deposit lpETH, they receive slpETH as a share. Unlike the fixed 1:1 asset-to-share ratio seen elsewhere, this ratio in StakingLPEth fluctuates based on the vault’s profitability. The deposited lpETH is locked in the Silo for a period defined by cooldownDuration. During this lock-up period—if activated (i.e., when cooldownDuration is not 0)—the ERC4626 functions withdraw and redeem are disabled, preventing immediate asset withdrawal.
modifier ensureCooldownOff() {
if (cooldownDuration != 0) revert OperationNotAllowed();
_;
}
// ...
function withdraw(uint256 assets, address receiver, address _owner)
>> public virtual override ensureCooldownOff returns (uint256) {
return super.withdraw(assets, receiver, _owner);
}
function redeem(uint256 shares, address receiver, address _owner)
>> public virtual override ensureCooldownOff returns (uint256) {
return super.redeem(shares, receiver, _owner);
}
Solidity
복사
2024-10-loopfi/src/StakingLPEth.sol
So how does the share value in StakingLPEth increase?
The earnings of StakingLPEth come from the pool's revenue, which is periodically distributed by the Treasury. The Treasury is configured to allocate 80% of the pool's revenue to StakingLPEth, and these earnings are sent to the vault in the form of lpETH through the release function. As a result, as the pool is utilized and lpETH accumulates within StakingLPEth, the value of slpETH(the shares) increases.
// Treasury inherits PaymentSplitter
function release(address payable account) public virtual {
require(_shares[account] > 0, "PaymentSplitter: account has no shares");
uint256 payment = releasable(account);
require(payment != 0, "PaymentSplitter: account is not due payment");
// _totalReleased is the sum of all values in _released.
// If "_totalReleased += payment" does not overflow, then "_released[account] += payment" cannot overflow.
_totalReleased += payment;
unchecked {
_released[account] += payment;
}
>> Address.sendValue(account, payment);
emit PaymentReleased(account, payment);
}
Solidity
복사
2024-10-loopfi/lib/openzeppelin-contracts/contracts/finance/PaymentSplitter.sol
When the share value increases, users who lock assets in the Silo can end up depositing a larger amount than their original assets or burning fewer shares, ultimately profiting.
There are two methods for asset lockup:
1.
cooldonAssets : The user specifies an asset quantity to lock; the corresponding share amount is calculated to be burned, and the asset is then locked into the Silo. If the pool revenue has been settled and the totalAsset has increased, the share value rises, meaning that fewer shares need to be burned.
2.
cooldownShares: The user specifies a share quantity to be burned, and the corresponding asset quantity is calculated and then locked in the Silo. Similarly, if the pool revenue has been settled, the same number of shares now represents a greater amount of asset being locked in the Silo.
The quantity of assets locked by the user in the Silo, along with the lockup end time, is stored in the cooldowns mapping. This mapping keeps track of the total assets locked by the user regardless of time. If a user calls one of the cooldown* functions while already having assets that have passed the lockup period (and thus unlocked), the new asset amount will be added to the existing locked amount, effectively renewing the lockup.
function cooldownAssets(uint256 assets) external ensureCooldownOn returns (uint256 shares) {
if (assets > maxWithdraw(msg.sender)) revert ExcessiveWithdrawAmount();
>> shares = previewWithdraw(assets);
cooldowns[msg.sender].cooldownEnd = uint104(block.timestamp) + cooldownDuration;
cooldowns[msg.sender].underlyingAmount += uint152(assets);
>> _withdraw(msg.sender, address(silo), msg.sender, assets, shares);
}
function cooldownShares(uint256 shares) external ensureCooldownOn returns (uint256 assets) {
if (shares > maxRedeem(msg.sender)) revert ExcessiveRedeemAmount();
>> assets = previewRedeem(shares);
cooldowns[msg.sender].cooldownEnd = uint104(block.timestamp) + cooldownDuration;
cooldowns[msg.sender].underlyingAmount += uint152(assets);
>> _withdraw(msg.sender, address(silo), msg.sender, assets, shares);
}
Solidity
복사
2024-10-loopfi/src/StakingLPEth.sol
After the cooldownDuration expires, the Lender can finally call unstake to retrieve the lpETH stored in the Silo. Partial withdrawals are not allowed; the entire stored amount must be withdrawn at once. Notably, the unstake function does not recalculate the share value at the time of withdrawal, meaning the user’s profit is determined by the share value at the time the asset was locked into the Silo.
function unstake(address receiver) external {
UserCooldown storage userCooldown = cooldowns[msg.sender];
uint256 assets = userCooldown.underlyingAmount;
if (block.timestamp >= userCooldown.cooldownEnd || cooldownDuration == 0) {
userCooldown.cooldownEnd = 0;
userCooldown.underlyingAmount = 0;
silo.withdraw(receiver, assets);
} else {
revert InvalidCooldown();
}
}
Solidity
복사
2024-10-loopfi/src/StakingLPEth.sol
TL;DR
Lenders can earn profits via StakingLPEth by following these steps.
•
Supply WETH to the pool and receive lpETH, deposit lpETH into the StakingLPEth.
•
When the Treasury distributes pool earnings, the share value increases. At that point, user stake their lpETH to the Silo via cooldownAssets or cooldownShares —resulting in either more assets being locked or fewer shares being burned.
•
Withdraw lpETH after 7days(cooldownDuration) via unstake.
As a result, users end up with a higher amount of lpETH, which they can either use further or exchange for ETH in the pool.
conclusion
While inheriting ERC4626, care must be taken not to confuse functions when both parent and child contracts have functions with the same name, if the child functions are defined separately without overriding those of the parent. In PoolV3, only the separately defined child contract functions are used, so no particular issues have arisen.
Furthermore, since this reward mechanism depends on the Treasury’s distribution of pool earnings, the Vault and Pool must accurately and error‑free calculate pool earnings and losses for the system to work as designed. Without proper distribution of pool earnings, StakingLPEth would be nothing more than a token storage facility without any interest.
Having covered the core logic up to this point, the next article will review the vulnerabilities that actually occurred.