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: 44/185
Findings: 4
Award: $101.04
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: crack-the-kelp
Also found by: 0xDING99YA, 0xffchain, Aymen0909, Bauchibred, DanielArmstrong, Pechenite, Stormy, T1MOH, ZanyBonzy, ast3ros, bLnk, chaduke, crack-the-kelp, deepkin, deth, jasonxiale, jayfromthe13th, lsaudit, nmirchev8, osmanozdemir1, roleengineer, tallo, zhaojie
76.0163 USDC - $76.02
https://github.com/code-423n4/2023-11-kelp/blob/f751d7594051c0766c7ecd1e68daeb0661e43ee3/src/LRTDepositPool.sol#L202 https://github.com/code-423n4/2023-11-kelp/blob/f751d7594051c0766c7ecd1e68daeb0661e43ee3/src/NodeDelegator.sol#L121 https://github.com/code-423n4/2023-11-kelp/blob/f751d7594051c0766c7ecd1e68daeb0661e43ee3/src/NodeDelegator.sol#L94
Previous strategy is overwritten and can cause tokens to be lost forever.
When the asset strategy is updated, it doesn't transfer the current strategy asset balance to the new one.
function updateAssetStrategy( address asset, address strategy ) external onlyRole(DEFAULT_ADMIN_ROLE) onlySupportedAsset(asset) { UtilLib.checkNonZeroAddress(strategy); if (assetStrategy[asset] == strategy) { revert ValueAlreadyInUse(); } assetStrategy[asset] = strategy; }
This futher translates to the getAssetBalance
and getAssetBalances
functions which inturn affects the returned value of the getTotalAssetDeposits
and affects the rsETH
pricing. See my other issues for more info.
function getAssetBalance(address asset) external view override returns (uint256) { address strategy = lrtConfig.assetStrategy(asset); return IStrategy(strategy).userUnderlyingView(address(this)); //@note checks the for the exact strategy. }
Manual code review
Use safeTransferFrom to send the asset balance to the new strategy.
Other
#0 - c4-pre-sort
2023-11-16T07:51:38Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-11-16T07:51:49Z
raymondfam marked the issue as duplicate of #197
#2 - c4-judge
2023-12-01T17:24:55Z
fatherGoose1 marked the issue as unsatisfactory: Invalid
#3 - c4-judge
2023-12-08T17:25:58Z
fatherGoose1 marked the issue as satisfactory
🌟 Selected for report: m_Rassska
Also found by: 0x1337, 0xAadi, 0xHelium, 0xLeveler, 0xblackskull, 0xbrett8571, 0xepley, 0xffchain, 0xluckhu, 0xmystery, 0xrugpull_detector, 0xvj, ABAIKUNANBAEV, Aamir, AerialRaider, Amithuddar, Bauchibred, Bauer, CatsSecurity, Cryptor, Daniel526, Draiakoo, Eigenvectors, ElCid, GREY-HAWK-REACH, Inspecktor, Juntao, King_, LinKenji, Madalad, MaslarovK, Matin, MatricksDeCoder, McToady, Noro, PENGUN, Pechenite, Phantasmagoria, RaoulSchaffranek, SBSecurity, SandNallani, Shaheen, Soul22, Stormreckson, T1MOH, Tadev, TeamSS, TheSchnilch, Topmark, Tumelo_Crypto, Udsen, Yanchuan, ZanyBonzy, _thanos1, adeolu, adriro, alexfilippov314, almurhasan, amaechieth, anarcheuz, ayden, baice, bareli, boredpukar, bronze_pickaxe, btk, cartlex_, catellatech, chaduke, cheatc0d3, circlelooper, codynhat, crack-the-kelp, critical-or-high, debo, deepkin, desaperh, dipp, eeshenggoh, evmboi32, ge6a, gesha17, glcanvas, gumgumzum, hals, hihen, hunter_w3b, jasonxiale, joaovwfreire, ke1caM, leegh, lsaudit, marchev, merlinboii, niser93, osmanozdemir1, paritomarrr, passion, pep7siup, phoenixV110, pipidu83, poneta, ro1sharkm, rouhsamad, rvierdiiev, sakshamguruji, seerether, shealtielanz, soliditytaker, spark, squeaky_cactus, stackachu, supersizer0x, tallo, taner2344, turvy_fuzz, twcctop, ubl4nk, wisdomn_, xAriextz, zach, zhaojie, zhaojohnson, ziyou-
2.7592 USDC - $2.76
getRSETHPrice
should be avoidedfunction getRSETHPrice() external view returns (uint256 rsETHPrice) { ... 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; }
The asset_idx
counter is a uint16 value and is being compared to the uint256 supportedAssetCount
variable. In making this comparison, the asset_idx
is repeatedly casted into uint256, which is unnecessary.
Also, in the rare case that the supportedAssetCount
is greater than type(uint16).max
, the constant unchecked increment in the loop might cause the asset_idx
value to wrap around and start over from 0 when it reaches its max value. This results in an infinite loop which can dos system.
If the uint16 type is desired, then consider implementing a safeCast library to protect against overflows.
updatePriceOracleFor
function doesn't check if oracle already exists.
function updatePriceOracleFor( address asset, address priceOracle ) external onlyLRTManager onlySupportedAsset(asset) { UtilLib.checkNonZeroAddress(priceOracle); assetPriceOracle[asset] = priceOracle; emit AssetPriceOracleUpdate(asset, priceOracle); }
updatePriceFeedFor
function doesn't check if priceFeed already exists.
function updatePriceFeedFor(address asset, address priceFeed) external onlyLRTManager onlySupportedAsset(asset) { UtilLib.checkNonZeroAddress(priceFeed); assetPriceFeed[asset] = priceFeed; emit AssetPriceFeedUpdate(asset, priceFeed); }
The RSETH
contract implements a burnFrom
function.
function burnFrom(address account, uint256 amount) external onlyRole(BURNER_ROLE) whenNotPaused { _burn(account, amount); }
However, its interface IRETH
implements a burn
function.
function burn(address account, uint256 amount) external;
Consequently, any indirect calls made from outside the RETH
contract to burn tokens will fail and tokens will not be burnt.
.approve()
in maxApproveToEigenStrategyManager
(https://github.com/code-423n4/2023-11-kelp/blob/f751d7594051c0766c7ecd1e68daeb0661e43ee3/src/NodeDelegator.sol#L45) is vulnerable to frontrunning which can lead to loss of assets. Use safeApprove
or safeIncreaseAllowance
instead. Also, approve first to 0 and check for return values.address eigenlayerStrategyManagerAddress = lrtConfig.getContract(LRTConstants.EIGEN_STRATEGY_MANAGER); IERC20(asset).approve(eigenlayerStrategyManagerAddress, type(uint256).max); }
function transferBackToLRTDepositPool( ... { address lrtDepositPool = lrtConfig.getContract(LRTConstants.LRT_DEPOSIT_POOL); //@note check for balance if (!IERC20(asset).transfer(lrtDepositPool, amount)) { revert TokenTransferFailed(); } }
updateMaxNodeDelegatorCount
function should implement a check to make sure the new maxNodeDelegatorCount_
is not less than current maxNodeDelegatorCount
. Unless this is intended, this can lead to unexpected behaviours.function updateMaxNodeDelegatorCount(uint256 maxNodeDelegatorCount_) external onlyLRTAdmin { maxNodeDelegatorCount = maxNodeDelegatorCount_; emit MaxNodeDelegatorCountUpdated(maxNodeDelegatorCount); }
The protocol uses stETH
as one of its base assets. stETH is a token subject to variable balances, it rebases both positively and negatively. This token type is a source of accounting issue, which consequently leads inflates/deflates the amount of rsETH
tokens that the user gets. The protocol also doesn't implement balance checks before the transfer
and transferFrom
calls are made. Recommend introducing a balance before and after check before calling the safe transfer options to ensure accurate accounting. Note that this might leave the contract vulnerable to reentrancy, the reentrancy guard should be implemented.
Consider adding a timelock to critical functions to give time for users to react and adjust to critical changes. Functions that involve making critical updates like updatePriceOracleFor
, updateLRTConfig
, updateAssetDepositLimit
and so on will benefit from this. It also protects from admin errors.
#0 - raymondfam
2023-11-18T00:04:47Z
Possible upgrade:
#1 - c4-pre-sort
2023-11-18T00:05:09Z
raymondfam marked the issue as sufficient quality report
#2 - c4-judge
2023-12-01T18:48:51Z
fatherGoose1 marked the issue as grade-b
🌟 Selected for report: 0xVolcano
Also found by: 0xAnah, 0xhex, 0xta, ABAIKUNANBAEV, JCK, Raihan, Rickard, Sathish9098, ZanyBonzy, hunter_w3b, lsaudit, rumen, shamsulhaq123, tala7985, unique
9.97 USDC - $9.97
Avoids a Gsset (20000 gas) when changing from false to true, after having been true in the past.
mapping(address token => bool isSupported) public isSupportedAsset;
LRTConfig.sol L15
function initialize(
LRTConfig.sol L41
function initialize(address lrtConfigAddr) external initializer {
LRTDepositPool.sol L31
function initialize(address lrtConfigAddr) external initializer {
LRTOracle.sol L29
function initialize(address lrtConfigAddr) external initializer {
NodeDelegator.sol L26
function initialize(address admin, address lrtConfigAddr) external initializer {
RSETH.sol L32
function initialize(address lrtConfig_) external initializer {
ChainlinkPriceOracle.sol L27
The reason for this, is that using address(this) requires an additional EXTCODESIZE operation to retrieve the contract's address from its bytecode, which can increase the gas cost of your contract. By pre-calculating and using a hardcoded address, you can avoid this additional operation and reduce the overall gas cost of your contract.
assetLyingInDepositPool = IERC20(asset).balanceOf(address(this));
LRTDepositPool.sol L79
if (!IERC20(asset).transferFrom(msg.sender, address(this), depositAmount)) {
LRTDeposit.sol L136
uint256 balance = token.balanceOf(address(this));
NodeDelegator.sol L63
IEigenStrategyManager(eigenlayerStrategyManagerAddress).getDeposits(address(this));
NodeDelegator.sol L103
assetBalances[i] = IStrategy(strategies[i]).userUnderlyingView(address(this));
NodeDelegator.sol L111
return IStrategy(strategy).userUnderlyingView(address(this));
NodeDelegator.sol L123
x = x + y
is cheaper than x += y;
totalETHInPool += totalAssetAmt * assetER;
LRTOracle.sol L71
assetLyingInNDCs += IERC20(asset).balanceOf(nodeDelegatorQueue[i]); assetStakedInEigenLayer += INodeDelegator(nodeDelegatorQueue[i]).
LRTDepositPool.sol 83-84
See this issue for a detail description of the issue
bytes32 public constant R_ETH_TOKEN = keccak256("R_ETH_TOKEN"); //stETH token bytes32 public constant ST_ETH_TOKEN = keccak256("ST_ETH_TOKEN"); //cbETH token bytes32 public constant CB_ETH_TOKEN = keccak256("CB_ETH_TOKEN"); //contracts bytes32 public constant LRT_ORACLE = keccak256("LRT_ORACLE"); bytes32 public constant LRT_DEPOSIT_POOL = keccak256("LRT_DEPOSIT_POOL"); bytes32 public constant EIGEN_STRATEGY_MANAGER = keccak256("EIGEN_STRATEGY_MANAGER"); //Roles bytes32 public constant MANAGER = keccak256("MANAGER");
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE");
RSETH.sol L21-22
#0 - c4-pre-sort
2023-11-17T03:37:08Z
raymondfam marked the issue as insufficient quality report
#1 - c4-judge
2023-12-01T16:22:25Z
fatherGoose1 marked the issue as grade-b
🌟 Selected for report: CatsSecurity
Also found by: 0xSmartContract, 0xbrett8571, 0xepley, Bauchibred, KeyKiril, Myd, SAAJ, SBSecurity, Sathish9098, ZanyBonzy, albahaca, clara, digitizeworx, fouzantanveer, foxb868, glcanvas, hunter_w3b, invitedtea, sakshamguruji, unique
12.2949 USDC - $12.29
Kelp is a collective DAO designed to unlock liquidity, DeFi and higher rewards for restaked assets. It aims to address the potential challenges of restaking, such as high gas fees, liquidity constraints, and node operator discovery by introducing a new Liquid Restaked Token which is issued for restaked ETH. At the heart of the protocol is the rsETH
token which is an LRT that provides liquidity to illiquid assets deposited in restaking platforms.
As the codebase is still in the development phase, it's not very large. However, it's well-structured, and functions are broken down into small, easy-to-understand bits - not too cyclomatically complex. The codebase (in scope) comprises a total of 17 contracts (8 interfaces and 9 actual contracts) and about 500 SLoC. The codebase mostly uses inheritance, defines no structs, and includes 2 external calls (to Chainlink oracle and to EigenLayer). The overall test line coverage is about 98%. The core contracts are designed to be upgradeable; therefore, the protocol uses OZ upgradeable imports.
LRTConfig.sol
is the protocol's configuration contract that also stores the protocols' contract addresses.LRTDepositPool.sol
is the contract through which users deposit funds to the protocol, and have their funds transferred to the NodeDelegator
contracts.NodeDelegator.sol
receives funds from the depositpool and delegates them to the eigenlayer strategy.LRTOracle.sol
fetches prices of LST tokens from oracles.RSETH.sol
is the token a user receives upon depositing in LRTDepositPool.sol
.ChainlinkPriceOracle.sol
is integrates chainlink oracles in LRTOracle.LRTConfigRoleChecker.sol
performs role checks.UtilLib.sol
is the helper function library. It handles 0x0 address checks.LRTConstants
is contains the global constant variables.and the interface contracts
Restakers stake their ETH
/liquid tokens by transferring them into the depositPool
contract. This mints them the rsETH
tokens in return, based on the current exchange rate. The staked ETH
/liquid tokens are then transferred to the NodeDelegator
contract. From here, they are delegated to the Eigenlayer protocol through predefined strategies. It is important to note that as of now, there is no way to withdraw directly from the protocol, but the team has confirmed that it is in the works.
rsETH
tokens minted.stETH
is a token with variable balance and can result in accounting issues.multiplier
constant to deal with possible precision loss when performing division operations.The KelpDao seems interesting and we're anticipating what the team has in store next. The architecture is solid, the codebase is well written, and most of the easy-to-overlook safety measures are actually implemented. However, the identified risks need to be mitigated, and provided recommendations should be taken into consideration. Constant upgrades and audits should be invested in to ensure the security of the protocol.
20 hours
#0 - c4-pre-sort
2023-11-17T03:17:25Z
raymondfam marked the issue as sufficient quality report
#1 - c4-judge
2023-11-29T18:56:52Z
fatherGoose1 marked the issue as grade-b