Platform: Code4rena
Start Date: 13/05/2022
Pot Size: $30,000 USDC
Total HM: 8
Participants: 65
Period: 3 days
Judge: hickuphh3
Total Solo HM: 1
Id: 125
League: ETH
Rank: 3/65
Findings: 5
Award: $3,059.61
π Selected for report: 0
π Solo Findings: 0
π Selected for report: jonah1005
Also found by: Picodes, WatchPug, berndartmueller, sorrynotsorry
1225.2497 USDC - $1,225.25
require(withdrawAmount >= _amount.percentMul(99_00), Errors.VT_WITHDRAW_AMOUNT_MISMATCH);
The GeneralVault.sol
contract comes with a on-chain slippage/loss control to ensure the output amount is no more than 1% less of the requested amount.
This can be a problem when the wrapped asset in underlying protocol is now trading at a discount or loss.
For example, if Lido's setETH is trading at a more than 1% of discount on Curve (which is the case at the moment of writing), trying to withdraw as ETH will always fail with error: VT_WITHDRAW_AMOUNT_MISMATCH
.
Since GeneralVault
is the super class for all kinds of vaults, when the underlying protocol is suffering a loss of > 1% or the wrapped token is trading at a discount > 1% for a long period of time, this can be major problem that pervents all users from withdrawing.
In essence, causing the funds to be frozen in the contract.
Consider adding a minAmountOut
as slippage control and allow the user to decide the minimum acceptable amount in outToken.
#0 - sforman2000
2022-05-18T02:29:43Z
Duplicate of https://github.com/code-423n4/2022-05-sturdy-findings/issues/133 (high risk)
π Selected for report: pedroais
Also found by: 0x4non, 0x52, 0xf15ers, 0xliumin, CertoraInc, Dravee, GimelSec, IllIllI, MaratCerby, StErMi, TerrierLover, WatchPug, berndartmueller, cccz, dipp, fatherOfBlocks, hake, hickuphh3, hyh, isamjay, mtz, oyc_109, p4st13r4, peritoflores, rotcivegaf, saian, simon135, sorrynotsorry, sseefried, tabish, z3s
14.8433 USDC - $14.84
function _withdrawFromYieldPool( address _asset, uint256 _amount, address _to ) internal override returns (uint256) { address LIDO = _addressesProvider.getAddress('LIDO'); if (_asset == address(0)) { // Case of ETH withdraw request from user, so exchange stETH -> ETH via curve uint256 receivedETHAmount = CurveswapAdapter.swapExactTokensForTokens( _addressesProvider, _addressesProvider.getAddress('STETH_ETH_POOL'), LIDO, ETH, _amount, 200 ); // send ETH to user (bool sent, bytes memory data) = address(_to).call{value: receivedETHAmount}(''); return receivedETHAmount; require(sent, Errors.VT_COLLATERAL_WITHDRAW_INVALID); } else { // Case of stETH withdraw request from user, so directly send require(_asset == LIDO, Errors.VT_COLLATERAL_WITHDRAW_INVALID); IERC20(LIDO).safeTransfer(_to, _amount); } return _amount; }
When calling LidoVault#withdrawCollateral()
, LidoVault#_withdrawFromYieldPool()
will called internally.
At L140, when the _to
address is a smart contract that can not receive ETH, the transfer will fail and the local bool variable sent
will be false
.
There is a check to revert the transaction when send == false
at L142.
However, L142 and L141 are in a wrong order. As a result, the user will lose funds as L141 returned and completed the transaction before the check at L142.
Change to:
function _withdrawFromYieldPool( address _asset, uint256 _amount, address _to ) internal override returns (uint256) { address LIDO = _addressesProvider.getAddress('LIDO'); if (_asset == address(0)) { // Case of ETH withdraw request from user, so exchange stETH -> ETH via curve uint256 receivedETHAmount = CurveswapAdapter.swapExactTokensForTokens( _addressesProvider, _addressesProvider.getAddress('STETH_ETH_POOL'), LIDO, ETH, _amount, 200 ); // send ETH to user (bool sent, bytes memory data) = address(_to).call{value: receivedETHAmount}(''); require(sent, Errors.VT_COLLATERAL_WITHDRAW_INVALID); return receivedETHAmount; } else { // Case of stETH withdraw request from user, so directly send require(_asset == LIDO, Errors.VT_COLLATERAL_WITHDRAW_INVALID); IERC20(LIDO).safeTransfer(_to, _amount); } return _amount; }
#0 - sforman2000
2022-05-18T03:10:55Z
π Selected for report: berndartmueller
Also found by: WatchPug
if (_amount == type(uint256).max) { uint256 decimal = IERC20Detailed(_asset).decimals(); _amount = _amountToWithdraw.mul(this.pricePerShare()).div(10**decimal); }
Per the comment:
The asset address for collateral * _asset = 0x0000000000000000000000000000000000000000 means to use ETH as collateral
However, if the user set amount as type(uint256).max
and _asset
is set as the native token at the same time, the transaction will always fail at L122 because IERC20Detailed(_asset).decimals()
will revert.
Change to:
if (_amount == type(uint256).max && _asset != address(0)) {
#0 - sforman2000
2022-05-18T02:35:25Z
π Selected for report: IllIllI
Also found by: 0x1f8b, 0x4non, 0xNazgul, 0xf15ers, 0xkatana, 0xliumin, AlleyCat, BouSalman, Dravee, Funen, GimelSec, Hawkeye, MaratCerby, Picodes, StErMi, TerrierLover, WatchPug, Waze, berndartmueller, bobirichman, cryptphi, csanuragjain, defsec, delfin454000, dipp, fatherOfBlocks, hake, hickuphh3, hyh, joestakey, kebabsec, mics, mtz, oyc_109, p4st13r4, p_crypt0, robee, rotcivegaf, sikorico, simon135, sorrynotsorry, tintin
45.3155 USDC - $45.32
It's a best practice to use the latest compiler version.
The specified minimum compiler version is quite old. Older compilers might be susceptible to some bugs. We recommend changing the solidity version pragma to the latest version to enforce the use of an up to date compiler.
List of known compiler bugs and their severity can be found here: https://etherscan.io/solcbuginfo
Across the contracts, there are certain critical operations that change critical values that affect the users of the protocol.
It's a best practice for these setter functions to emit events to record these changes on-chain for off-chain monitors/tools/interfaces to register the updates and react if necessary.
Instances include:
function setConfiguration(address _lpToken, uint256 _poolId) external onlyAdmin { require(internalAssetToken == address(0), Errors.VT_INVALID_CONFIGURATION); convexBooster = 0xF403C135812408BFbE8713b5A23a04b3D48AAE31; curveLPToken = _lpToken; convexPoolId = _poolId; SturdyInternalAsset _interalToken = new SturdyInternalAsset( string(abi.encodePacked('Sturdy ', IERC20Detailed(_lpToken).symbol())), string(abi.encodePacked('c', IERC20Detailed(_lpToken).symbol())), IERC20Detailed(_lpToken).decimals() ); internalAssetToken = address(_interalToken); }
function setExchangeToken(address _token) external onlyAdmin { require(_token != address(0), Errors.VT_INVALID_CONFIGURATION); _exchangeToken = _token; }
#0 - HickupHH3
2022-06-06T06:23:07Z
both are NC
π Selected for report: IllIllI
Also found by: 0v3rf10w, 0x1f8b, 0x4non, 0xNazgul, 0xf15ers, 0xkatana, 0xliumin, Cityscape, Dravee, Fitraldys, Funen, GimelSec, Hawkeye, JC, MaratCerby, SooYa, StErMi, Tomio, WatchPug, Waze, bobirichman, defsec, delfin454000, fatherOfBlocks, hake, hansfriese, hickuphh3, ignacio, joestakey, kebabsec, mics, mtz, oyc_109, robee, rotcivegaf, samruna, sikorico, simon135, z3s
93.4733 USDC - $93.47
[S]: Suggested optimation, save a decent amount of gas without compromising readability;
[M]: Minor optimation, the amount of gas saved is minor, change when you see fit;
[N]: Non-preferred, the amount of gas saved is at cost of readability, only apply when gas saving is a top priority.
++i
is more efficient than i++
Using ++i
is more gas efficient than i++
, especially in for loops.
For example:
for (uint256 i = 0; i < extraRewardsLength; i++) { address _extraReward = IConvexBaseRewardPool(baseRewardPool).extraRewards(i); address _rewardToken = IRewards(_extraReward).rewardToken(); _transferYield(_rewardToken); }
Can be changed to:
for (uint256 i = 0; i < extraRewardsLength; ++i) {
for (uint256 i = 0; i < length; i++) {
for (uint256 i = 0; i < _count; i++) {
for (uint256 i = 0; i < assetYields.length; i++) {
for (uint256 i = 0; i < length; i++) {
uint256
variables to 0
is redundantFor example:
for (uint256 i = 0; i < extraRewardsLength; i++) { address _extraReward = IConvexBaseRewardPool(baseRewardPool).extraRewards(i); address _rewardToken = IRewards(_extraReward).rewardToken(); _transferYield(_rewardToken); }
Can be changed to:
for (uint256 i; i < extraRewardsLength; i++) {
for (uint256 i = 0; i < length; i++) {
for (uint256 i = 0; i < _count; i++) {
for (uint256 i = 0; i < assetYields.length; i++) {
for (uint256 i = 0; i < length; i++) {
if (stAssetBalance >= aTokenBalance) return stAssetBalance.sub(aTokenBalance); return 0;
if (stAssetBalance > aTokenBalance) return stAssetBalance - aTokenBalance; return 0;
for (uint256 i = 0; i < length; i++) { assetYields[i].asset = assets[i]; if (i != length - 1) { // Distribute wethAmount based on percent of asset volume assetYields[i].amount = _WETHAmount.percentMul( volumes[i].mul(PercentageMath.PERCENTAGE_FACTOR).div(totalVolume) ); extraWETHAmount = extraWETHAmount.sub(assetYields[i].amount); } else { // without calculation, set remained extra amount assetYields[i].amount = extraWETHAmount; } }
Comparing to i != length - 1
, i < length
can save 3 gas.
And length - 1
is being recalculated each in the for loop, which is unnecessary.
for (uint256 i = 0; i < length; i++) { assetYields[i].asset = assets[i]; if (i < length) { // Distribute wethAmount based on percent of asset volume assetYields[i].amount = _WETHAmount.percentMul( volumes[i].mul(PercentageMath.PERCENTAGE_FACTOR).div(totalVolume) ); extraWETHAmount = extraWETHAmount.sub(assetYields[i].amount); } else { // without calculation, set remained extra amount assetYields[i].amount = extraWETHAmount; } }
Similiar issue also exists in:
Reading array length at each iteration of the loop takes 6 gas (3 for mload and 3 to place memory_offset) in the stack.
Caching the array length in the stack saves around 3 gas per iteration.
Instances include:
YieldManager.sol#distributeYield()
for (uint256 i = 0; i < assetYields.length; i++)
SturdyInternalAsset _interalToken = new SturdyInternalAsset( string(abi.encodePacked('Sturdy ', IERC20Detailed(_lpToken).symbol())), string(abi.encodePacked('c', IERC20Detailed(_lpToken).symbol())), IERC20Detailed(_lpToken).decimals() );
IERC20Detailed(_lpToken).symbol()
can be cached and reused to save gas.
string memory _symbol = IERC20Detailed(_lpToken).symbol(); SturdyInternalAsset _interalToken = new SturdyInternalAsset( string(abi.encodePacked('Sturdy ', _symbol)), string(abi.encodePacked('c', _symbol)), IERC20Detailed(_lpToken).decimals() );
Every call to an external contract costs a decent amount of gas. For optimization of gas usage, external call results should be reused if possible.
For example:
if (_amount == type(uint256).max) { uint256 decimal = IERC20Detailed(_asset).decimals(); _amount = _amountToWithdraw.mul(this.pricePerShare()).div(10**decimal); }
Can be changed to:
if (_amount == type(uint256).max) { uint256 decimal = IERC20Detailed(_asset).decimals(); _amount = _amountToWithdraw.mul(1e18).div(10**decimal); }