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: 54/178
Findings: 2
Award: $228.18
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 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
11.6897 USDC - $11.69
Details:
The condition require((reserves.reserve0 >= PoolUtils.DUST) && (reserves.reserve1 >= PoolUtils.DUST), "Insufficient reserves after liquidity removal");
checks whether both reserve0 and reserve1 are greater than or equal to the value specified by PoolUtils.DUST. This condition is intended to ensure that the removal of liquidity does not deplete the reserves below a certain threshold, defined by PoolUtils.DUST. The purpose is to prevent the reserves from becoming too low, which could potentially Details the stability and functionality of the liquidity pool.
Proof of Code:
function removeLiquidity( IERC20 tokenA, IERC20 tokenB, uint256 liquidityToRemove, uint256 minReclaimedA, uint256 minReclaimedB, uint256 totalLiquidity ) external nonReentrant returns (uint256 reclaimedA, uint256 reclaimedB) { require( msg.sender == address(collateralAndLiquidity), "Pools.removeLiquidity is only callable from the CollateralAndLiquidity contract" ); require( liquidityToRemove > 0, "The amount of liquidityToRemove cannot be zero" ); (bytes32 poolID, bool flipped) = PoolUtils._poolIDAndFlipped(tokenA, tokenB); // Determine how much liquidity is being withdrawn and round down in favor of the protocol PoolReserves storage reserves = _poolReserves[poolID]; reclaimedA = ( reserves.reserve0 * liquidityToRemove ) / totalLiquidity; reclaimedB = ( reserves.reserve1 * liquidityToRemove ) / totalLiquidity; reserves.reserve0 -= uint128(reclaimedA); reserves.reserve1 -= uint128(reclaimedB); // Make sure that removing liquidity doesn't drive either of the reserves below DUST. // This is to ensure that ratios remain relatively constant even after a maximum withdrawal. require((reserves.reserve0 >= PoolUtils.DUST) && (reserves.reserve0 >= PoolUtils.DUST), "Insufficient reserves after liquidity removal"); //@audit require check wrong check reserve0 -> reserve1 // Switch reclaimed amounts back to the order that was specified in the call arguments so they make sense to the caller if (flipped) (reclaimedA,reclaimedB) = (reclaimedB,reclaimedA); require( (reclaimedA >= minReclaimedA) && (reclaimedB >= minReclaimedB), "Insufficient underlying tokens returned" ); // Send the reclaimed tokens to the user tokenA.safeTransfer( msg.sender, reclaimedA ); tokenB.safeTransfer( msg.sender, reclaimedB ); emit LiquidityRemoved(tokenA, tokenB, reclaimedA, reclaimedB, liquidityToRemove); }
Optimized code: If you want to correct the condition to ensure that both reserves are above the threshold, you should replace the second part of the condition. The corrected line would be:
function removeLiquidity( IERC20 tokenA, IERC20 tokenB, uint256 liquidityToRemove, uint256 minReclaimedA, uint256 minReclaimedB, uint256 totalLiquidity ) external nonReentrant returns (uint256 reclaimedA, uint256 reclaimedB) { require( msg.sender == address(collateralAndLiquidity), "Pools.removeLiquidity is only callable from the CollateralAndLiquidity contract" ); require( liquidityToRemove > 0, "The amount of liquidityToRemove cannot be zero" ); (bytes32 poolID, bool flipped) = PoolUtils._poolIDAndFlipped(tokenA, tokenB); // Determine how much liquidity is being withdrawn and round down in favor of the protocol PoolReserves storage reserves = _poolReserves[poolID]; reclaimedA = ( reserves.reserve0 * liquidityToRemove ) / totalLiquidity; reclaimedB = ( reserves.reserve1 * liquidityToRemove ) / totalLiquidity; reserves.reserve0 -= uint128(reclaimedA); reserves.reserve1 -= uint128(reclaimedB); // Make sure that removing liquidity doesn't drive either of the reserves below DUST. // This is to ensure that ratios remain relatively constant even after a maximum withdrawal. - require((reserves.reserve0 >= PoolUtils.DUST) && (reserves.reserve0 >= PoolUtils.DUST), "Insufficient reserves after liquidity - removal"); + require((reserves.reserve0 >= PoolUtils.DUST) && (reserves.reserve1 >= PoolUtils.DUST), "Insufficient reserves after liquidity + removal"); // Switch reclaimed amounts back to the order that was specified in the call arguments so they make sense to the caller if (flipped) (reclaimedA,reclaimedB) = (reclaimedB,reclaimedA); require( (reclaimedA >= minReclaimedA) && (reclaimedB >= minReclaimedB), "Insufficient underlying tokens returned" ); // Send the reclaimed tokens to the user tokenA.safeTransfer( msg.sender, reclaimedA ); tokenB.safeTransfer( msg.sender, reclaimedB ); emit LiquidityRemoved(tokenA, tokenB, reclaimedA, reclaimedB, liquidityToRemove); }
_arbitrage()
Details: Subtraction operation where arbitrageAmountIn is subtracted from amountOut. If amountOut is less than arbitrageAmountIn, it can lead to an underflow, and the result would wrap around to a very large positive number.
Assuming the amountOut is greater than arbitrageAmountIn, the function continues with depositing the arbitrage profit to the DAO and updating stats related to the pools involved in the arbitrage. These actions, if successfully executed, could indirectly affect the state of the liquidity pool. For example, depositing arbitrage profits to the DAO might Details the available liquidity in the pool or influence the distribution of rewards.
Proof of Code:
File: src/pools/Pools.sol 284: function _arbitrage(IERC20 arbToken2, IERC20 arbToken3, uint256 arbitrageAmountIn ) internal returns (uint256 arbitrageProfit) { uint256 amountOut = _adjustReservesForSwap( weth, arbToken2, arbitrageAmountIn ); amountOut = _adjustReservesForSwap( arbToken2, arbToken3, amountOut ); amountOut = _adjustReservesForSwap( arbToken3, weth, amountOut ); // Will revert if amountOut < arbitrageAmountIn @> arbitrageProfit = amountOut - arbitrageAmountIn; //@audit // Deposit the arbitrage profit for the DAO - later to be divided between the DAO, SALT stakers and liquidity providers in DAO.performUpkeep _userDeposits[address(dao)][weth] += arbitrageProfit; // Update the stats related to the pools that contributed to the arbitrage so they can be rewarded proportionally later // The arbitrage path can be identified by the middle tokens arbToken2 and arbToken3 (with WETH always on both ends) _updateProfitsFromArbitrage( arbToken2, arbToken3, arbitrageProfit ); }
Optimized code: To mitigate this issue, you should perform a check before subtracting to ensure that amountOut is greater than or equal to arbitrageAmountIn. If it's not, you should handle the error appropriately, such as by reverting the transaction.
File: src/pools/Pools.sol 284: function _arbitrage(IERC20 arbToken2, IERC20 arbToken3, uint256 arbitrageAmountIn ) internal returns (uint256 arbitrageProfit) { uint256 amountOut = _adjustReservesForSwap( weth, arbToken2, arbitrageAmountIn ); amountOut = _adjustReservesForSwap( arbToken2, arbToken3, amountOut ); amountOut = _adjustReservesForSwap( arbToken3, weth, amountOut ); + if (amountout <= arbitrageAmountIn) + revert ("InvalidBorrowAmount"); // Will revert if amountOut < arbitrageAmountIn + unchecked { + arbitrageProfit = amountOut - arbitrageAmountIn; + } // Deposit the arbitrage profit for the DAO - later to be divided between the DAO, SALT stakers and liquidity providers in DAO.performUpkeep _userDeposits[address(dao)][weth] += arbitrageProfit; // Update the stats related to the pools that contributed to the arbitrage so they can be rewarded proportionally later // The arbitrage path can be identified by the middle tokens arbToken2 and arbToken3 (with WETH always on both ends) _updateProfitsFromArbitrage( arbToken2, arbToken3, arbitrageProfit ); }
Details: ecrecover() accepts as valid, two versions of signatures, meaning an attacker can use the same signature twice, or an attacker may be able to front-run the original signer with the altered version of the signature, causing the signer's transaction to revert due to nonce reuse. Consider adding checks for signature malleability, or using OpenZeppelin's ECDSA library to perform the extra checks necessary in order to prevent malleability. Proof of Code:
File: src/SiginingTool.sol 11: function _verifySignature(bytes32 messageHash, bytes memory signature ) internal pure returns (bool) { require( signature.length == 65, "Invalid signature length" ); bytes32 r; bytes32 s; uint8 v; assembly { r := mload (add (signature, 0x20)) s := mload (add (signature, 0x40)) v := mload (add (signature, 0x41)) } //@audit-issue Verify s or add a nonce @> address recoveredAddress = ecrecover(messageHash, v, r, s); return (recoveredAddress == EXPECTED_SIGNER); } }
Details: The package.json configuration file says that the project is using OZ which has a not last update version. Latest non vulnerable version 5.0.1 for @openzeppelin/contracts
Proof of Code:
File: lib/chainlink/contracts/package.json 85: "@openzeppelin/contracts": "~4.3.3",
The use of block.timestamp for time-based conditions might be susceptible to miner manipulation. It's generally recommended to use block numbers or other mechanisms that are less susceptible to manipulation.
File: src/ManagedWallet.sol 71: // Confirm the wallet proposals - assuming that the active timelock has already expired. function changeWallets() external { // proposedMainWallet calls the function - to make sure it is a valid address. require( msg.sender == proposedMainWallet, "Invalid sender" ); - require( block.timestamp >= activeTimelock, "Timelock not yet completed" ); + require(block.number >= activeTimelockBlock, "Timelock not yet completed"); // Set the wallets mainWallet = proposedMainWallet; confirmationWallet = proposedConfirmationWallet; emit WalletChange(mainWallet, confirmationWallet); // Reset activeTimelock = type(uint256).max; proposedMainWallet = address(0); proposedConfirmationWallet = address(0); }
proposedMainWallet
is not the zero address before proceedingthe implementation relies on the assumption that the proposedMainWallet has already been validated as a valid address. It would be good practice to include an additional check to ensure that proposedMainWallet is not the zero address before proceeding with the state changes.
File: src/ManagedWallet.sol 71: // Confirm the wallet proposals - assuming that the active timelock has already expired. function changeWallets() external { // proposedMainWallet calls the function - to make sure it is a valid address. require( msg.sender == proposedMainWallet, "Invalid sender" ); require( block.timestamp >= activeTimelock, "Timelock not yet completed" ); // Set the wallets mainWallet = proposedMainWallet; confirmationWallet = proposedConfirmationWallet; emit WalletChange(mainWallet, confirmationWallet); // Reset activeTimelock = type(uint256).max; proposedMainWallet = address(0); proposedConfirmationWallet = address(0); }
#0 - c4-judge
2024-02-03T13:59:33Z
Picodes marked the issue as grade-b
#1 - Picodes
2024-02-03T14:00:22Z
L-01 is a dup of #647
🌟 Selected for report: 0xVolcano
Also found by: 0x11singh99, 0xAnah, Beepidibop, JCK, JcFichtner, K42, Kaysoft, Pechenite, Raihan, Rolezn, dharma09, hunter_w3b, lsaudit, n0kto, naman1778, niroh, sivanesh_808, slvDev, unique
216.4912 USDC - $216.49
Highlighted below are optimizations exclusively targeting state-mutating functions and view/pure functions invoked by state-mutating functions. In the discussion that follows, only runtime gas is emphasized, given its inevitable dominance over deployment gas costs throughout the protocol's lifetime.
Please be aware that some code snippets may be shortened to conserve space, and certain code snippets may include @audit
tags in comments to facilitate issue explanations."
ballot
is only used once, no need to cache proposals.ballotForID(ballotID)
changeWallets()
to avoid making unnecessary state loads(SLOADS)step11()
for better gas optimization_verifySignature
Function in the SigningTools ContractnumberAuthorized()
is called multiple times -> reduce gas costs by avoiding redundant function callsDetails:
In function , _increseUserShare
the variable data is only used when require( block.timestamp >= user.cooldownExpiration, "Must wait for the cooldown to expire" );
, therefore there's no need to make a state read if we are not going to make use of it
Proof Of Code
File: src/staking/StakingRewards.sol 57: function _increaseUserShare( address wallet, bytes32 poolID, uint256 increaseShareAmount, bool useCooldown ) internal { require( poolsConfig.isWhitelisted( poolID ), "Invalid pool" ); require( increaseShareAmount != 0, "Cannot increase zero share" ); UserShareInfo storage user = _userShareInfo[wallet][poolID]; //@audit cache vaiable when its use write down after below cheack if ( useCooldown ) if ( msg.sender != address(exchangeConfig.dao()) ) // DAO doesn't use the cooldown { require( block.timestamp >= user.cooldownExpiration, "Must wait for the cooldown to expire" ); // Update the cooldown expiration for future transactions user.cooldownExpiration = block.timestamp + stakingConfig.modificationCooldown(); } uint256 existingTotalShares = totalShares[poolID];
If useCooldown is not true than not run second loop, then we save ourselves one entire SLOAD Optimized code:
57: function _increaseUserShare( address wallet, bytes32 poolID, uint256 increaseShareAmount, bool useCooldown ) internal { require( poolsConfig.isWhitelisted( poolID ), "Invalid pool" ); require( increaseShareAmount != 0, "Cannot increase zero share" ); - UserShareInfo storage user = _userShareInfo[wallet][poolID]; if ( useCooldown ) if ( msg.sender != address(exchangeConfig.dao()) ) // DAO doesn't use the cooldown { + UserShareInfo storage user = _userShareInfo[wallet][poolID]; require( block.timestamp >= user.cooldownExpiration, "Must wait for the cooldown to expire" ); // Update the cooldown expiration for future transactions user.cooldownExpiration = block.timestamp + stakingConfig.modificationCooldown(); } uint256 existingTotalShares = totalShares[poolID];
Details:
using pre-increment (++i)
instead of the addition assignment (i += 1)
can sometimes result in more efficient code. By doing this we can avoid state variable read as a result we can save gas.
Proof of Code:
File: src/AccessManager.sol 41: function excludedCountriesUpdated() external { require( msg.sender == address(dao), "AccessManager.excludedCountriedUpdated only callable by the DAO" ); geoVersion += 1; //@audit Use pre Increment }
The idea here is that using ++i
directly modifies the value in the storage location without reading the current value first, potentially saving gas compared to i += 1
, which involves reading the current value, adding 1, and then writing it back.
Optimized code:
41: function excludedCountriesUpdated() external { require( msg.sender == address(dao), "AccessManager.excludedCountriedUpdated only callable by the DAO" ); - geoVersion += 1; + geoVersion ++; }
Details: geoVersion is assigned the value of geoVersion before emitting the AccessGranted event. This can potentially save gas if the compiler doesn't optimize away the redundant read operation. Proof of Code:
File: src/AccessManager.sol 61: function grantAccess(bytes calldata signature) external { require( _verifyAccess(msg.sender, signature), "Incorrect AccessManager.grantAccess signatory" ); _walletsWithAccess[geoVersion][msg.sender] = true; emit AccessGranted( msg.sender, geoVersion ); //@audit do need emit state variable }
Optimized code:
assigns the value of _geoVersion
to the geoVersion state variable.
File: src/AccessManager.sol 61: function grantAccess(bytes calldata signature) external { require( _verifyAccess(msg.sender, signature), "Incorrect AccessManager.grantAccess signatory" ); _walletsWithAccess[geoVersion][msg.sender] = true; + geoVersion = _geoVersion; - emit AccessGranted( msg.sender, geoVersion ); + emit AccessGranted( msg.sender, _geoVersion ); }
In function incrementBurnableUSDS
use two time state variable which can avoid by caching in stack variable.
Proof of Code:
File: src/stable/Liquidizer.sol 81: function incrementBurnableUSDS( uint256 usdsToBurn ) external { require( msg.sender == address(collateralAndLiquidity), "Liquidizer.incrementBurnableUSDS is only callable from the CollateralAndLiquidity contract" ); + _usdsThatShouldBeBurned = usdsThatShouldBeBurned; - usdsThatShouldBeBurned += usdsToBurn; //@audit usdsThatShouldBeBurned + usdsThatShouldBeBurned = _usdsThatShouldBeBurned + usdsToBurn; - emit incrementedBurnableUSDS(usdsThatShouldBeBurned); + emit incrementedBurnableUSDS(_usdsThatShouldBeBurned); }
Details:
By caching the length, you save gas by eliminating the need to repeatedly access the length property of the array.This is particularly beneficial if the length of the array doesn't change during the loop execution.
Proof of Code:
cache userUnstakes.length;
in unstakesForUser
File: src/staking/Staking.sol 150: function unstakesForUser( address user, uint256 start, uint256 end ) public view returns (Unstake[] memory) { // Check if start and end are within the bounds of the array require(end >= start, "Invalid range: end cannot be less than start"); uint256[] memory userUnstakes = _userUnstakeIDs[user]; + uint256 userUnstakesLength = userUnstakes.length; - require(userUnstakes.length > end, "Invalid range: end is out of bounds"); //@audit cache userUnstake.length - require(start < userUnstakes.length, "Invalid range: start is out of bounds"); + require(userUnstakesLength > end, "Invalid range: end is out of bounds"); + require(start < userUnstakesLength, "Invalid range: start is out of bounds"); Unstake[] memory unstakes = new Unstake[](end - start + 1); uint256 index; for(uint256 i = start; i <= end; i++) unstakes[index++] = _unstakesByID[ userUnstakes[i]]; return unstakes; }
Cache unstakeIDs.length
in unstakesForUser
Proof Of Code
File: src/staking/Staking.sol 171: function unstakesForUser( address user ) external view returns (Unstake[] memory) { // Check to see how many unstakes the user has uint256[] memory unstakeIDs = _userUnstakeIDs[user]; - if ( unstakeIDs.length == 0 ) + if ( unstakeIDs == 0 ) return new Unstake[](0); // Return them all - return unstakesForUser( user, 0, unstakeIDs.length - 1 ); //@audit cache length + return unstakesForUser( user, 0, unstakeIDs - 1 ); }
Details: Use store values in local variables before emitting the event.This way we can avoid to read state variable two time. Proof of Code:
File: src/ManagedWallet.sol 42: function proposeWallets( address _proposedMainWallet, address _proposedConfirmationWallet ) external { require( msg.sender == mainWallet, "Only the current mainWallet can propose changes" ); require( _proposedMainWallet != address(0), "_proposedMainWallet cannot be the zero address" ); require( _proposedConfirmationWallet != address(0), "_proposedConfirmationWallet cannot be the zero address" ); // Make sure we're not overwriting a previous proposal (as only the confirmationWallet can reject proposals) require( proposedMainWallet == address(0), "Cannot overwrite non-zero proposed mainWallet." ); proposedMainWallet = _proposedMainWallet; proposedConfirmationWallet = _proposedConfirmationWallet; emit WalletProposal(proposedMainWallet, proposedConfirmationWallet); //@audit use memory value }
Optimized code: By doing this, you avoid reading the state variables twice when emitting the event, potentially saving some gas.
42: function proposeWallets( address _proposedMainWallet, address _proposedConfirmationWallet ) external { require( msg.sender == mainWallet, "Only the current mainWallet can propose changes" ); require( _proposedMainWallet != address(0), "_proposedMainWallet cannot be the zero address" ); require( _proposedConfirmationWallet != address(0), "_proposedConfirmationWallet cannot be the zero address" ); // Make sure we're not overwriting a previous proposal (as only the confirmationWallet can reject proposals) require( proposedMainWallet == address(0), "Cannot overwrite non-zero proposed mainWallet." ); proposedMainWallet = _proposedMainWallet; proposedConfirmationWallet = _proposedConfirmationWallet; - emit WalletProposal(proposedMainWallet, proposedConfirmationWallet); + emit WalletProposal(_proposedMainWallet, _proposedConfirmationWallet); }
ballot
is only used once, no need to cache proposals.ballotForID(ballotID)
Details: It makes no sense to cache data in memory if a state variable is only used once throughout the entire function. We can save one sold by using the state variable directly. Proof of Code:
File: src/dao/Dao.sol 219: function _finalizeApprovalBallot( uint256 ballotID ) internal { if ( proposals.ballotIsApproved(ballotID ) ) { Ballot memory ballot = proposals.ballotForID(ballotID); //@audit do not cache state variable if used once _executeApproval( ballot ); } proposals.markBallotAsFinalized(ballotID); }
Optimized code:
We can use proposals.ballotForID(ballotID
Directly there's no need to cache.
219: function _finalizeApprovalBallot( uint256 ballotID ) internal { if ( proposals.ballotIsApproved(ballotID ) ) { - Ballot memory ballot = proposals.ballotForID(ballotID); - _executeApproval( ballot ); + _executeApproval( proposals.ballotForID(ballotID) ); } proposals.markBallotAsFinalized(ballotID); }
Change uint256 to uint8 for PERCENT_POL_TO_WITHDRAW if the value fits within the range of a uint8.
Proof Of Code
File: src/stable/Liquidizer.sol -29: uint256 constant PERCENT_POL_TO_WITHDRAW = 1; +29: uint8 constant PERCENT_POL_TO_WITHDRAW = 1;
uint256 //1 uint8 constant PERCENT_POL_TO_WITHDRAW = 1; //1 1 IERC20 immutable public wbtc; //20 1 IERC20 immutable public weth; //20 2 IUSDS immutable public usds; //32 3 ISalt immutable public salt; //32 4 IERC20 immutable public dai; //20 5
Refactor withdraw
function to save more gas
Reorder the checks to have cheaper checks first
Details: Require() or revert() statements that check input arguments or cost lesser gas should be at the top of the function. Checks that involve constants should come before checks that involve state variables, function calls, and calculations. By doing these checks first, the function is able to revert before wasting alot of gas in a function that may ultimately revert in the unhappy case. Proof of Code:
File: src/pools/Pools.sol 220: function withdraw( IERC20 token, uint256 amount ) external nonReentrant { require( _userDeposits[msg.sender][token] >= amount, "Insufficient balance to withdraw specified amount" ); require( amount > PoolUtils.DUST, "Withdraw amount too small"); //@audit check before above conditoin its cheaper to above check _userDeposits[msg.sender][token] -= amount; // Send the token to the user token.safeTransfer( msg.sender, amount ); emit TokenWithdrawal(msg.sender, token, amount); }
Optimized code:
220: function withdraw( IERC20 token, uint256 amount ) external nonReentrant { - require( _userDeposits[msg.sender][token] >= amount, "Insufficient balance to withdraw specified amount" ); require( amount > PoolUtils.DUST, "Withdraw amount too small"); + require( _userDeposits[msg.sender][token] >= amount, "Insufficient balance to withdraw specified amount" ); _userDeposits[msg.sender][token] -= amount; // Send the token to the user token.safeTransfer( msg.sender, amount ); emit TokenWithdrawal(msg.sender, token, amount); }
Details
In Pools.sol::removeLiquidity
check first msg.sender == address(collateralAndLiquidity)
and this check involve with global value which consume more gas then other check. so its recommended to revert or check first cheaper statement. This way we can save some gas by reverting cheaper condition.
File:src/pools/Pools.sol 170: function removeLiquidity( IERC20 tokenA, IERC20 tokenB, uint256 liquidityToRemove, uint256 minReclaimedA, uint256 minReclaimedB, uint256 totalLiquidity ) external nonReentrant returns (uint256 reclaimedA, uint256 reclaimedB) { - require( msg.sender == address(collateralAndLiquidity), "Pools.removeLiquidity is only callable from the CollateralAndLiquidity contract" ); require( liquidityToRemove > 0, "The amount of liquidityToRemove cannot be zero" ); //@audit check input value first + require( msg.sender == address(collateralAndLiquidity), "Pools.removeLiquidity is only callable from the CollateralAndLiquidity contract" ); (bytes32 poolID, bool flipped) = PoolUtils._poolIDAndFlipped(tokenA, tokenB); // Determine how much liquidity is being withdrawn and round down in favor of the protocol PoolReserves storage reserves = _poolReserves[poolID]; reclaimedA = ( reserves.reserve0 * liquidityToRemove ) / totalLiquidity; reclaimedB = ( reserves.reserve1 * liquidityToRemove ) / totalLiquidity; reserves.reserve0 -= uint128(reclaimedA); reserves.reserve1 -= uint128(reclaimedB); // Make sure that removing liquidity doesn't drive either of the reserves below DUST. // This is to ensure that ratios remain relatively constant even after a maximum withdrawal. require((reserves.reserve0 >= PoolUtils.DUST) && (reserves.reserve0 >= PoolUtils.DUST), "Insufficient reserves after liquidity removal"); // one of token has more decimal than other // Make sure that the reserves after swap are above DUST // Switch reclaimed amounts back to the order that was specified in the call arguments so they make sense to the caller if (flipped) (reclaimedA,reclaimedB) = (reclaimedB,reclaimedA); require( (reclaimedA >= minReclaimedA) && (reclaimedB >= minReclaimedB), "Insufficient underlying tokens returned" ); // Send the reclaimed tokens to the user tokenA.safeTransfer( msg.sender, reclaimedA ); tokenB.safeTransfer( msg.sender, reclaimedB ); emit LiquidityRemoved(tokenA, tokenB, reclaimedA, reclaimedB, liquidityToRemove); }
Details
In CollateralAndLiquidity.sol::repayUSDS
check first userShareForPool( msg.sender, collateralPoolID
and amountRepaid <= usdsBorrowedByUsers[msg.sender]
which envolve state variable check which consume more gas than input value check. so its recommended to revert or check first cheaper statement.check amountRapid > 0
This way we can save some gas by reverting cheaper condition.
File: src/stable/CollateralAndLiquidity.sol 115: function repayUSDS( uint256 amountRepaid ) external nonReentrant { - require( userShareForPool( msg.sender, collateralPoolID ) > 0, "User does not have any collateral" ); - require( amountRepaid <= usdsBorrowedByUsers[msg.sender], "Cannot repay more than the borrowed amount" ); require( amountRepaid > 0, "Cannot repay zero amount" ); //@audit check first + require( userShareForPool( msg.sender, collateralPoolID ) > 0, "User does not have any collateral" ); + require( amountRepaid <= usdsBorrowedByUsers[msg.sender], "Cannot repay more than the borrowed amount" ); // Decrease the borrowed amount for the user usdsBorrowedByUsers[msg.sender] -= amountRepaid; // Have the user send the USDS to the USDS contract so that it can later be burned (on USDS.performUpkeep) usds.safeTransferFrom(msg.sender, address(usds), amountRepaid); // Have USDS remember that the USDS should be burned liquidizer.incrementBurnableUSDS( amountRepaid ); // Check if the user no longer has any borrowed USDS if ( usdsBorrowedByUsers[msg.sender] == 0 ) _walletsWithBorrowedUSDS.remove(msg.sender); emit RepaidUSDS(msg.sender, amountRepaid); }
Details
In USDS.sol::mintTo
involve some pre check in this condition function check first require statement which chekck msg.sender against state variable which is consume more gas. So its better to check after input value check.
File: src/stable/USDS.sol 40: function mintTo( address wallet, uint256 amount ) external { - require( msg.sender == address(collateralAndLiquidity), "USDS.mintTo is only callable from the Collateral contract" ); require( amount > 0, "Cannot mint zero USDS" ); //@audit check first + require( msg.sender == address(collateralAndLiquidity), "USDS.mintTo is only callable from the Collateral contract" ); _mint( wallet, amount ); emit USDSMinted(wallet, amount); }
Details
in StakingRewards.sol::_increaseShareAmount
function check pool is whiltelisted or not which contain external call and this require statment cost more gas. So its better to revert after input value check.Which save huge gas by avoiding external call first.
File: src/staking/StakingRewards.sol 57: function _increaseUserShare( address wallet, bytes32 poolID, uint256 increaseShareAmount, bool useCooldown ) internal { - require( poolsConfig.isWhitelisted( poolID ), "Invalid pool" ); require( increaseShareAmount != 0, "Cannot increase zero share" ); //@audit check first + require( poolsConfig.isWhitelisted( poolID ), "Invalid pool" ); UserShareInfo storage user = _userShareInfo[wallet][poolID]; if ( useCooldown ) if ( msg.sender != address(exchangeConfig.dao()) ) // DAO doesn't use the cooldown { require( block.timestamp >= user.cooldownExpiration, "Must wait for the cooldown to expire" ); // Update the cooldown expiration for future transactions user.cooldownExpiration = block.timestamp + stakingConfig.modificationCooldown(); } uint256 existingTotalShares = totalShares[poolID];
There are some checks to avoid an underflow, but in some scenarios where it is impossible for underflow to occur we can use unchecked blocks to have some gas savings.
Details:
In Pools.sol::withdraw
function update balance after user withdraw which happen on l225 and also doing check for _userDeposits[msg.sender][token] >= amount
on line 222. That means we can wrap this statement in unchecked block for saving some gas.
Proof of Code:
File: src/pools/Pools.sol 220: function withdraw( IERC20 token, uint256 amount ) external nonReentrant { require( _userDeposits[msg.sender][token] >= amount, "Insufficient balance to withdraw specified amount" ); require( amount > PoolUtils.DUST, "Withdraw amount too small"); _userDeposits[msg.sender][token] -= amount; //@audit unchecked this block // Send the token to the user token.safeTransfer( msg.sender, amount ); emit TokenWithdrawal(msg.sender, token, amount); }
Optimized code:
220: function withdraw( IERC20 token, uint256 amount ) external nonReentrant { require( _userDeposits[msg.sender][token] >= amount, "Insufficient balance to withdraw specified amount" ); require( amount > PoolUtils.DUST, "Withdraw amount too small"); + unchecked { + _userDeposits[msg.sender][token] -= amount; + } // Send the token to the user token.safeTransfer( msg.sender, amount ); emit TokenWithdrawal(msg.sender, token, amount); }
Details
In StakingRewards.sol
function update claimableRewards after user claim which happen on l134 and also doing check for virtualRewardsToRemove < rewardsForAmount
on line 133. That means we can wrap this statement in unchecked block for saving some gas.
File: src/staking/StakingRewards.sol 130: // Some of the rewardsForAmount are actually virtualRewards and can't be claimed. // In the event that virtualRewards are greater than actual rewards - claimableRewards will stay zero. if ( virtualRewardsToRemove < rewardsForAmount ) claimableRewards = rewardsForAmount - virtualRewardsToRemove; //@audit unchecked // Send the claimable rewards
Details
In CollateralAndLiquidity.sol::repayUSDS
function update borrow share after user borrow amount which happen on L120 and also doing check for amountRepaid <= usdsBorrowedByUsers[msg.sender]
on line 118. That means we can wrap this statement in unchecked block for saving some gas.
proof of code
File: src/stable/CollateralAndLiquidity.sol 115: function repayUSDS( uint256 amountRepaid ) external nonReentrant { require( userShareForPool( msg.sender, collateralPoolID ) > 0, "User does not have any collateral" ); require( amountRepaid <= usdsBorrowedByUsers[msg.sender], "Cannot repay more than the borrowed amount" ); require( amountRepaid > 0, "Cannot repay zero amount" ); // Decrease the borrowed amount for the user - usdsBorrowedByUsers[msg.sender] -= amountRepaid; //@audit unchecked this block + unchecked{ + usdsBorrowedByUsers[msg.sender] -= amountRepaid; + } // Have the user send the USDS to the USDS contract so that it can later be burned (on USDS.performUpkeep) usds.safeTransferFrom(msg.sender, address(usds), amountRepaid); // Have USDS remember that the USDS should be burned liquidizer.incrementBurnableUSDS( amountRepaid ); // Check if the user no longer has any borrowed USDS if ( usdsBorrowedByUsers[msg.sender] == 0 ) _walletsWithBorrowedUSDS.remove(msg.sender); emit RepaidUSDS(msg.sender, amountRepaid); }
Details
In Pools.sol::swap
function update balance after user swap which happen on L378 and also doing check for userDeposits[swapTokenIn] >= swapAmountIn
on line 377. That means we can wrap this statment in unchecked block for saving some gas.
File: src/pools/Pools.sol 372: function swap( IERC20 swapTokenIn, IERC20 swapTokenOut, uint256 swapAmountIn, uint256 minAmountOut, uint256 deadline ) external nonReentrant ensureNotExpired(deadline) returns (uint256 swapAmountOut) { // Confirm and adjust user deposits mapping(IERC20=>uint256) storage userDeposits = _userDeposits[msg.sender]; require( userDeposits[swapTokenIn] >= swapAmountIn, "Insufficient deposited token balance of initial token" ); - userDeposits[swapTokenIn] -= swapAmountIn; + unchecked { + userDeposits[swapTokenIn] -= swapAmountIn; + } swapAmountOut = _adjustReservesForSwapAndAttemptArbitrage(swapTokenIn, swapTokenOut, swapAmountIn, minAmountOut ); // Deposit the final tokenOut for the caller userDeposits[swapTokenOut] += swapAmountOut; }
note : All instances are not found by bot race
Details: Repeatedly accessing the same mapping or array key/index in smart contracts without caching can result in substantial gas consumption. In Solidity, each read operation from storage incurs a gas cost. By implementing a caching mechanism for recurring accesses, significant gas savings can be achieved. This is particularly crucial in loops or functions with multiple reads of the same data.
Proof of Code:
File: src/pools/Pools.sol 220: function withdraw( IERC20 token, uint256 amount ) external nonReentrant { require( _userDeposits[msg.sender][token] >= amount, "Insufficient balance to withdraw specified amount" ); require( amount > PoolUtils.DUST, "Withdraw amount too small"); _userDeposits[msg.sender][token] -= amount; //@audit Cache _userDeposits[msg.sender][token] // Send the token to the user token.safeTransfer( msg.sender, amount ); emit TokenWithdrawal(msg.sender, token, amount); }
Optimized code:
220: function withdraw( IERC20 token, uint256 amount ) external nonReentrant { + UserAmount calldata _userAmount = _userDeposits[msg.sender][token]; - require( _userDeposits[msg.sender][token] >= amount, "Insufficient balance to withdraw specified amount" ); + require( _userDeposits >= amount, "Insufficient balance to withdraw specified amount" ); require( amount > PoolUtils.DUST, "Withdraw amount too small"); - _userDeposits[msg.sender][token] -= amount; + _userDeposits[msg.sender][token] = _userAmount - amount; // Send the token to the user token.safeTransfer( msg.sender, amount ); emit TokenWithdrawal(msg.sender, token, amount); }
Proof Of Code
cache totalShares[poolID]
in StakingRewards.sol::userRewardForPool()
File: src/staking/StakingRewards.sol 232: function userRewardForPool( address wallet, bytes32 poolID ) public view returns (uint256) { + TotalShare memory totalShares = totalShares[poolID]; // If there are no shares for the pool, the user can't have any shares either and there can't be any rewards - if ( totalShares[poolID] == 0 ) + if ( totalShares == 0 ) return 0; UserShareInfo memory user = _userShareInfo[wallet][poolID]; if ( user.userShare == 0 ) return 0; // Determine the share of the rewards for the user based on their deposited share - uint256 rewardsShare = ( totalRewards[poolID] * user.userShare ) / totalShares[poolID]; //@audit cache totalShares[poolID] + uint256 rewardsShare = ( totalRewards[poolID] * user.userShare ) / totalShares; // Reduce by the virtualRewards - as they were only added to keep the share / rewards ratio the same when the used added their share // In the event that virtualRewards exceeds rewardsShare due to precision loss - just return zero if ( user.virtualRewards > rewardsShare ) return 0; return rewardsShare - user.virtualRewards; }
Proof Of Code
Cache totalShares[poolID]
abd totoalRewards[poolID]
in StakingRewards.sol
File: src/staking/StakingRewards.sol 105: if ( msg.sender != address(exchangeConfig.dao()) ) // DAO doesn't use the cooldown { require( block.timestamp >= user.cooldownExpiration, "Must wait for the cooldown to expire" ); // Update the cooldown expiration for future transactions user.cooldownExpiration = block.timestamp + stakingConfig.modificationCooldown(); } + TotalRewards memory totalRewards = totalRewards[poolID]; + TotalShares memory totalShares = totalShares[poolID]; // Determine the share of the rewards for the amountToDecrease (will include previously added virtual rewards) - uint256 rewardsForAmount = ( totalRewards[poolID] * decreaseShareAmount ) / totalShares[poolID]; //@audit cache totoalRewards[poolID] and totalShares[poolID] + uint256 rewardsForAmount = ( totalRewards * decreaseShareAmount ) / totalShares; // For the amountToDecrease determine the proportion of virtualRewards (proportional to all virtualRewards for the user) // Round virtualRewards down in favor of the protocol uint256 virtualRewardsToRemove = (user.virtualRewards * decreaseShareAmount) / user.userShare; // Update totals - totalRewards[poolID] -= rewardsForAmount; - totalShares[poolID] -= decreaseShareAmount; + totalRewards[poolID] = totalRewards - rewardsForAmount; + totalShares[poolID] = totalShares - decreaseShareAmount; // Update the user's share and virtual rewards user.userShare -= uint128(decreaseShareAmount); user.virtualRewards -= uint128(virtualRewardsToRemove); uint256 claimableRewards = 0;
Cache totalRewards[poolID]
in StakingRewards.sol::
. also in this function there's calculation for virtualRewardsToAdd which multiplcation with totalRewars[poolID]. If we use memory value than we can save some gas.
File: src/staking/StakingRewards.sol 73: uint256 existingTotalShares = totalShares[poolID]; // Determine the amount of virtualRewards to add based on the current ratio of rewards/shares. // The ratio of virtualRewards/increaseShareAmount is the same as totalRewards/totalShares for the pool. // The virtual rewards will be deducted later when calculating the user's owed rewards. if ( existingTotalShares != 0 ) // prevent / 0 { // Round up in favor of the protocol. - uint256 virtualRewardsToAdd = Math.ceilDiv( totalRewards[poolID] * increaseShareAmount, existingTotalShares ); //@audit Cache totalRewards[poolID] + uint256 virtualRewardsToAdd = Math.ceilDiv( totalRewards * increaseShareAmount, existingTotalShares ); user.virtualRewards += uint128(virtualRewardsToAdd); - totalRewards[poolID] += uint128(virtualRewardsToAdd); + totalRewards[poolID] = totalRewards + uint128(virtualRewardsToAdd); } // Update the deposit balances user.userShare += uint128(increaseShareAmount); totalShares[poolID] = existingTotalShares + increaseShareAmount; emit UserShareIncreased(wallet, poolID, increaseShareAmount); }
First of all cache pendingRewards[poolID]
and make variable outside for loop. with help of this we can skip the variable creation in every interaction.
File: src/rewards/RewardsEmitter.sol 112: // Rewards to emit = pendingRewards * timeSinceLastUpkeep * rewardsEmitterDailyPercent / oneDay uint256 numeratorMult = timeSinceLastUpkeep * rewardsConfig.rewardsEmitterDailyPercentTimes1000(); uint256 denominatorMult = 1 days * 100000; // simplification of numberSecondsInOneDay * (100 percent) * 1000 + PendingRewards memory pendingRewards = pendingRewards[poolID]; + bytes32 poolID; uint256 sum = 0; for( uint256 i = 0; i < poolIDs.length; i++ ) { - poolID = poolIDs[i]; //@audit make variable outside // 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; //@audit cache pendingRewards[poolID] + uint256 amountToAddForPool = ( pendingRewards* numeratorMult ) / denominatorMult; // Reduce the pending rewards so they are not sent again if ( amountToAddForPool != 0 ) { - pendingRewards[poolID] -= amountToAddForPool; + pendingRewards[poolID] = 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 ); }
Cache usdsBorrowedByUsers[msg.sender]
File: src/stable/CollateralAndLiquidity.sol 115: function repayUSDS( uint256 amountRepaid ) external nonReentrant { require( userShareForPool( msg.sender, collateralPoolID ) > 0, "User does not have any collateral" ); - UsdsBorrowedByUsers memory usdsBorrowedByUsers = usdsBorrowedByUsers[msg.sender]; - require( amountRepaid <= usdsBorrowedByUsers[msg.sender], "Cannot repay more than the borrowed amount" ); //@audit cache usdsBorrowedByUsers[msg.sender] + require( amountRepaid <= usdsBorrowedByUsers, "Cannot repay more than the borrowed amount" ); require( amountRepaid > 0, "Cannot repay zero amount" ); // Decrease the borrowed amount for the user - usdsBorrowedByUsers[msg.sender] -= amountRepaid; + usdsBorrowedByUsers[msg.sender] = usdsBorrowedByUsers - amountRepaid; // Have the user send the USDS to the USDS contract so that it can later be burned (on USDS.performUpkeep) usds.safeTransferFrom(msg.sender, address(usds), amountRepaid); // Have USDS remember that the USDS should be burned liquidizer.incrementBurnableUSDS( amountRepaid ); // Check if the user no longer has any borrowed USDS if ( usdsBorrowedByUsers[msg.sender] == 0 ) _walletsWithBorrowedUSDS.remove(msg.sender); emit RepaidUSDS(msg.sender, amountRepaid); }
changeWallets()
to avoid making unnecessary state loads(SLOADS)Details:
Change wallet functionality, which updates the state variable, is implemented in the changeWallets()
function of ManagedWallet.sol
. In order to prevent needless state changes, we can first make sure the input value hasn't changed before reverting.
Proof of Code:
File: src/ManagedWallet.sol 73: function changeWallets() external { // proposedMainWallet calls the function - to make sure it is a valid address. require( msg.sender == proposedMainWallet, "Invalid sender" ); require( block.timestamp >= activeTimelock, "Timelock not yet completed" ); // Set the wallets mainWallet = proposedMainWallet; //@audit confirmationWallet = proposedConfirmationWallet; emit WalletChange(mainWallet, confirmationWallet); // Reset activeTimelock = type(uint256).max; proposedMainWallet = address(0); proposedConfirmationWallet = address(0); }
Optimized code:
73: function changeWallets() external { require(msg.sender == proposedMainWallet, "Invalid sender"); require(block.timestamp >= activeTimelock, "Timelock not yet completed"); - // Set the wallets - mainWallet = proposedMainWallet; - confirmationWallet = proposedConfirmationWallet; - emit WalletChange(mainWallet, confirmationWallet); + // Check if wallets are different before changing state + if (mainWallet != proposedMainWallet || confirmationWallet != proposedConfirmationWallet) { + // Set the wallets + mainWallet = proposedMainWallet; + confirmationWallet = proposedConfirmationWallet; + emit WalletChange(mainWallet, confirmationWallet); + } // Reset activeTimelock = type(uint256).max; proposedMainWallet = address(0); proposedConfirmationWallet = address(0); }
step11()
for better gas optimizationDetails: Combine the two external calls to the VestingWallet contract into a single call. This can save gas by reducing the number of external calls.
By doing this, we save gas by avoiding the repetition of the external call to the same contract. Proof of Code:
File: src/Upkeep.sol 231: function step11() public onlySameContract { uint256 releaseableAmount = VestingWallet(payable(exchangeConfig.teamVestingWallet())).releasable(address(salt)); //@audit two external call // teamVestingWallet actually sends the vested SALT to this contract - which will then need to be sent to the active teamWallet VestingWallet(payable(exchangeConfig.teamVestingWallet())).release(address(salt)); salt.safeTransfer( exchangeConfig.managedTeamWallet().mainWallet(), releaseableAmount ); }
Optimized code: we can cache external call in local variable
function step11() public onlySameContract { - uint256 releaseableAmount = VestingWallet(payable(exchangeConfig.teamVestingWallet())).releasable(address(salt)); + VestingWallet teamVestingWallet = VestingWallet(payable(exchangeConfig.teamVestingWallet())); + uint256 releaseableAmount = teamVestingWallet.releasable(address(salt)); // teamVestingWallet actually sends the vested SALT to this contract - which will then need to be sent to the active teamWallet - VestingWallet(payable(exchangeConfig.teamVestingWallet())).release(address(salt)); + teamVestingWallet.release(address(salt)); salt.safeTransfer( exchangeConfig.managedTeamWallet().mainWallet(), releaseableAmount ); }
Details:
A loop is implemented in the findLiquidatableUsers
function to determine which user has at least mincollateral. In the meantime, the calculation calls an external function on line 26
, which calls each interaction and wastes gas.For better gas efficiency Cache Function Calls outside for loop.
Proof of Code:
File: src/stable/CollateralAndLiquidity.sol 311: function findLiquidatableUsers( uint256 startIndex, uint256 endIndex ) public view returns (address[] memory) { address[] memory liquidatableUsers = new address[](endIndex - startIndex + 1); uint256 count = 0; // Cache uint256 totalCollateralShares = totalShares[collateralPoolID]; uint256 totalCollateralValue = totalCollateralValueInUSD(); if ( totalCollateralValue != 0 ) for ( uint256 i = startIndex; i <= endIndex; i++ ) { address wallet = _walletsWithBorrowedUSDS.at(i); // Determine the minCollateralValue a user needs to have based on their borrowedUSDS uint256 minCollateralValue = (usdsBorrowedByUsers[wallet] * stableConfig.minimumCollateralRatioPercent()) / 100; //@audit cache external call // Determine minCollateral in terms of minCollateralValue uint256 minCollateral = (minCollateralValue * totalCollateralShares) / totalCollateralValue; // Make sure the user has at least minCollateral if ( userShareForPool( wallet, collateralPoolID ) < minCollateral ) liquidatableUsers[count++] = wallet; } // Resize the array to match the actual number of liquidatable positions found address[] memory resizedLiquidatableUsers = new address[](count); for ( uint256 i = 0; i < count; i++ ) resizedLiquidatableUsers[i] = liquidatableUsers[i]; return resizedLiquidatableUsers; }
Optimized code:
311: function findLiquidatableUsers( uint256 startIndex, uint256 endIndex ) public view returns (address[] memory) { address[] memory liquidatableUsers = new address[](endIndex - startIndex + 1); uint256 count = 0; // Cache uint256 totalCollateralShares = totalShares[collateralPoolID]; uint256 totalCollateralValue = totalCollateralValueInUSD(); + minimumCOllateralRatioPercent = stableConfig.minimumCollateralRatioPercent(); if ( totalCollateralValue != 0 ) for ( uint256 i = startIndex; i <= endIndex; i++ ) { address wallet = _walletsWithBorrowedUSDS.at(i); // Determine the minCollateralValue a user needs to have based on their borrowedUSDS - uint256 minCollateralValue = (usdsBorrowedByUsers[wallet] * stableConfig.minimumCollateralRatioPercent()) / 100; + uint256 minCollateralValue = (usdsBorrowedByUsers[wallet] * minimumCollateralRatioPercent) / 100; // Determine minCollateral in terms of minCollateralValue uint256 minCollateral = (minCollateralValue * totalCollateralShares) / totalCollateralValue; // Make sure the user has at least minCollateral if ( userShareForPool( wallet, collateralPoolID ) < minCollateral ) liquidatableUsers[count++] = wallet; } // Resize the array to match the actual number of liquidatable positions found address[] memory resizedLiquidatableUsers = new address[](count); for ( uint256 i = 0; i < count; i++ ) resizedLiquidatableUsers[i] = liquidatableUsers[i]; return resizedLiquidatableUsers; }
The poolsConfig.isWhitelisted
function is an external call within the loop. External calls can be expensive in terms of gas. If possible, consider refactoring the logic to perform the external call outside the loop and store the result in a local variable. This way, you can avoid making the same external call multiple times.
File: src/rewards/RewardsEmitter.sol 57: function addSALTRewards( AddedReward[] calldata addedRewards ) external nonReentrant { uint256 sum = 0; for( uint256 i = 0; i < addedRewards.length; i++ ) { AddedReward memory addedReward = addedRewards[i]; @> require( poolsConfig.isWhitelisted( addedReward.poolID ), "Invalid pool" ); //@audit external call in loop uint256 amountToAdd = addedReward.amountToAdd; if ( amountToAdd != 0 ) { // Update pendingRewards so the SALT can be distributed later pendingRewards[ addedReward.poolID ] += amountToAdd; sum += amountToAdd; } } // Transfer the SALT from the sender for all of the specified rewards if ( sum > 0 ) salt.safeTransferFrom( msg.sender, address(this), sum ); }
_verifySignature
Function in the SigningTools ContractDetails: The _verifySignature function in the SigningTools contract uses inline assembly to split an Ethereum signature into its components (r, s, v).On line20 function Extracting r, s, and v from the signature using assembly. While inline assembly can be efficient in terms of gas usage, it's generally more error-prone and can introduce security vulnerabilities if not implemented correctly. This report suggests a refactoring of the _verifySignature function to use Solidity's native operations, potentially improving security and maintainability with minimal Details on gas costs.
The optimized code removes the use of inline assembly for extracting the v component of the signature. Instead, it directly accesses the 65th byte of the signature array. This approach leverages Solidity's native array handling, which is generally safer and less prone to errors compared to manual memory manipulation in assembly. The v value is adjusted to comply with Ethereum's signature malleability rules (EIP-155), ensuring it falls within the correct range.
Proof of Code:
File: src/SigningTools.sol 11: function _verifySignature(bytes32 messageHash, bytes memory signature ) internal pure returns (bool) { require( signature.length == 65, "Invalid signature length" ); bytes32 r; bytes32 s; uint8 v; assembly { r := mload (add (signature, 0x20)) s := mload (add (signature, 0x40)) v := mload (add (signature, 0x41)) //@audit } address recoveredAddress = ecrecover(messageHash, v, r, s); return (recoveredAddress == EXPECTED_SIGNER); }
Optimized code:
11: function _verifySignature(bytes32 messageHash, bytes memory signature ) internal pure returns (bool) { require( signature.length == 65, "Invalid signature length" ); bytes32 r; bytes32 s; uint8 v; assembly { r := mload (add (signature, 0x20)) s := mload (add (signature, 0x40)) - v := mload (add (signature, 0x41)) } + //directly accesses the 65th byte of the signature array + v = uint8(signature[64]); + // Adjust v for Ethereum's malleability rules + if (v < 27) v += 27; address recoveredAddress = ecrecover(messageHash, v, r, s); return (recoveredAddress == EXPECTED_SIGNER); }
Details: We can further optimize the code by calculating the common part only once and reusing it.
the results of the external calls (totalStaked, baseQuorumTimes1000, and totalSupply) are cached in local variables, reducing the number of external calls made. Caching external calls can significantly optimize gas usage, especially when they involve complex calculations or storage reads. Proof of Code:
File: src/dao/Proposals.sol 317: function requiredQuorumForBallotType( BallotType ballotType ) public view returns (uint256 requiredQuorum) { // The quorum will be specified as a percentage of the total amount of SALT staked uint256 totalStaked = staking.totalShares( PoolUtils.STAKED_SALT ); require( totalStaked != 0, "SALT staked cannot be zero to determine quorum" ); if ( ballotType == BallotType.PARAMETER ) requiredQuorum = ( 1 * totalStaked * daoConfig.baseBallotQuorumPercentTimes1000()) / ( 100 * 1000 ); //@audit cache this calculation else if ( ( ballotType == BallotType.WHITELIST_TOKEN ) || ( ballotType == BallotType.UNWHITELIST_TOKEN ) ) requiredQuorum = ( 2 * totalStaked * daoConfig.baseBallotQuorumPercentTimes1000()) / ( 100 * 1000 ); else // All other ballot types require 3x multiple of the baseQuorum requiredQuorum = ( 3 * totalStaked * daoConfig.baseBallotQuorumPercentTimes1000()) / ( 100 * 1000 ); // Make sure that the requiredQuorum is at least 0.50% of the total SALT supply. // Circulating supply after the first 45 days of emissions will be about 3 million - so this would require about 16% of the circulating // SALT to be staked and voting to pass a proposal (including whitelisting) 45 days after deployment.. uint256 totalSupply = ERC20(address(exchangeConfig.salt())).totalSupply(); uint256 minimumQuorum = totalSupply * 5 / 1000; if ( requiredQuorum < minimumQuorum ) requiredQuorum = minimumQuorum; }
Optimized code: the calculation is cached in the variable quorum, which is then reused in each branch of the conditional statements.
function requiredQuorumForBallotType(BallotType ballotType) public view returns (uint256 requiredQuorum) { // The quorum will be specified as a percentage of the total amount of SALT staked uint256 totalStaked = staking.totalShares(PoolUtils.STAKED_SALT); require(totalStaked != 0, "SALT staked cannot be zero to determine quorum"); + uint256 quorum = totalStaked * daoConfig.baseBallotQuorumPercentTimes1000() / (100*1000); - if ( ballotType == BallotType.PARAMETER ) - requiredQuorum = ( 1 * totalStaked * daoConfig.baseBallotQuorumPercentTimes1000()) / ( 100 * 1000 ); - else if ( ( ballotType == BallotType.WHITELIST_TOKEN ) || ( ballotType == BallotType.UNWHITELIST_TOKEN ) ) - requiredQuorum = ( 2 * totalStaked * daoConfig.baseBallotQuorumPercentTimes1000()) / ( 100 * 1000 ); - else - // All other ballot types require 3x multiple of the baseQuorum - requiredQuorum = ( 3 * totalStaked * daoConfig.baseBallotQuorumPercentTimes1000()) / ( 100 * 1000 ); + if (ballotType == BallotType.PARAMETER) { + requiredQuorum = (1 * quorum); + } else if (ballotType == BallotType.WHITELIST_TOKEN || ballotType == BallotType.UNWHITELIST_TOKEN) { + requiredQuorum = (2 * quorum); + } else { + // All other ballot types require 3x multiple of the baseQuorum + requiredQuorum = (3 * quorum); + } // Cache external call result ERC20 saltToken = ERC20(address(exchangeConfig.salt())); uint256 totalSupply = saltToken.totalSupply(); // Cache external call result uint256 minimumQuorum = totalSupply * 5 / 1000; // Use cached value if (requiredQuorum < minimumQuorum) requiredQuorum = minimumQuorum; }
Details: The instances below point to the second+ access of a state variable within a function. Caching of a state variable replaces each Gwarmaccess (100 gas) with a much cheaper stack read. Other less obvious fixes/optimizations include having local memory caches of state variable structs, or having local caches of state variable contracts/addresses.
Please note these instances were not included in the bots reports Proof of Code:
File: src/launch/Airdrop.sol 74: function claimAirdrop() external nonReentrant { require( claimingAllowed, "Claiming is not allowed yet" ); require( isAuthorized(msg.sender), "Wallet is not authorized for airdrop" ); require( ! claimed[msg.sender], "Wallet already claimed the airdrop" ); // Have the Airdrop contract stake a specified amount of SALT and then transfer it to the user staking.stakeSALT( saltAmountForEachUser ); staking.transferStakedSaltFromAirdropToUser( msg.sender, saltAmountForEachUser ); //@audit saltAmountEachUser claimed[msg.sender] = true; }
Proof of code:
File: src/stable/Liquidizer.sol 101: function _possiblyBurnUSDS() internal { + _usdsThatShouldBeBurned = usdsThatShouldBeBurned; // Check if there is USDS to burn - if ( usdsThatShouldBeBurned == 0 ) + if ( _usdsThatShouldBeBurned == 0 ) return; uint256 usdsBalance = usds.balanceOf(address(this)); - if ( usdsBalance >= usdsThatShouldBeBurned ) + if ( usdsBalance >= _usdsThatShouldBeBurned ) { // Burn only up to usdsThatShouldBeBurned. // Leftover USDS will be kept in this contract in case it needs to be burned later. - _burnUSDS( usdsThatShouldBeBurned ); + _burnUSDS( _usdsThatShouldBeBurned ); usdsThatShouldBeBurned = 0; } else { // The entire usdsBalance will be burned - but there will still be an outstanding balance to burn later _burnUSDS( usdsBalance ); - usdsThatShouldBeBurned -= usdsBalance; + usdsThatShouldBeBurned = _usdsThatShouldBeBurned - usdsBalance; // As there is a shortfall in the amount of USDS that can be burned, liquidate some Protocol Owned Liquidity and // send the underlying tokens here to be swapped to USDS dao.withdrawPOL(salt, usds, PERCENT_POL_TO_WITHDRAW); dao.withdrawPOL(dai, usds, PERCENT_POL_TO_WITHDRAW); } }
File: src/price_feed/PriceAggregator.sol 65: function changeMaximumPriceFeedPercentDifferenceTimes1000(bool increase) public onlyOwner { + _maximumPriceFeedPercentDifferenceTimes1000 = maximumPriceFeedPercentDifferenceTimes1000; if (increase) { - if (_maximumPriceFeedPercentDifferenceTimes1000 < 7000) //@audit cache maximumPriceFeedPercentDifferenceTimes1000 + if (_maximumPriceFeedPercentDifferenceTimes1000 < 7000) - maximumPriceFeedPercentDifferenceTimes1000 += 500; + maximumPriceFeedPercentDifferenceTimes1000 = maximumPriceFeedPercentDifferenceTimes1000 + 500; } else { - if (maximumPriceFeedPercentDifferenceTimes1000 > 1000) + if (_maximumPriceFeedPercentDifferenceTimes1000 > 1000) - maximumPriceFeedPercentDifferenceTimes1000 -= 500; + maximumPriceFeedPercentDifferenceTimes1000 =maximumPriceFeedPercentDifferenceTimes1000 - 500; } emit MaximumPriceFeedPercentDifferenceChanged(maximumPriceFeedPercentDifferenceTimes1000); }
File: src/price_feed/PriceAggregator.sol 82: function changePriceFeedModificationCooldown(bool increase) public onlyOwner { + _priceFeedModificationCooldown = priceFeedModificationCooldown; if (increase) { - if (priceFeedModificationCooldown < 45 days) //@audit cache priceFeedModificationCooldown - priceFeedModificationCooldown += 5 days; + if (_priceFeedModificationCooldown < 45 days) + priceFeedModificationCooldown = priceFeedModificationCooldown + 5 days; } else { - if (priceFeedModificationCooldown > 30 days) - priceFeedModificationCooldown -= 5 days; + if (_priceFeedModificationCooldown > 30 days) + priceFeedModificationCooldown =priceFeedModificationCooldown - 5 days; } emit SetPriceFeedCooldownChanged(priceFeedModificationCooldown); }
numberAuthorized()
is called multiple times -> reduce gas costs by avoiding redundant function callsDetails: the function numberAuthorized() is called multiple times and its result remains constant during the execution of the function, we can optimize the code by caching its result.
Proof of Code:
File: src/launch/Airdrop.sol 56: function allowClaiming() external { require( msg.sender == address(exchangeConfig.initialDistribution()), "Airdrop.allowClaiming can only be called by the InitialDistribution contract" ); require( ! claimingAllowed, "Claiming is already allowed" ); require(numberAuthorized() > 0, "No addresses authorized to claim airdrop."); // All users receive an equal share of the airdrop. uint256 saltBalance = salt.balanceOf(address(this)); saltAmountForEachUser = saltBalance / numberAuthorized(); //@audit cache numberAuthorized() // Have the Airdrop approve max so that that xSALT (staked SALT) can later be transferred to airdrop recipients. salt.approve( address(staking), saltBalance ); claimingAllowed = true; }
Optimized code: the result of numberAuthorized() is cached in the variable authorizedCount, and this value is then reused in the subsequent calculations. Caching the result of a function that does not change during the function's execution can help reduce gas costs by avoiding redundant function calls.
56: function allowClaiming() external { require( msg.sender == address(exchangeConfig.initialDistribution()), "Airdrop.allowClaiming can only be called by the InitialDistribution contract" ); require( ! claimingAllowed, "Claiming is already allowed" ); + // Cache the result of numberAuthorized + uint256 authorizedCount = numberAuthorized(); - require(numberAuthorized() > 0, "No addresses authorized to claim airdrop."); + require(authorizedCount > 0, "No addresses authorized to claim airdrop."); // All users receive an equal share of the airdrop. uint256 saltBalance = salt.balanceOf(address(this)); - saltAmountForEachUser = saltBalance / numberAuthorized(); + saltAmountForEachUser = saltBalance / authorizedCount; // Have the Airdrop approve max so that that xSALT (staked SALT) can later be transferred to airdrop recipients. salt.approve( address(staking), saltBalance ); claimingAllowed = true; }
In Proposals.sol::proposeTokenUnwhitelisting
check multi token to find token whitelist or not. During this check require statement call external function multiple time and result consume more gas. So we can cache multiple external call in local cache and use whenever we needed.
File: src/dao/Proposals.sol 180: function proposeTokenUnwhitelisting( IERC20 token, string calldata tokenIconURL, string calldata description ) external nonReentrant returns (uint256 ballotID) { + address wbtcAddress = address(exchangeConfig.wbtc()); + address wethAddress = address(exchangeConfig.weth()); - require( poolsConfig.tokenHasBeenWhitelisted(token, exchangeConfig.wbtc(), exchangeConfig.weth()), "Can only unwhitelist a whitelisted token" ); //@audit two external call + require( poolsConfig.tokenHasBeenWhitelisted(token, wbtcAddress, wethAddress), "Can only unwhitelist a whitelisted token" ); - require( address(token) != address(exchangeConfig.wbtc()), "Cannot unwhitelist WBTC" ); //@audit - require( address(token) != address(exchangeConfig.weth()), "Cannot unwhitelist WETH" ); //@audit + require( address(token) != address(wbtcAddress), "Cannot unwhitelist WBTC" ); + require( address(token) != address(wethAddress), "Cannot unwhitelist WETH" ); require( address(token) != address(exchangeConfig.dai()), "Cannot unwhitelist DAI" ); require( address(token) != address(exchangeConfig.usds()), "Cannot unwhitelist USDS" ); require( address(token) != address(exchangeConfig.salt()), "Cannot unwhitelist SALT" ); string memory ballotName = string.concat("unwhitelist:", Strings.toHexString(address(token)) ); return _possiblyCreateProposal( ballotName, BallotType.UNWHITELIST_TOKEN, address(token), 0, tokenIconURL, description ); }
Details: When fetching data from a storage location, assigning the data to a memory variable causes all fields of the struct/array to be read from storage, which incurs a Gcoldsload (2100 gas) for each field of the struct/array. If the fields are read from the new memory variable, they incur an additional MLOAD rather than a cheap stack read. Instead of declearing the variable with the memory keyword, declaring the variable with the storage keyword and caching any fields that need to be re-read in stack variables, will be much cheaper, only incuring the Gcoldsload for the fields actually read. The only time it makes sense to read the whole struct/array into a memory variable, is if the full struct/array is being returned by the function, is being passed to a function that requires memory, or if the array/struct is being read from another memory array/struct
Proof of Code:
File: src/dao/Proposals.sol 239: function castVote( uint256 ballotID, Vote vote ) external nonReentrant { Ballot memory ballot = ballots[ballotID]; //@audit use storage // Require that the ballot is actually live require( ballot.ballotIsLive, "The specified ballot is not open for voting" ); // Make sure that the vote type is valid for the given ballot if ( ballot.ballotType == BallotType.PARAMETER ) require( (vote == Vote.INCREASE) || (vote == Vote.DECREASE) || (vote == Vote.NO_CHANGE), "Invalid VoteType for Parameter Ballot" ); else // If a Ballot is not a Parameter Ballot, it is an Approval ballot require( (vote == Vote.YES) || (vote == Vote.NO), "Invalid VoteType for Approval Ballot" ); // Make sure that the user has voting power before proceeding. // Voting power is equal to their userShare of STAKED_SALT. // If the user changes their stake after voting they will have to recast their vote. uint256 userVotingPower = staking.userShareForPool( msg.sender, PoolUtils.STAKED_SALT ); require( userVotingPower > 0, "Staked SALT required to vote" ); // Remove any previous votes made by the user on the ballot @> UserVote memory lastVote = _lastUserVoteForBallot[ballotID][msg.sender]; //@audit use storage // Undo the last vote? if ( lastVote.votingPower > 0 ) _votesCastForBallot[ballotID][lastVote.vote] -= lastVote.votingPower; // Update the votes cast for the ballot with the user's current voting power _votesCastForBallot[ballotID][vote] += userVotingPower; // Remember how the user voted in case they change their vote later _lastUserVoteForBallot[ballotID][msg.sender] = UserVote( vote, userVotingPower ); emit VoteCast(msg.sender, ballotID, vote, userVotingPower); }
File: src/dao/Proposals.sol 385: function canFinalizeBallot( uint256 ballotID ) external view returns (bool) { @> Ballot memory ballot = ballots[ballotID]; //@audit if ( ! ballot.ballotIsLive ) return false; // Check that the minimum duration has passed if (block.timestamp < ballot.ballotMinimumEndTime ) return false; // Check that the required quorum has been reached if ( totalVotesCastForBallot(ballotID) < requiredQuorumForBallotType( ballot.ballotType )) return false; return true; }
File: src/staking/Staking.sol 171: function unstakesForUser( address user ) external view returns (Unstake[] memory) { // Check to see how many unstakes the user has @> uint256[] memory unstakeIDs = _userUnstakeIDs[user]; //@audit Use storage if ( unstakeIDs.length == 0 ) return new Unstake[](0); // Return them all return unstakesForUser( user, 0, unstakeIDs.length - 1 ); }
#0 - c4-judge
2024-02-03T14:22:45Z
Picodes marked the issue as grade-a
#1 - c4-sponsor
2024-02-08T12:26:28Z
othernet-global (sponsor) acknowledged