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: 71/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
The LRTDepositPool
acts as a simplified vault allowing restakers to transfer their liquid staked tokens and receive rsETH
tokens based on the current rsETH
exchange rate. rsETH
are minted to user by interacting with depositAsset
function of LRTDepositPool
contract and sending amount
of liquid staked token (called asset
) to it. First, amount
of asset
is transferred to LRTDepositPool
, then rsethAmountToMint
is computed and same rsETH
are minted to the user. However, there is a flaw in calculation of rsethAmountToMint
due to wrong price calculation of rsETH
which results in lesser rsETH
being minted to user.
This happens due to the fact that while computing the price of rsETH
i.e price = totalETHInPool / rsEthSupply
, asset
deposited by user for minting rsETH
is also included in totalETHInPool
, whereas, corresponding rsEthSupply
has yet not been increased. This, results in biased price of rsETH
and lesser rsETH
are minted to the user for his deposited asset
.
getRsETHAmountToMint()
function of LRTDepositPool
contract returns
rsethAmountToMint = (amount * lrtOracle.getAssetPrice(asset)) / lrtOracle.getRSETHPrice();
getRSETHPrice()
function of LRTOracle
contract returns rsETHPrice
as
return totalETHInPool / rsEthSupply;
where totalETHInPool
are computed as sum of all assets
held in LRTDepositPool
, NDC contract
and eigen layer (after adjust price of each asset). Moreover, if rsEthSupply == 0
, getRSETHPrice()
returns price as 1 ether
.
Following explains the PoC working. For simplicity, assume that totalETHInPool
is computed by all asset
held in LRTDepositPool
only (leave NDC contract
and eigen layer for the moment).
totalETHInPool = 0
and rsEthSupply = 0
as no asset
has been deposited yet.depositAsset
function and transfers 2 rETH
to LRTDepositPool
rsethAmountToMint = 2 ether
as totalETHInPool = 2
and rsEthSupply = 0
(getRSETHPrice()
returns price as 1 ether
when rsEthSupply == 0
).rsETH
are minted for Alice. Updated values are totalETHInPool = 2
and rsEthSupply = 2
.depositAsset
function and transfers 99 rETH
to LRTDepositPool
rsethAmountToMint = 1.96 eth
as totalETHInPool = 101
and rsEthSupply = 2
(getRSETHPrice()
returns price as 50.5 ether
).rsETH
are minted to Bob. Updated values are totalETHInPool = 101
and rsEthSupply = 3.96
.The above scenarios shows that a user buying later and buying with amount bogger than the asset
held by kelp protcol
is going to face more biasedness in rsETH
price and will get lesser amount of rsETH
.
Tests were implemented for above scenario which produces following output (test codes given subsequently)
rsETH Price: 1000000000000000000 Alice RSeth Balance: 2000000000000000000 Pool rEth Balance: 2000000000000000000 rsETH Supply: 2000000000000000000 rsETH Price: 50500000000000000000 Bob RSeth Balance: 1960396039603960396 Pool rEth Balance: 101000000000000000000 rsETH Supply: 3960396039603960396
Use following test code in LRTDepositPoolDepositAsset
contract to test the above explanation. (also update the oracle to reflect actual RSETH_Price
rather than fixed dummy price, code given below).
function test_DepositAssetCustom() external { uint256 balrs; uint256 balr; address ad = alice; vm.startPrank(ad); rETH.approve(address(lrtDepositPool), 100 ether); lrtDepositPool.depositAsset(rETHAddress, 2 ether); balrs = rseth.balanceOf(address(ad)); balr = rETH.balanceOf(address(lrtDepositPool)); console.log(" Alice RSeth Balance: %s", balrs); console.log(" Pool rEth Balance: %s", balr); console.log(" rsETH Supply: %s", rseth.totalSupply()); vm.stopPrank(); ad = bob; vm.startPrank(ad); rETH.approve(address(lrtDepositPool), 100 ether); lrtDepositPool.depositAsset(rETHAddress, 99 ether); balrs = rseth.balanceOf(address(ad)); balr = rETH.balanceOf(address(lrtDepositPool)); console.log(" Bob RSeth Balance: %s", balrs); console.log(" Pool rEth Balance: %s", balr); console.log(" rsETH Supply: %s", rseth.totalSupply()); vm.stopPrank(); }
Update LRTOracleMock
contract as follows to compute exact value of rsETH
by computing deposited assets.
import "forge-std/console.sol"; import { RSETH } from "src/RSETH.sol"; import { ILRTConfig } from "src/interfaces/ILRTConfig.sol"; import { IRSETH } from "src/interfaces/IRSETH.sol"; contract LRTOracleMock { function getAssetPrice(address) public pure returns (uint256) { return 1e18; } ILRTConfig lrtConfig; MockToken rETH; address lrtDepositPool; function setAddress(address _lrtConfig, address _rETH, address _lrtDepositPool) external { lrtConfig = ILRTConfig(_lrtConfig); rETH = MockToken(_rETH); lrtDepositPool = _lrtDepositPool; } function getRSETHPrice() external view returns (uint256) { //return 1e18; address rsETHTokenAddress = lrtConfig.rsETH(); uint256 rsEthSupply = IRSETH(rsETHTokenAddress).totalSupply(); if (rsEthSupply == 0) { console.log("rsETH Price: %s", 1 ether); 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; } }*/ uint256 totalAssetAmt = rETH.balanceOf(lrtDepositPool); uint256 assetER = getAssetPrice(address(rETH)); totalETHInPool += totalAssetAmt * assetER; console.log("rsETH Price: %s", totalETHInPool / rsEthSupply); return totalETHInPool / rsEthSupply; } }
update setup
function of LRTDepositPoolTest
contract as follows
function setUp() public virtual override(RSETHTest, BaseTest) { super.setUp(); // deploy LRTDepositPool ProxyAdmin proxyAdmin = new ProxyAdmin(); LRTDepositPool contractImpl = new LRTDepositPool(); TransparentUpgradeableProxy contractProxy = new TransparentUpgradeableProxy( address(contractImpl), address(proxyAdmin), "" ); lrtDepositPool = LRTDepositPool(address(contractProxy)); // initialize RSETH. LRTCOnfig is already initialized in RSETHTest rseth.initialize(address(admin), address(lrtConfig)); vm.startPrank(admin); // add rsETH to LRT config lrtConfig.setRSETH(address(rseth)); // add oracle to LRT config LRTOracleMock oracle = new LRTOracleMock(); lrtConfig.setContract(LRTConstants.LRT_ORACLE, address(oracle)); oracle.setAddress(address(lrtConfig), address(rETH), address(lrtDepositPool)); // add minter role for rseth to lrtDepositPool rseth.grantRole(rseth.MINTER_ROLE(), address(lrtDepositPool)); vm.stopPrank(); }
Manual Review and Forge Test
Do not include the asset
transferred by user in rsETH
price computation for calculating the rsethAmountToMint
value. This can be easily avoided by first computing rsethAmountToMint
and then asking user to transfer the asset
. For example, rewrite line 136-141 as follows :-
// interactions uint256 rsethAmountMinted = _mintRsETH(asset, depositAmount); if (!IERC20(asset).transferFrom(msg.sender, address(this), depositAmount)) { revert TokenTransferFailed(); }
Math
#0 - c4-pre-sort
2023-11-16T23:54:35Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-11-16T23:54:43Z
raymondfam marked the issue as duplicate of #62
#2 - c4-judge
2023-11-29T21:26:19Z
fatherGoose1 marked the issue as satisfactory
#3 - c4-judge
2023-12-01T19:02:09Z
fatherGoose1 changed the severity to 2 (Med Risk)
#4 - c4-judge
2023-12-04T15:31:42Z
fatherGoose1 changed the severity to 3 (High Risk)