Platform: Code4rena
Start Date: 10/11/2023
Pot Size: $28,000 USDC
Total HM: 5
Participants: 185
Period: 5 days
Judge: 0xDjango
Id: 305
League: ETH
Rank: 76/185
Findings: 1
Award: $36.03
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: T1MOH
Also found by: 0x1337, 0xNaN, 0xepley, 0xluckhu, 0xmystery, 7siech, Aamir, AlexCzm, Aymen0909, DanielArmstrong, GREY-HAWK-REACH, HChang26, Jiamin, Juntao, QiuhaoLi, Ruhum, SBSecurity, Varun_05, Weed0607, adam-idarrha, adriro, ast3ros, ayden, circlelooper, crack-the-kelp, crunch, cryptothemex, deepplus, mahdirostami, max10afternoon, osmanozdemir1, rouhsamad, rvierdiiev, trachev, xAriextz, zhaojie
36.0335 USDC - $36.03
https://github.com/code-423n4/2023-11-kelp/blob/main/src/LRTOracle.sol#L52 https://github.com/code-423n4/2023-11-kelp/blob/main/src/LRTDepositPool.sol#L119 https://github.com/code-423n4/2023-11-kelp/blob/main/src/LRTDepositPool.sol#L79
Users can receive significantly fewer rsETH than they should when depositing assets into a non-empty pool.
This happens because the user's funds are transferred to the LRTDepositPool
before the rsETH is calculated taking into account the transferred funds and thus diluting the value of rsETH.
The more funds a user transfers in relation to the existing asset balance, the higher the dilution for the user thus punishing the most enthusiastic and trusting users of the protocol.
The depositAsset
function first transfers the assets to the LRTDepositPool
contract before calculating the amount of rsETH to mint -
function depositAsset( address asset, uint256 depositAmount ) external whenNotPaused nonReentrant onlySupportedAsset(asset) { // checks if (depositAmount == 0) { revert InvalidAmount(); } if (depositAmount > getAssetCurrentLimit(asset)) { revert MaximumDepositLimitReached(); } if (!IERC20(asset).transferFrom(msg.sender, address(this), depositAmount)) { revert TokenTransferFailed(); } // interactions uint256 rsethAmountMinted = _mintRsETH(asset, depositAmount); emit AssetDeposit(asset, depositAmount, rsethAmountMinted); }
The amount of rsETH to mint is calculated as the amount
x assetPrice
/ rsETHPrice
-
function getRsETHAmountToMint( address asset, uint256 amount ) public view override returns (uint256 rsethAmountToMint) { // setup oracle contract address lrtOracleAddress = lrtConfig.getContract(LRTConstants.LRT_ORACLE); ILRTOracle lrtOracle = ILRTOracle(lrtOracleAddress); // calculate rseth amount to mint based on asset amount and asset exchange rate rsethAmountToMint = (amount * lrtOracle.getAssetPrice(asset)) / lrtOracle.getRSETHPrice(); }
The rsETH price is calculated as essentially totalETHInPool / rsEthSupply
-
function getRSETHPrice() external view returns (uint256 rsETHPrice) { address rsETHTokenAddress = lrtConfig.rsETH(); uint256 rsEthSupply = IRSETH(rsETHTokenAddress).totalSupply(); if (rsEthSupply == 0) { return 1 ether; } uint256 totalETHInPool; address lrtDepositPoolAddr = lrtConfig.getContract(LRTConstants.LRT_DEPOSIT_POOL); address[] memory supportedAssets = lrtConfig.getSupportedAssetList(); uint256 supportedAssetCount = supportedAssets.length; for (uint16 asset_idx; asset_idx < supportedAssetCount;) { address asset = supportedAssets[asset_idx]; uint256 assetER = getAssetPrice(asset); uint256 totalAssetAmt = ILRTDepositPool(lrtDepositPoolAddr).getTotalAssetDeposits(asset); totalETHInPool += totalAssetAmt * assetER; unchecked { ++asset_idx; } } return totalETHInPool / rsEthSupply; }
When calculating the total amount of ETH, the oracle calls back into the pool to total the assets including the current balance which now includes the user's funds that have already been transferred to the pool thus inflating the total ETH in pool numerator resulting in a higher rsETH price and thus a lower amount of rsETH when calculating the mint amount.
function getAssetDistributionData(address asset) public view override onlySupportedAsset(asset) returns (uint256 assetLyingInDepositPool, uint256 assetLyingInNDCs, uint256 assetStakedInEigenLayer) { // Question: is here the right place to have this? Could it be in LRTConfig? assetLyingInDepositPool = IERC20(asset).balanceOf(address(this)); uint256 ndcsCount = nodeDelegatorQueue.length; for (uint256 i; i < ndcsCount;) { assetLyingInNDCs += IERC20(asset).balanceOf(nodeDelegatorQueue[i]); assetStakedInEigenLayer += INodeDelegator(nodeDelegatorQueue[i]).getAssetBalance(asset); unchecked { ++i; } } }
Here is a unit test showing the issue -
function testWrongPriceCalculation() external { uint supplyBefore = rseth.totalSupply(); uint priceBefore = lrtOracle.getRSETHPrice(); uint balanceBefore = rseth.balanceOf(alice); assertEq(supplyBefore, 0); assertEq(priceBefore, 1 ether); assertEq(balanceBefore, 0); // alice deposit 1 eth first time vm.startPrank(alice); rETH.approve(address(lrtDepositPool), 1 ether); lrtDepositPool.depositAsset(address(rETH), 1 ether); vm.stopPrank(); assertEq(rseth.totalSupply(), 1 ether); assertEq(lrtOracle.getRSETHPrice(), 1 ether); assertEq(rseth.balanceOf(alice), 1 ether); // alice deposit another 1 eth vm.startPrank(alice); rETH.approve(address(lrtDepositPool), 1 ether); lrtDepositPool.depositAsset(address(rETH), 1 ether); vm.stopPrank(); assertLt(rseth.totalSupply(), 2 ether, "total rsETH supply"); assertGt(lrtOracle.getRSETHPrice(), 1 ether, "rsETH price"); assertLt(rseth.balanceOf(alice), 2 ether, "alice rsETH balance"); }
Link to fully functioning POC - https://gist.github.com/alpeware/c3f2e38ab258863ae27df88a1fa29dd3#file-depositpool-invariants-t-sol-L288
One simple option is to flip the order and first mint and then transfer the asset -
function depositAsset( address asset, uint256 depositAmount ) external whenNotPaused nonReentrant onlySupportedAsset(asset) { // checks if (depositAmount == 0) { revert InvalidAmount(); } if (depositAmount > getAssetCurrentLimit(asset)) { revert MaximumDepositLimitReached(); } // interactions uint256 rsethAmountMinted = _mintRsETH(asset, depositAmount); if (!IERC20(asset).transferFrom(msg.sender, address(this), depositAmount)) { revert TokenTransferFailed(); } emit AssetDeposit(asset, depositAmount, rsethAmountMinted); }
Math
#0 - c4-pre-sort
2023-11-16T04:59:09Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-11-16T04:59:18Z
raymondfam marked the issue as duplicate of #62
#2 - c4-judge
2023-11-29T21:21:47Z
fatherGoose1 marked the issue as satisfactory
#3 - c4-judge
2023-12-01T19:00:05Z
fatherGoose1 changed the severity to 2 (Med Risk)
#4 - c4-judge
2023-12-04T15:31:41Z
fatherGoose1 changed the severity to 3 (High Risk)