Platform: Code4rena
Start Date: 08/12/2021
Pot Size: $30,000 ETH
Total HM: 12
Participants: 26
Period: 3 days
Judge: leastwood
Total Solo HM: 9
Id: 65
League: ETH
Rank: 2/26
Findings: 5
Award: $5,120.53
🌟 Selected for report: 10
🚀 Solo Findings: 2
🌟 Selected for report: gzeon
Also found by: 0x0x0x, TomFrenchBlockchain
0x0x0x
Publisher
can call changeLicenseFee
to change fees. There is no maximum cap for this parameter. At first glance, because there is a timelock period of 1 day, the users have enough time to react.
But if the publisher can start an auction and bondForRebalance
. The publisher
can simply not settle the auction and block the basket for 1 day.
So the publisher
can make it extremely hard (almost impossible) for users to react to the new license fee. With an extremely high license fee, almost all funds can get stolen by publisher
(because of protocol fees also the factory owner gets his share).
1- Publisher
, calls publishNewIndex
and proposes new weights.
2- After one day, Publisher
calls changeLicenseFee
. As license fee a really high amount is set, which does not overflow.
3- After 1-2 blocks, before anyone can react Publisher
calls publishNewIndex
, bondForRebalance
.
4- When TIMELOCK_DURATION
of changeLicenseFee
ends, the publisher calls it to update the fee.
License fee definitely needs a maximum cap. Furthermore to make it really safe:
First option is to make sure there is a significant difference, between timelock duration for license fee and duration for bondBurn
.
The better option is to make sure that an auction cannot be started, when there is a pendingLicenseFee
and changeLicenseFee
cannot be called during an auction (can also add a resetPendingLicenseFee
function to be able to create an auction whenever wanted).
#0 - frank-beard
2022-02-22T19:10:55Z
#1 - frank-beard
2022-02-22T19:12:20Z
also while the publisher could block an auction, minting and burning is disabled during rebalances and thus no fees would be collected
🌟 Selected for report: 0x0x0x
1535.2197 USDC - $1,535.22
0x0x0x
The function is implemented as follows:
function auctionBurn(uint256 amount) onlyAuction nonReentrant external override { uint256 startSupply = totalSupply(); handleFees(startSupply); _burn(msg.sender, amount); uint256 newIbRatio = ibRatio * startSupply / (startSupply - amount); ibRatio = newIbRatio; emit NewIBRatio(newIbRatio); emit Burned(msg.sender, amount); }
When handleFees
is called, totalSupply
and ibRatio
changes accordingly, but for newIbRatio
calculation tokens minted in handleFees
is not included. Therefore, ibRatio
is calculated higher than it should be. This is dangerous, since last withdrawing user(s) lose their funds with this operation. In case this miscalculation happens more than once, newIbRatio
will increase the miscalculation even faster and can result in serious amount of funds missing. At each time auctionBurn
is called, at least 1 day (auction duration) of fees result in this miscalculation. Furthermore, all critical logic of this contract is based on ibRatio
, this behaviour can create serious miscalculations.
Rather than
uint256 newIbRatio = ibRatio * startSupply / (startSupply - amount);
A practical solution to this problem is calculating newIbRatio
as follows:
uint256 supply = totalSupply(); uint256 newIbRatio = ibRatio * (supply + amount) / supply;
#0 - 0xleastwood
2022-03-26T07:14:21Z
The warden has identified an issue whereby newIbRatio
uses an incorrect startSupply
variable which is under-represented. As new tokens may be minted in handleFees()
, this will lead to an incorrect ibRatio
which is used in all other areas of the protocol. A lower ibRatio
causes pushUnderlying()
and pullUnderlying()
to be improperly accounted for. As a result, burning basket tokens will redeem a smaller amount of underlying tokens and minting basket tokens will require a smaller amount of underlying tokens.
This causes the protocol to leak value from all basket token holders but it does not allow assets to be stolen. As such, I think this is better put as a medium
severity issue.
🌟 Selected for report: 0x0x0x
1535.2197 USDC - $1,535.22
0x0x0x
When factory changes auctionMultiplier
or auctionDecrement
profitability of bonded auctions change. There is no protection against this behaviour. Furthermore, factory owners can decide to get all tokens from baskets where they are bonded for the auction.
1- Factory owners call bondForRebalance
for an auction.
2- Factory owners sets auctionMultiplier
as 0 and auctionDecrement
as maximum value
3- settleAuction
is called. newRatio = 0
, since a = b = 0
. All tokens can be withdrawn with this call, since tokensNeeded = 0
.
Furthermore, even the factory owners does not try to scam users. In case auctionMultiplier
or auctionDecrement
is changed, all current auctionBonder
from Auctions
can only call settleAuction
with different constraints. Because of different constraints, users/bonder will lose/gain funds.
Save auctionDecrement
and auctionMultiplier
to global variables in Auction.sol
, when startAuction
is called.
#0 - frank-beard
2022-02-23T18:02:54Z
Adding in protection from the global governance is definitely important.
#1 - 0xleastwood
2022-03-27T01:46:37Z
The warden has identified an issue whereby the factory owner can rug-pull baskets through the re-balancing mechanism. Because newRatio = 0
, the basket improperly checks the tokens needed in the contract. However, this assumes that the factory owner is malicious which satisfies medium
severity due to assets not being at direct risk.
The sponsor has decided to add additional protections (potentially via timelock) to mitigate this issue.
#2 - 0xleastwood
2022-03-27T01:49:54Z
This rug-pull is made even more difficult by the fact that newRatio
must be >= minIbRatio
. Because minIbRatio
is behind timelock, I think this rug vector is unlikely or at least can only be used to steal a fixed amount of funds.
🌟 Selected for report: 0x0x0x
511.7399 USDC - $511.74
0x0x0x
If someone calls settleAuction
and inputs more tokens than the person should, tokens get stuck. To avoid this, it would be better to compute inputTokens/inputWeights
and outputTokens/outputWeights
automatically, since they can be known beforehand. When a bonder sends a settleAuction
request, any change to conditions might lead them to lose their funds(or revert). Furthermore, this is a serious attack vector and for example web hackers can abuse it, without even communicating with a different contract.
Automate the calculation of inputTokens/inputWeights
and outputTokens/outputWeights
or add a mechanism to calculate excess amounts for everything and transfer them back to the sender.
#0 - frank-beard
2022-02-22T19:16:23Z
this is correct however we are not as concerned with having more tokens than is needed and future rebalances can correct this issue. i would consider this as a low/medium risk as perhaps a naive publisher could not recognize this and create issues when rebalancing.
#1 - 0xleastwood
2022-03-27T01:39:29Z
I don't think there will be any changes to the basket's ibRatio
when an auction is ongoing. Basket.sol
does not allow for such changes and Basket.handleFees()
is the only function which updates ibRatio
. At best, I think this is a low
severity issue as automatically calculating the correct token amounts prevents bonded users from making mistakes upon calling settleAuction()
.
🌟 Selected for report: 0x0x0x
511.7399 USDC - $511.74
0x0x0x
Lets say there is already as many tokens as maxSupply
. A user calls burn
with amount = 1
. handleFees
create more than 1 token. Therefore, maxSupply
is exceeded. Furthermore, the same can also happen again, when maxSupply
is already exceeded.
#0 - frank-beard
2022-02-23T18:11:10Z
While this could happen, this shouldn't affect any of the functionality of the basket and can be rectified by the publisher by resetting the maxSupply or more burns. Generally I would consider this a low risk issue.
#1 - 0xleastwood
2022-03-27T02:25:14Z
While I agree that this is an issue, it does not lead to any unexpected behaviour and as such I'd deem this as low
risk as its more of a state handling issue.
🌟 Selected for report: 0x0x0x
511.7399 USDC - $511.74
0x0x0x
When bondPercentDiv
is set to 1, it is unlikely that someone can bond by using all tokens (Also because of fees factory would have some tokens). I think this is not intended behaviour.
Add the condition that this parameter is more than 1 (rather than 0). Another option is to keep it as a lock mechanism, but add a comment about it in factory.
#0 - frank-beard
2022-02-23T18:13:15Z
We made the decision to keep some of those values open as ideally a dao/multisig will be modifying those parameters if needed. In this case, something like that should be caught in governance or before it is set.
🌟 Selected for report: 0x0x0x
Also found by: Jujic, pmerkleplant, ye0lde
0x0x0x
!= 0
is a cheaper operation compared to > 0
, when dealing with uint
.
./Basket.sol:68: require(_tokens.length > 0); ./Basket.sol:77: require(_weights[i] > 0); ./Basket.sol:93: require(amount > 0); ./Basket.sol:110: require(amount > 0); ./Basket.sol:292: require(tokenAmount > 0); ./Factory.sol:53: require(newBondPercentDiv > 0);
🌟 Selected for report: 0x0x0x
Also found by: Jujic, Meta0xNull, WatchPug, pmerkleplant, rishabh
0x0x0x
Example:
for (uint i = 0; i < arr.length; i++) { //Operations not effecting the length of the array. }
Loading length of array costs gas. Therefore, the length should be cached, if the length of the array doesn't change inside the loop. Furthermore, there is no need to assign the initial value 0. This costs extra gas.
Recommended implementation:
uint length = arr.length; for (uint i; i < length; i++) { //Operations not effecting the length of the array. }
By doing so the length is only loaded once rather than loading it as many times as iterations (Therefore, less gas is spent).
./Auction.sol:88: for (uint256 i = 0; i < inputTokens.length; i++) { ./Auction.sol:92: for (uint256 i = 0; i < outputTokens.length; i++) { ./Auction.sol:105: for (uint256 i = 0; i < pendingWeights.length; i++) { ./Auction.sol:152: for (uint256 i = 0; i < bountyIds.length; i++) { ./Basket.sol:75: for (uint i = 0; i < length; i++) { ./Basket.sol:79: for (uint256 x = 0; x < tokenList.length; x++) { ./Basket.sol:275: for (uint256 i = 0; i < weights.length; i++) { ./Basket.sol:282: for (uint256 i = 0; i < weights.length; i++) { ./Basket.sol:290: for (uint256 i = 0; i < weights.length; i++) { ./Factory.sol:109: for (uint256 i = 0; i < bProposal.weights.length; i++) {
0x0x0x
Negation(!) is a better optimized implementation of applying == false
. It requires less gas and also the recommended approach to compute the negation.
In other words: x == false
and !x
compute the same result, but !x
costs less gas.
./Auction.sol:37: require(auctionOngoing == false, 'ongoing auction'); ./Basket.sol:92: require(auction.auctionOngoing() == false); ./Basket.sol:109: require(auction.auctionOngoing() == false); ./Basket.sol:221: if (auction.auctionOngoing() == false) { ./Basket.sol:259: require(auction.auctionOngoing() == false);
🌟 Selected for report: 0x0x0x
Also found by: TomFrenchBlockchain, danb
0x0x0x
basket.sol#mintTo
is as follows:
function mintTo(uint256 amount, address to) public nonReentrant override { require(auction.auctionOngoing() == false); require(amount > 0); uint256 startSupply = totalSupply(); require(startSupply + amount <= maxSupply); handleFees(startSupply); pullUnderlying(amount, msg.sender); _mint(to, amount); require(totalSupply() <= maxSupply); emit Minted(to, amount); }
To check, whether maxSupply is exceeded first the following statement is used:
require(startSupply + amount <= maxSupply);
At the end of the function once again, it is checked:
require(totalSupply() <= maxSupply);
Since second requirement already check whether maximum supply is exceeded, the first on is not required and consumes extra gas.
#0 - 0xleastwood
2022-03-27T10:35:03Z
I agree, the first require
statement is redundant.
18.4266 USDC - $18.43
0x0x0x
In handleFees
, when timeDiff = 0
the calculations for the fee are not required. Inside last else
statement can an if
statement added to avoid not needed calculations. This would save gas especially, when the contract is used more than once in a single block.
So this part
} else { uint256 timeDiff = (block.timestamp - lastFee);
Can be replaced with the following:
} else { uint256 timeDiff = (block.timestamp - lastFee); if (timeDiff == 0) { return; }
Or alternatively all remaining calculations can be inside an if (timeDiff != 0)
.
#0 - 0xleastwood
2022-03-27T22:19:13Z
Duplicate of #110
🌟 Selected for report: 0x0x0x
0x0x0x
In pushUnderlying
and pullUnderlying
, to compute the tokenAmount
it is required to divide by BASE
twice. Square of BASE
can be saved in a constant to avoid applying this division twice. Simply a new variable called BASE2
(representing square of BASE
) can apply the same logic for less gas.