Platform: Code4rena
Start Date: 25/11/2021
Pot Size: $80,000 USDC
Total HM: 35
Participants: 32
Period: 7 days
Judge: GalloDaSballo
Total Solo HM: 27
Id: 59
League: ETH
Rank: 9/32
Findings: 5
Award: $2,652.88
π Selected for report: 12
π Solo Findings: 1
π Selected for report: thank_you
Also found by: 0x0x0x, Koustre, Meta0xNull, WatchPug, cmichel, defsec, harleythedog, hyh, leastwood, pauliax, pmerkleplant, tabish, xYrYuYx
hyh
Liquidity provision can happen at a manipulated price which leads to immediate loss for liquidity provider (i.e. IL happens right after liquidity provision in this case).
This yields direct loss for an account owner, for example (schematically): 0. Suppose tokens A and B has equal value, i.e. market is 1:1 token A:B
addLiquidity
is external without access modifiers, so is meant to be user facing as well as be part of the system.
https://github.com/code-423n4/2021-11-malt/blob/main/src/contracts/DexHandlers/UniswapHandler.sol#L185
Also, it has 5% hard coded slippage: https://github.com/code-423n4/2021-11-malt/blob/main/src/contracts/DexHandlers/UniswapHandler.sol#L206
The issue here is hard coding the slippage allows front running of the direct usage of the addLiquidity
function.
In this case a maxSlippage function argument replacing hard coded 5% level will alleviate the front running issue.
Now:
function addLiquidity() external returns ( ... maltBalance.mul(95).div(100), rewardBalance.mul(95).div(100),
To be:
function addLiquidity(uint256 minAmountUsedBP) external returns ( maltBalance.mul(minAmountUsedBP).div(10000), rewardBalance.mul(minAmountUsedBP).div(10000),
minAmountUsedBP can be set to high enough value, say 9950
, when the function be called from RewardReinvestor
's provideReinvest
and splitReinvest
.
#0 - 0xScotch
2021-12-10T00:21:11Z
#219
#1 - GalloDaSballo
2022-01-24T00:49:45Z
Duplicate of #219
π Selected for report: 0x0x0x
Also found by: harleythedog, hyh
hyh
AbstractRewardMine and RewardDistributor both have internal reward token ledger and hold user's tokens in custody. The beneficiaries of these contracts expect to receive particular reward token from them.
An ability to substitute the token when system is fully operational is equivalent to an ability to remove all the funds held with the contracts. A malicious owner can substitute reward token to be any contract, say to be a new token without any value, affectively voiding all the usersβ holdings, as all the withdrawals will be carried out in the terms of the new, potentially worthless token.
Also, there is an issue of new vs old token decimals difference. As contracts hold the record of current amounts, it should recalculate them on reward token substitution. Otherwise amounts due could be miscalculated by magnitude of the difference between the decimals.
Both contracts have ADMIN_ROLE
methods for unconditionally changing contract's reward token.
AbstractRewardMine: https://github.com/code-423n4/2021-11-malt/blob/main/src/contracts/AbstractRewardMine.sol#L273
RewardDistributor: https://github.com/code-423n4/2021-11-malt/blob/main/src/contracts/RewardSystem/RewardDistributor.sol#L317
RewardDistributor has reward token set during initialization: https://github.com/code-423n4/2021-11-malt/blob/main/src/contracts/RewardSystem/RewardDistributor.sol#L62
So it's advised to remove RewardDistributor
's setRewardToken
method.
AbstractRewardMine has _initialSetup setup function, that isn't called in current implementation, but also does the initialization.
It's advised to remove AbstractRewardMine
's setRewardToken
method as well.
In order for token to be immutable, the _initialSetup
function should implement initializer pattern in some way, i.e. allow only one time setting of the configuration variables.
Or, if other variables need to be changed, it should control token setup directly, for example:
Now:
function _initialSetup(address _rewardToken, address _miningService) internal { _roleSetup(MINING_SERVICE_ROLE, _miningService); _roleSetup(REWARD_MANAGER_ROLE, _miningService); rewardToken = ERC20(_rewardToken); miningService = _miningService; }
To be:
function _initialSetup(address _rewardToken, address _miningService) internal { _roleSetup(MINING_SERVICE_ROLE, _miningService); _roleSetup(REWARD_MANAGER_ROLE, _miningService); if (rewardToken == address(0)) { rewardToken = ERC20(_rewardToken); } miningService = _miningService; }
Although in this case it is advised to change function name as _initialSetup
one implies allowing using it only once, i.e. some adherence to the initializer pattern.
If the reward token change is a part of planned setup, an introduction of additional logic is required, for example the contracts need to recalculate future token claims.
#0 - 0xScotch
2021-12-08T12:44:20Z
#285
#1 - GalloDaSballo
2022-01-13T01:07:00Z
Duplicate of #285
π Selected for report: hyh
1103.7569 USDC - $1,103.76
hyh
BONDING_ROLE cannot be managed after it was initialized.
setBonding
set the wrong role via _swapRole:
https://github.com/code-423n4/2021-11-malt/blob/main/src/contracts/MiningService.sol#L116
Set BONDING_ROLE
instead of REINVESTOR_ROLE
in setBonding
function:
Now:
function setBonding(address _bonding) public onlyRole(ADMIN_ROLE, "Must have admin privs") { require(_bonding != address(0), "Cannot use address 0"); _swapRole(_bonding, bonding, REINVESTOR_ROLE); bonding = _bonding; }
To be:
function setBonding(address _bonding) public onlyRole(ADMIN_ROLE, "Must have admin privs") { require(_bonding != address(0), "Cannot use address 0"); _swapRole(_bonding, bonding, BONDING_ROLE); bonding = _bonding; }
#0 - GalloDaSballo
2022-01-23T17:05:02Z
Agree with the finding
36.2087 USDC - $36.21
hyh
Being instantiated with wrong configuration, the contract is inoperable and deploy gas costs will be lost. If misconfiguration is noticed too late the various types of malfunctions become possible.
The checks for zero addresses during contract construction and initialization are considered to be the best-practice.
Now basically all the contract do not check for correctness of constructor arguments:
https://github.com/code-423n4/2021-11-malt/blob/main/src/contracts/Malt.sol#L29
https://github.com/code-423n4/2021-11-malt/blob/main/src/contracts/TransferService.sol#L25
https://github.com/code-423n4/2021-11-malt/blob/main/src/contracts/ForfeitHandler.sol#L31
https://github.com/code-423n4/2021-11-malt/blob/main/src/contracts/MiningService.sol#L30
...
Add zero-address checks and key non-address variables checks in all contract constructors. Small increase of gas costs are far out weighted by wrong deploy costs savings and additional coverage against misconfiguration.
#0 - GalloDaSballo
2022-01-18T14:40:04Z
Input validation is a industry standard finding with low severity
π Selected for report: hyh
367.919 USDC - $367.92
hyh
A condition requires that calculated retrievable amount shouldn't be too big. If it is the function fails and the remaining portion of commitment is frozen.
As the amount is calculated by the system a user cannot do anything to retrieve remaining part of commitment, if any.
claimArbitrage
fails if calculated redemption is higher than remaining commitment:
https://github.com/code-423n4/2021-11-malt/blob/main/src/contracts/Auction.sol#L230
userClaimableArbTokens
calculated amount can be bigger than remaining user funds:
https://github.com/code-423n4/2021-11-malt/blob/main/src/contracts/Auction.sol#L279
If the freezing of remainder amount is not intentional then substitute require with ceiling the amount to be retrieved with the remaining part.
Now:
require(redemption <= remaining.add(1), "Cannot claim more tokens than available");
To be:
if (redemption > remaining) { redemption = remaining; }
#0 - GalloDaSballo
2022-01-24T00:48:18Z
The sponsor confirms, personally I'd recommend the sponsor to consider whether this is a good idea as this means that users will renounce the rest of the rewards, which could an unintended consequences of using this recommended mitigation steps.
π Selected for report: hyh
367.919 USDC - $367.92
hyh
If Malt token be set to have lower decimals the incentives will be too big to be issued and DAO advance epoch and StabilizerNode auction start functions will fail, the system will have to be redeployed.
For example, if Malt was set to have 6 decimals like USDC, then 100*1e18 StabilizerNode defaultIncentive will be 100 trillions Malt.
Now some parts of the system use malt.decimals()
(SwingTrader, UniswapHandler), some (StabilizerNode, DAO) use 18.
DAO advanceIncentive:
https://github.com/code-423n4/2021-11-malt/blob/main/src/contracts/DAO.sol#L60
StabilizerNode defaultIncentive:
stabilize function https://github.com/code-423n4/2021-11-malt/blob/main/src/contracts/StabilizerNode.sol#L145
calls _startAuction in low exchangeRate case, minting defaultIncentive * 10**18 = 100 * 1e18 Malt to the sender as a caller fee. https://github.com/code-423n4/2021-11-malt/blob/main/src/contracts/StabilizerNode.sol#L344
If Malt decimals are meant to be set to 18, add a constant variable and use it across the system to save gas.
If the flexibility is desired malt.decimals()
to be used, in a form of contract storage variable for gas optimization (decimals()
can be saved to storage once on initialization, and read from there afterwards).
#0 - GalloDaSballo
2022-01-24T00:53:20Z
Because:
I agree with he finding as it shows that some of the code is not consistently handling the possibility of having different decimals.
I believe the system as presented has no risk, however the warden has shown an improvement that is valid.
π Selected for report: robee
Also found by: GiveMeTestEther, WatchPug, hyh, ye0lde
hyh
Gas is overspent on storage access.
The commitment
and maltPurchased
amounts are read from storage twice:
for require: https://github.com/code-423n4/2021-11-malt/blob/main/src/contracts/Auction.sol#L791
and for update: https://github.com/code-423n4/2021-11-malt/blob/main/src/contracts/Auction.sol#L797
Add memory variables and use them in the logic:
uint256 memory commitment = auction.accountCommitments[account].commitment uint256 memory maltPurchased = auction.accountCommitments[account].maltPurchased
#0 - 0xScotch
2021-12-09T22:55:55Z
#161
#1 - GalloDaSballo
2021-12-28T00:36:09Z
Duplicate of #161
15.2616 USDC - $15.26
hyh
Gas is overspent on the function call.
As neither Malt, nor Reward token decimals change since initial setup, both can be saved and accessed as a storage variable instead of calling decimals() function each time. https://github.com/code-423n4/2021-11-malt/blob/main/src/contracts/DexHandlers/UniswapHandler.sol#L94
Save both decimals values to contract storage variables and use them instead of decimals()
function.
#0 - 0xScotch
2021-12-08T18:29:07Z
#371
#1 - GalloDaSballo
2021-12-30T16:56:56Z
Duplicate of #371
15.2616 USDC - $15.26
hyh
As there is a check in the beginning of the function that includes the auction.finalPrice == 0
condition:
https://github.com/code-423n4/2021-11-malt/blob/main/src/contracts/Auction.sol#L285
The same condition down the line is never true and its check is redundant: https://github.com/code-423n4/2021-11-malt/blob/main/src/contracts/Auction.sol#L298
#0 - GalloDaSballo
2022-01-01T14:56:16Z
Agree with finding, the second if can't happen as the function will return 0
π Selected for report: hyh
56.5245 USDC - $56.52
hyh
Gas is overspent on function call and calculations
First, save Malt and Collateral tokens decimals difference to storage variable. As neither Malt, nor Collateral token decimals change since initial setup, both can be saved and accessed as a storage variable instead of calling decimals()
function and calculating the difference each time.
Second, now sellMalt calls costBasis, which already retrieved decimals and their difference, but sellMalt ignores those, retrieving them from functions/storage again. This could be unified as discussed below.
sellMalt: https://github.com/code-423n4/2021-11-malt/blob/main/src/contracts/SwingTrader.sol#L77 https://github.com/code-423n4/2021-11-malt/blob/main/src/contracts/SwingTrader.sol#L109
costBasis: https://github.com/code-423n4/2021-11-malt/blob/main/src/contracts/SwingTrader.sol#L146
Save both decimals values to contract storage variables and use them instead of decimals()
function. As the calculations use decimals difference it might be enough to save and use the difference only.
In any case saving is preferred to calling as the latter spend gas on call and storage access anyway.
Also, return the difference along with decimals from costBasis and use them in sellMalt instead of obtaining afresh. I.e. first reuse costBasis
returned decimals
instead of collateralToken.decimals()
, then add maltDecimals
and the difference, whether maltDecimals - decimals
or decimals - maltDecimals
to its output and use in rewards / soldBasis calculations. Function arguments and returned values are memory and are cheaper than another storage access.
Now:
(uint256 basis,) = costBasis(); ... uint256 maltDecimals = malt.decimals(); uint256 decimals = collateralToken.decimals(); ... uint256 diff = maltDecimals - decimals;
To be:
(uint256 basis, uint256 decimals, uint256 maltDecimals, uint256 diff) = costBasis(); ...
#0 - GalloDaSballo
2022-01-01T15:13:53Z
Agree with the finding, because malt
and and collateralToken
are unchangeable (not immutable, but no setter) you're effectively locking in one of the three logic paths at the time of initialization
π Selected for report: hyh
56.5245 USDC - $56.52
hyh
Gas is overspent on access and operations.
amountOut
is calculated in 3 steps, which can be made simpler.
https://github.com/code-423n4/2021-11-malt/blob/main/src/contracts/Auction.sol#L302-305
Now:
uint256 amountTokens = commitment.commitment.mul(auction.pegPrice).div(price); uint256 redeemedTokens = commitment.redeemed.mul(auction.pegPrice).div(price); uint256 amountOut = amountTokens.mul(claimablePerc).div(auction.pegPrice).sub(redeemedTokens);
To be (amountTokens
and redeemedTokens
aren't used elsewhere):
/* * uint256 amountTokens = commitment.commitment.mul(auction.pegPrice).div(price); * uint256 redeemedTokens = commitment.redeemed.mul(auction.pegPrice).div(price); * uint256 amountOut = amountTokens.mul(claimablePerc).div(auction.pegPrice).sub(redeemedTokens); */ uint256 redeemed = commitment.redeemed.mul(auction.pegPrice); uint256 amountOut = commitment.commitment.mul(claimablePerc).sub(redeemed).div(price);
#0 - GalloDaSballo
2022-01-01T15:27:44Z
Through the symbolic substitutions
AT = (Cc * A) / P RT = Cr * A / P AO = (AT * CP) / A - RT
AO = ((Cc * A) / P * (CP))/ A - (Cr * A) / P AO = (Cc * CP - (Cr * A)) / P
R = Cr * A AO = (Cc * CP - R) / P AO = (Cc * CP - Cr * A) / P
I agree that the warden has shown a way to save gas while keeping the math correct
π Selected for report: hyh
56.5245 USDC - $56.52
hyh
Gas is overspent on access and operations.
claimablePerc
is calculated in two steps, which can be made simpler.
https://github.com/code-423n4/2021-11-malt/blob/main/src/contracts/Auction.sol#L294
Now:
uint256 totalTokens = auction.commitments.mul(auction.pegPrice).div(auction.finalPrice); uint256 claimablePerc = auction.claimableTokens.mul(auction.pegPrice).div(totalTokens);
To be (totalTokens
isn't used elsewhere):
uint256 claimablePerc = auction.claimableTokens.mul(auction.finalPrice).div(auction.commitments);
#0 - GalloDaSballo
2022-01-01T15:50:28Z
Through the following symbolic replacements:
TT = (Ac * Ap) / Af CP = (Act * Ap) / TT
CP = (Act * Ap) / (Ac * Ap) / Af CP = (Act) / (Ac) / Af CP = Af * Act / AC
I agree that the finding is valid and will save gas
π Selected for report: hyh
56.5245 USDC - $56.52
hyh
Gas is overspent on operations.
The function contains two non-intersecting logic pathways, which can be separated to lighten calculations.
https://github.com/code-423n4/2021-11-malt/blob/main/src/contracts/AbstractRewardMine.sol#L179
Now:
function _handleStakePadding(address account, uint256 amount) internal { uint256 totalRewardedWithStakePadding = totalDeclaredReward().add(totalStakePadding()); uint256 INITIAL_STAKE_SHARE_MULTIPLE = 1e6; uint256 bondedTotal = totalBonded(); uint256 newStakePadding = bondedTotal == 0 ? totalDeclaredReward() == 0 ? amount.mul(INITIAL_STAKE_SHARE_MULTIPLE) : 0 : totalRewardedWithStakePadding.mul(amount).div(bondedTotal); _addToStakePadding(account, newStakePadding); }
To be:
function _handleStakePadding(address account, uint256 amount) internal { uint256 bondedTotal = totalBonded(); uint256 newStakePadding; if (bondedTotal == 0) { uint256 INITIAL_STAKE_SHARE_MULTIPLE = 1e6; newStakePadding = totalDeclaredReward() == 0 ? amount.mul(INITIAL_STAKE_SHARE_MULTIPLE) : 0; } else { newStakePadding = (totalDeclaredReward().add(totalStakePadding())).mul(amount).div(bondedTotal); } if (newStakePadding > 0) _addToStakePadding(account, newStakePadding); }
#0 - GalloDaSballo
2022-01-16T14:10:34Z
Agree with the refactoring, the last if avoids a SLOAD when the math is 0 and the else if is simpler to read
π Selected for report: hyh
56.5245 USDC - $56.52
hyh
Gas is overspent on access and function calls.
totalDeclaredReward is called by _handleStakePadding twice: https://github.com/code-423n4/2021-11-malt/blob/main/src/contracts/AbstractRewardMine.sol#L180
While totalDeclaredReward does expensive balanceOf call: https://github.com/code-423n4/2021-11-malt/blob/main/src/contracts/AbstractRewardMine.sol#L97
It is viable to at least remove its double usage:
Now:
uint256 totalRewardedWithStakePadding = totalDeclaredReward().add(totalStakePadding()); ... uint256 newStakePadding = bondedTotal == 0 ? totalDeclaredReward() == 0 ? amount.mul(INITIAL_STAKE_SHARE_MULTIPLE) : 0 : totalRewardedWithStakePadding.mul(amount).div(bondedTotal);
To be:
uint256 declaredRewardTotal = rewardToken.balanceOf(address(this)); uint256 totalRewardedWithStakePadding = declaredRewardTotal.add(totalStakePadding()); ... uint256 newStakePadding = bondedTotal == 0 ? declaredRewardTotal == 0 ? amount.mul(INITIAL_STAKE_SHARE_MULTIPLE) : 0 : totalRewardedWithStakePadding.mul(amount).div(bondedTotal);
#0 - GalloDaSballo
2022-01-16T14:11:28Z
Agree, whenever you are reading from storage more than once, you want to use a supporting memory variable to save gas
π Selected for report: hyh
56.5245 USDC - $56.52
hyh
Gas is overspent on function calls.
earned
function calls public getRewardOwnershipFraction
function:
https://github.com/code-423n4/2021-11-malt/blob/main/src/contracts/AbstractRewardMine.sol#L144
Now:
function totalDeclaredReward() virtual public view returns (uint256) { return rewardToken.balanceOf(address(this)); } function getRewardOwnershipFraction(address account) public view returns(uint256 numerator, uint256 denominator) { numerator = balanceOfRewards(account); denominator = totalDeclaredReward(); } ... function earned(address account) public view returns (uint256 earnedReward) { (uint256 rewardNumerator, uint256 rewardDenominator) = getRewardOwnershipFraction(account);
To be:
function earned(address account) public view returns (uint256 earnedReward) { uint256 rewardNumerator = balanceOfRewards(account); uint256 rewardDenominator = rewardToken.balanceOf(address(this));
#0 - GalloDaSballo
2022-01-16T14:13:20Z
The finding is valid, using the same public functions instead of a new one would save the cost of an extra JUMP
I believe you could also save gas by using internal functions as the compiler will inline them.
That said, the finding saves gas, personally I wouldn't apply the improvement as I believe it would impair readability
hyh
Gas is overspent on storage access, which happens each time when the variable is mutable.
Token variables cannot be changed, but aren't immutable in a number of contracts.
ForfeitHandler https://github.com/code-423n4/2021-11-malt/blob/main/src/contracts/ForfeitHandler.sol#L13
RewardReinvestor https://github.com/code-423n4/2021-11-malt/blob/main/src/contracts/RewardReinvestor.sol#L22
Auction https://github.com/code-423n4/2021-11-malt/blob/main/src/contracts/Auction.sol#L77
UniswapHandler https://github.com/code-423n4/2021-11-malt/blob/main/src/contracts/DexHandlers/UniswapHandler.sol#L26
ForfeitHandler example, the rest are very same.
ERC20 public immutable rewardToken;
#0 - 0xScotch
2021-12-09T23:38:58Z
#105
#1 - GalloDaSballo
2022-01-01T15:14:43Z
This is a different finding from the duplicate flagged by the sponsor.
I need to know if the contracts are supposed to be upgradeable before judging this
#2 - GalloDaSballo
2022-01-25T01:45:17Z
Duplicate of #164
π Selected for report: hyh
56.5245 USDC - $56.52
hyh
ERC20 balanceOf call is costly. Malt balance is read twice in sellMalt call, which isn't needed, so gas is overspent here.
Malt balanceOf(address(this)) is called twice: https://github.com/code-423n4/2021-11-malt/blob/main/src/contracts/SwingTrader.sol#L86 https://github.com/code-423n4/2021-11-malt/blob/main/src/contracts/SwingTrader.sol#L150
It's recommended to make internal version of costBasis that takes Malt balance as an argument.
Now:
uint256 totalMaltBalance = malt.balanceOf(address(this)); ... (...) = costBasis(); ... function costBasis() public view returns (uint256 cost, uint256 decimals) { ... uint256 maltBalance = malt.balanceOf(address(this)); ...
To be:
uint256 totalMaltBalance = malt.balanceOf(address(this)); ... (...) = _costBasis(totalMaltBalance); ... function _costBasis(uint256 maltBalance) internal view returns (...) { ... function costBasis() public view returns (...) { return _costBasis(malt.balanceOf(address(this))); }
#0 - GalloDaSballo
2022-01-01T15:03:55Z
Agree that the code can be refactored to make it less redundant