Platform: Code4rena
Start Date: 16/01/2024
Pot Size: $80,000 USDC
Total HM: 37
Participants: 178
Period: 14 days
Judge: Picodes
Total Solo HM: 4
Id: 320
League: ETH
Rank: 68/178
Findings: 2
Award: $125.62
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: klau5
Also found by: 0xAsen, 0xCiphky, 0xbepresent, KingNFT, Topmark, Toshii, falconhoof, jasonxiale, jesjupyter, nonseodion, pkqs90
53.4926 USDC - $53.49
https://github.com/code-423n4/2024-01-salty/blob/main/src/pools/PoolStats.sol#L42 https://github.com/code-423n4/2024-01-salty/blob/main/src/pools/PoolsConfig.sol#L68
When Pool Id are unwhitelisted in the PoolsConfig.sol contract Unclaimed Arbitrage Profit stored in the PoolStats contract would be completely lost
// Record that arbitrageProfit was generated and the a specific arbitrage path generated it (which is defined by the middle two tokens in WETH->arbToken2->arbToken3->WETH) function _updateProfitsFromArbitrage( IERC20 arbToken2, IERC20 arbToken3, uint256 arbitrageProfit ) internal { // Though three pools contributed to the arbitrage we can record just the middle one as we know the input and output token will be WETH bytes32 poolID = PoolUtils._poolID( arbToken2, arbToken3 ); >>> _arbitrageProfits[poolID] += arbitrageProfit; }
The function above from the PoolStats.sol contract shows how arbitrageProfit
is updated to the mapping of each PoolId
function unwhitelistPool( IPools pools, IERC20 tokenA, IERC20 tokenB ) external onlyOwner { bytes32 poolID = PoolUtils._poolID(tokenA,tokenB); _whitelist.remove(poolID); >>> delete underlyingPoolTokens[poolID]; // Make sure that the cached arbitrage indicies in PoolStats are updated pools.updateArbitrageIndicies(); emit PoolUnwhitelisted(address(tokenA), address(tokenB)); }
While the function above from the PoolsConfig.sol contract shows how each poolId is unwhitelisted, The problem is that every factor was put into consideration such as removing it from _whitelist, deleting from underlyingPoolTokens mapping, and updating ArbitrageIndicies correctly but unclaimed _arbitrageProfits
was not put into consideration before deleting the PoolIds, this would cause loss of fund as this array of PoolIds is still needed in other to claim profit.
Manual Review
As adjusted in the code provided below protocol should check to ensure there is no unclaimed arbitrage profit left before removing Pool from whitelisted Pool ID
function unwhitelistPool( IPools pools, IERC20 tokenA, IERC20 tokenB ) external onlyOwner { bytes32 poolID = PoolUtils._poolID(tokenA,tokenB); _whitelist.remove(poolID); delete underlyingPoolTokens[poolID]; +++ if ( _arbitrageProfits[poolID] > 0 ){ +++ revert ( "Unclaimed arbitrage Profit" ) +++ } +++ delete _arbitrageProfits[poolID]; // Make sure that the cached arbitrage indicies in PoolStats are updated pools.updateArbitrageIndicies(); emit PoolUnwhitelisted(address(tokenA), address(tokenB)); }
Access Control
#0 - c4-judge
2024-02-06T16:27:29Z
Picodes marked the issue as duplicate of #570
#1 - c4-judge
2024-02-21T15:50:35Z
Picodes marked the issue as satisfactory
#2 - c4-judge
2024-02-21T15:51:18Z
Picodes marked the issue as duplicate of #752
#3 - c4-judge
2024-02-26T07:54:55Z
Picodes changed the severity to QA (Quality Assurance)
#4 - c4-judge
2024-02-26T07:59:48Z
This previously downgraded issue has been upgraded by Picodes
#5 - c4-judge
2024-02-28T10:23:19Z
Picodes marked the issue as not a duplicate
#6 - c4-judge
2024-02-28T10:24:26Z
Picodes marked the issue as duplicate of #752
🌟 Selected for report: juancito
Also found by: 0x3b, 0xBinChook, 0xCiphky, 0xHelium, 0xMango, 0xOmer, 0xRobocop, 0xSmartContractSamurai, 0xWaitress, 0xbepresent, 0xpiken, 7ashraf, Arz, Audinarey, Banditx0x, Bauchibred, Draiakoo, IceBear, J4X, Jorgect, Kaysoft, KingNFT, Rhaydden, Rolezn, The-Seraphs, Tigerfrake, Topmark, Tripathi, Udsen, ZanyBonzy, a3yip6, b0g0, chaduke, codeslide, csanuragjain, dharma09, djxploit, eta, ether_sky, grearlake, inzinko, jasonxiale, jesjupyter, josephdara, lanrebayode77, linmiaomiao, lsaudit, niroh, oakcobalt, peanuts, pina, sivanesh_808, slvDev, t0x1c, vnavascues, wangxx2026
72.1303 USDC - $72.13
When a pool Id is invalid it returns INVALID_POOL_ID which represents type(uint64).max, however this return value was not checked at L88-L91 in the updateArbitrageIndicies() function. This would allow invalid pool during Arbitrage and could escalate to serious problems, The return value should be checked as adjusted below. https://github.com/code-423n4/2024-01-salty/blob/main/src/pools/PoolStats.sol#L89-L91 https://github.com/code-423n4/2024-01-salty/blob/main/src/pools/PoolStats.sol#L71
function _poolIndex( IERC20 tokenA, IERC20 tokenB, bytes32[] memory poolIDs ) internal pure returns (uint64 index) { bytes32 poolID = PoolUtils._poolID( tokenA, tokenB ); for( uint256 i = 0; i < poolIDs.length; i++ ) { if (poolID == poolIDs[i]) return uint64(i); } >>> return INVALID_POOL_ID; }
function updateArbitrageIndicies() public { bytes32[] memory poolIDs = poolsConfig.whitelistedPools(); for( uint256 i = 0; i < poolIDs.length; i++ ) { bytes32 poolID = poolIDs[i]; (IERC20 arbToken2, IERC20 arbToken3) = poolsConfig.underlyingTokenPair(poolID); // The middle two tokens can never be WETH in a valid arbitrage path as the path is WETH->arbToken2->arbToken3->WETH. if ( (arbToken2 != _weth) && (arbToken3 != _weth) ) { >>> uint64 poolIndex1 = _poolIndex( _weth, arbToken2, poolIDs ); >>> uint64 poolIndex2 = _poolIndex( arbToken2, arbToken3, poolIDs ); >>> uint64 poolIndex3 = _poolIndex( arbToken3, _weth, poolIDs ); +++ require ( poolIndex1 != INVALID_POOL_ID && poolIndex2 != INVALID_POOL_ID && poolIndex3 != INVALID_POOL_ID , "INVALID_POOL_ID" ); ... }
Possible Incomplete implementation in the performUpkeep(...) function of the RewardsEmitter.sol contracr. As noted in the code provided below, uint256 sum was declared and was continuously assigned a value of amountToAddForPool at every loop cycle, but sum
was only assigned it was not used at all through the function implementation
https://github.com/code-423n4/2024-01-salty/blob/main/src/rewards/RewardsEmitter.sol#L115-L128
function performUpkeep( uint256 timeSinceLastUpkeep ) external ... >>> uint256 sum = 0; for( uint256 i = 0; i < poolIDs.length; i++ ) { bytes32 poolID = poolIDs[i]; // Each pool will send a percentage of the pending rewards based on the time elapsed since the last send uint256 amountToAddForPool = ( pendingRewards[poolID] * numeratorMult ) / denominatorMult; // Reduce the pending rewards so they are not sent again if ( amountToAddForPool != 0 ) { pendingRewards[poolID] -= amountToAddForPool; >>> sum += amountToAddForPool; } // Specify the rewards that will be added for the specific pool addedRewards[i] = AddedReward( poolID, amountToAddForPool ); } // Add the rewards so that they can later be claimed by the users proportional to their share of the StakingRewards derived contract. stakingRewards.addSALTRewards( addedRewards ); }
Typographical error in comment description of tokenWhitelistingBallotWithTheMostVotes() function in the Proposals.sol contract. The correct statement as corrected below should be "number of no votes" not "number no votes". https://github.com/code-423n4/2024-01-salty/blob/main/src/dao/Proposals.sol#L416
// Returns the ballotID of the whitelisting ballot that currently has the most yes votes --- // Requires that the quorum has been reached and that the number of yes votes is greater than the number no votes +++ // Requires that the quorum has been reached and that the number of yes votes is greater than the number of no votes function tokenWhitelistingBallotWithTheMostVotes() external view returns (uint256) { uint256 quorum = requiredQuorumForBallotType( BallotType.WHITELIST_TOKEN); uint256 bestID = 0; uint256 mostYes = 0; for( uint256 i = 0; i < _openBallotsForTokenWhitelisting.length(); i++ ) { ... } return bestID; }
The comment in the code provided below in the Pools.sol contract is to determine the value WETH would worth after swap with SwapAmountIn and not just proportionate value, therefore should be adjusted as provided below. https://github.com/code-423n4/2024-01-salty/blob/main/src/pools/Pools.sol#L301-L313
>>> // Determine the WETH equivalent of swapAmountIn of swapTokenIn function _determineSwapAmountInValueInETH(IERC20 swapTokenIn, uint256 swapAmountIn) internal view returns (uint256 swapAmountInValueInETH) { if ( address(swapTokenIn) == address(weth) ) return swapAmountIn; // All tokens within the DEX are paired with WETH (and WBTC) - so this pool will exist. (uint256 reservesWETH, uint256 reservesTokenIn) = getPoolReserves(weth, swapTokenIn); if ( (reservesWETH < PoolUtils.DUST) || (reservesTokenIn < PoolUtils.DUST) ) return 0; // can't arbitrage as there are not enough reserves to determine swapAmountInValueInETH --- swapAmountInValueInETH = ( swapAmountIn * reservesWETH ) / reservesTokenIn; +++ swapAmountInValueInETH = ( swapAmountIn * reservesWETH ) / ( reservesTokenIn + swapAmountIn ); if ( swapAmountInValueInETH <= PoolUtils.DUST ) return 0; }