Platform: Code4rena
Start Date: 16/09/2021
Pot Size: $200,000 SUSHI
Total HM: 26
Participants: 16
Period: 14 days
Judge: alcueca
Total Solo HM: 13
Id: 29
League: ETH
Rank: 1/16
Findings: 16
Award: $76,321.29
π Selected for report: 16
π Solo Findings: 7
broccoli
In the IndexPool contract, flashSwap does not work.
The callback function is called prior to token transfer. The sender won't receive tokens in the callBack function.
ITridentCallee(msg.sender).tridentSwapCallback(context);
Flashswap is not implemented correctly. It may need a migration to redeploy all indexPools if the issue is found after main-net launch. I consider this a high-risk issue.
ITridentCallee(msg.sender).tridentSwapCallback(context); // @dev Check Trident router has sent `amountIn` for skim into pool. unchecked { // @dev This is safe from under/overflow - only logged amounts handled. require(_balance(tokenIn) >= amountIn + inRecord.reserve, "NOT_RECEIVED"); inRecord.reserve += uint120(amountIn); outRecord.reserve -= uint120(amountOut); } _transfer(tokenOut, amountOut, recipient, unwrapBento);
None
_transfer(tokenOut, amountOut, recipient, unwrapBento); ITridentCallee(msg.sender).tridentSwapCallback(context); // @dev Check Trident router has sent `amountIn` for skim into pool. unchecked { // @dev This is safe from under/overflow - only logged amounts handled. require(_balance(tokenIn) >= amountIn + inRecord.reserve, "NOT_RECEIVED"); inRecord.reserve += uint120(amountIn); outRecord.reserve -= uint120(amountOut); }
#0 - alcueca
2021-10-27T04:06:57Z
I can't find a duplicate, @maxsam4?
#1 - maxsam4
2021-10-27T04:11:27Z
broccoli
When an Index pool is initiated with two tokens A: B and the weight rate = 1:2, then no user can buy token A with token B.
The root cause is the error in pow. It seems like the dev tries to implement Exponentiation by squaring. IndexPool.sol#L286-L291
function _pow(uint256 a, uint256 n) internal pure returns (uint256 output) { output = n % 2 != 0 ? a : BASE; for (n /= 2; n != 0; n /= 2) a = a * a; if (n % 2 != 0) output = output * a; }
There's no bracket for for
.
The IndexPool is not functional. I consider this is a high-risk issue.
When we initiated the pool with 2:1.
deployed_code = encode_abi(["address[]","uint136[]","uint256"], [ (link.address, dai.address), (2*10**18, 10**18), 10**13 ])
No one can buy dai with link.
# (address tokenIn, address tokenOut, address recipient, bool unwrapBento, uint256 amountIn) previous_balance = bento.functions.balanceOf(dai.address, admin).call() swap_amount = 10**18 bento.functions.transfer(link.address, admin, pool.address, swap_amount).transact() pool.functions.swap( encode_abi( ['address', 'address', 'address', 'bool', 'uint256'], [link.address, dai.address, admin, False, swap_amount] ) ).transact() current_balance = bento.functions.balanceOf(dai.address, admin).call() token_received = current_balance - previous_balance # always = 0 print(token_received)
None
The brackets of for
were missed.
function _pow(uint256 a, uint256 n) internal pure returns (uint256 output) { output = n % 2 != 0 ? a : BASE; for (n /= 2; n != 0; n /= 2) { a = a * a; if (n % 2 != 0) output = output * a; } }
π Selected for report: broccoli
705.9844 SUSHI - $8,824.81
broccoli
In the IndexPool contract, pow is used in calculating price. IndexPool.sol#L255-L266
However, Pow is easy to cause overflow. If the weightRatio
is large (e.g. 10), there's always overflow.
Lp providers can still provide liquidity to the pool where no one can swap. All pools need to redeploy. I consider this a high-risk issue.
It's easy to trigger this bug by deploying a 1:10 IndexPool.
deployed_code = encode_abi(["address[]","uint136[]","uint256"], [ (link.address, dai.address), (10**18, 10 * 10**18), 10**13 ]) tx_hash = master_deployer.functions.deployPool(index_pool_factory.address, deployed_code).transact()
Transactions would be reverted when buying link
with dai
.
None
The weightRatio
is an 18 decimals number. It should be divided by (BASE)^exp
. The scale in the contract is not consistent. Recommend the dev to check all the scales/ decimals.
broccoli
The indexPool mint INIT_POOL_SUPPLY
to address 0 in the constructor. However, the value of the burned lp is decided by the first lp provider. According to the formula IndexPool.sol#L106.
AmountIn = first_lp_amount / INIT_POOL_SUPPLY
and the burned lp worth = AmountIn * (INIT_POOL_SUPPLY) / (first_lp_amount + INIT_POOL_SUPPLY)
.
If a pool is not initialized with optimal parameters, it would be a great number of tokens been burn. All lp providers in the pool would receive less profit.
The optimal parameter is 10**8
. It's likely no one would initialize with 10**8
wei in most pools. I consider this is a high-risk issue.
There are two scenarios that the first lp provider can do. The lp provider provides the same amount of token in both cases. However, in the first scenario, he gets about 10 ** 18 * 10**18
lp while in the other scenario he gets 100 * 10**18
lp.
deposit_amount = 10**18 bento.functions.transfer(link.address, admin, pool.address, deposit_amount).transact() bento.functions.transfer(dai.address, admin, pool.address, deposit_amount).transact() pool.functions.mint(encode_abi( ['address', 'uint256'], [admin, 10**8] # minimum )).transact() pool.functions.mint(encode_abi( ['address', 'uint256'], [admin, 10000000000009999 * 10** 20] )).transact()
deposit_amount = 10**18 bento.functions.transfer(link.address, admin, pool.address, deposit_amount).transact() bento.functions.transfer(dai.address, admin, pool.address, deposit_amount).transact() pool.functions.mint(encode_abi( ['address', 'uint256'], [admin, deposit_amount * 100] )).transact()
Recommend to handle INIT_POOL_SUPPLY
in uniswap-v2's way. Determine an optimized parameter for the user would be a better UX design.
π Selected for report: broccoli
705.9844 SUSHI - $8,824.81
broccoli
When an lp provider deposits an imbalance amount of token, a swap fee is applied. HybridPool uses the same _nonOptimalMintFee
as constantProductPool
; however, since two pools use different AMM curve, the ideal balance is not the same. ref: StableSwap3Pool.vy#L322-L337
Stable swap Pools are designed for 1B+ TVL. Any issue related to pricing/fee is serious. I consider this is a high-risk issue
None
Calculate the swapping fee based on the stable swap curve. Please refer to StableSwap3Pool.vy#L322-L337.
broccoli
In the IndexPool contract, the first lp providers have to deposit the same amount of tokens. This creates arbitrage space.
If the deployer tries to deploy a BTC/DAI pool. S/He has to initialize the BTC pool with BTC price = 1.
I consider this is a medium-risk issue
The logic that decides the amountIn
of the first lp provider:
// @dev If token balance is '0', initialize with `ratio`. uint120 amountIn = reserve != 0 ? uint120(_mul(ratio, reserve)) : ratio;
amountIn = ratio
for all tokens.
None
I would say it is a bad UX design than a bug. I consider allowing the first lp provider to decide the price is a better choice.
#0 - ninek9
2021-10-27T18:32:16Z
duplicate of #72
317.693 SUSHI - $3,971.16
broccoli
The difference
function of MathUtils
is incorrect. Without a return
statement in the if
bracket, the function always returns diff = b - a
, causing difference(x + 1, x)
to be uint(-1)
, and thus within(x + 1, x)
is false
. The within
function is used to in the _getY
function and for calculating the D
invariant, and the incorrect result of within
could cause inaccurate amount of tokens being swaped, or inaccurate pool liquidity being used.
Referenced code: MathUtils.sol#L22-L29
Add a return diff;
after the line diff = a - b
.
#0 - maxsam4
2021-10-06T08:04:24Z
317.693 SUSHI - $3,971.16
broccoli
It is possible to overflow the addition in the balance check (i.e., _balance(tokenIn) >= amountIn + reserve
) in the mint function by setting the amountIn
to a large amount. As a result, the attacker could gain a large number of LP tokens by not even providing any liquidity. The attacker's liquidity would be much greater than any other LPs, causing him could effectively steal others' funds by burning his liquidity (since the funds he receives are proportional to his liquidity).
PoC: Link to PoC
Referenced code: IndexPool.sol#L110
Consider removing the uncheck
statement to prevent integer overflows from happening.
#0 - maxsam4
2021-10-06T08:34:41Z
FWIW The problem here isn't that we used unchecked but that we didn't cast amountIn to uint256. It's possible to overflow uint120 but not uint256.
π Selected for report: broccoli
705.9844 SUSHI - $8,824.81
broccoli
The _computeSingleOutGivenPoolIn
function of IndexPool
uses the _pow
function to calculate tokenOutRatio
with the exponent in WAD
(i.e., in 18 decimals of precision). However, the _pow
function assumes that the given exponent n
is not in WAD
. (for example, _pow(5, BASE)
returns 5 ** (10 ** 18)
instead of 5 ** 1
). The misuse of the _pow
function could causes an integer overflow in the _computeSingleOutGivenPoolIn
function and thus prevent any function from calling it.
Referenced code: IndexPool.sol#L279
Change the _pow
function to the _compute
function, which supports exponents in WAD
.
π Selected for report: broccoli
705.9844 SUSHI - $8,824.81
broccoli
The _computeSingleOutGivenPoolIn
function of IndexPool
uses the raw multiplication (i.e., *
) to calculate the zaz
variable. However, since both (BASE - normalizedWeight)
and _swapFee
are in WAD
, the _mul
function should be used instead to calculate the correct value of zaz
. Otherwise, zaz
would be 10 ** 18
times larger than the expected value and causes an integer underflow when calculating amountOut
. The incorrect usage of multiplication prevents anyone from calling the function successfully.
Referenced code: IndexPool.sol#L282
Change (BASE - normalizedWeight) * _swapFee
to _mul((BASE - normalizedWeight), _swapFee)
.
π Selected for report: broccoli
705.9844 SUSHI - $8,824.81
broccoli
An attacker can call the bento.harvest
function during the callback function of a flash swap of the HybridPool
to reduce the number of input tokens that he has to pay to the pool, as long as there is any unrealized profit in the strategy contract of the underlying asset.
HybridPool
accounts for the reserve and balance of the pool using the bento.toAmount
function, which represents the actual amount of assets that the pool owns instead of the relative share. The value of toAmount
could increase or decrease if the bento.harvest
function is called (by anyone), depending on whether the strategy contract earns or loses money.Bento
has a profit not accounted for yet. To account for the profit, anyone could call harvest
on Bento
with the corresponding parameters, which, as a result, increases the elastic
of the DAI token.flashSwap
to initiate a flash swap from WETH to DAI. First, the pool transfers the corresponding amount of DAI to him, calls the tridentSwapCallback
function on the attacker's contract, and expects that enough DAI is received at the end.tridentSwapCallback
function, the attacker calls bento.harvest
to realize the profit of DAI. As a result, the pool's bento.toAmount
increases, and the amount of DAI that the attacker has to pay to the pool is decreased. The attacker could get the same amount of ETH but paying less DAI by exploiting this bug.Referenced code: HybridPool.sol#L218-L220 HybridPool.sol#L249-L250 HybridPool.sol#L272-L285 BentoBoxV1Flat.sol#L1105 BentoBoxV1Flat.sol#L786-L792 BentoBoxV1Flat.sol#L264-L277
Consider not using bento.toAmount
to track the reservers and balances, but use balanceOf
instead (as done in the other two pools).
#0 - maxsam4
2021-10-06T08:13:49Z
Stableswap needs to use toAmount
balances rather shares to work. This issue allows skimming yield profits from the pool. There's no user funds at risk but still an issue.
We plan on resolving this by using a fixed toElastic ratio during the whole swap.
broccoli
When a user provides liquidity with unbalanced balance. It should be the same as swapping tokens and adding lp. However, the liquidity the users get is calculated as follow:
uint256 computed = TridentMath.sqrt((balance0 - fee0) * (balance1 - fee1)); uint256 k = TridentMath.sqrt(uint256(_reserve0) * _reserve1); liquidity = ((computed - k) * _totalSupply) / k;
The user can take a share of the swapping fee he's paying. It's like first add the liquidity then swap the tokens.
It may be a design choice. However, the pool is designed to hold billions of dollars. Any extra fee would be amplified. I consider this is a medium-risk issue.
ConstantProductPool and HybridPool have the same issue. ConstantProductPool.sol#L83-L120
I noticed that imbalanced mint and burnSingle
may sometimes have a better price than swapping. It may be a lot of issues involved.
None
// Note: what is the optimal value of computed uint256 computed = TridentMath.sqrt((balance0) * (balance1)); // The user should get the lp based on post-swap-fee reserve. uint256 k = TridentMath.sqrt(uint256(_reserve0 + fee) * _reserve1 + fee); liquidity = ((computed - k) * _totalSupply) / k;
#0 - maxsam4
2021-11-08T11:28:48Z
π Selected for report: broccoli
211.7953 SUSHI - $2,647.44
broccoli
TridentRouter is easy to fail when trying to provide liquidity to an index pool.
Users would not get extra lp if they are not providing lp at the pool's spot price. It's the same design as uniswap v2. However, uniswap's v2 handle's the dirty part.
UniswapV2Router02.sol#L61-L76 Users would not lose tokens if they use the router.
However, the router wouldn't stop users from transferring extra tokens. TridentRouter.sol#L168-L190
Second, the price would possibly change when the transaction is confirmed. This would be reverted in the index pool.
Users would either transfer extra tokens or fail. I consider this is a medium-risk issue.
A possible scenario:
There's a BTC/USD pool. BTC = 50000 USD.
None
Please refer to the uniswap v2 router. UniswapV2Router02.sol#L61-L76
The router should calculate the optimal parameters for users.
95.3079 SUSHI - $1,191.35
broccoli
Title: Revert the transaction if convergence does not happen in HybridPool
The _getY
and _computeLiquidityFromAdjustedBalances
functions use iterative approximations to calculate the output token amount or the liquidity invariant D
. However, if the approximation does not converge after MAX_LOOP_LIMIT
of steps, the functions directly return them instead of reverting, causing inaccurate results being used by the contract.
As commented in the Curve implementation, the convergence typically occurs in 4 rounds or less. If the convergence doesn't happen after 256 rounds, the pool is likely in a corrupted state, where the admin should pause the trades to prevent users from losing funds accidentally or anyone exploiting such edge cases to profit. The likelihood of this happening is low, yet the severity is high. Thus I considered it as a medium-risk vulnerability.
Referenced code: HybridPool.sol#L347-L364 HybridPool.sol#L373-L387
Curve implementation of _get_D
Consider reverting the transaction if convergence does not happen after MAX_LOOP_LIMIT
rounds of approximation.
#0 - maxsam4
2021-10-05T13:04:08Z
I'll apply the suggestion because better be safe than sorry but this case should never be triggerable.
#1 - alcueca
2021-10-25T06:17:50Z
Due to impact of issue, even if just theoretical, warrants a Sev 2.
π Selected for report: broccoli
211.7953 SUSHI - $2,647.44
broccoli
The _depositToBentoBox
and _depositFromUserToBentoBox
allow users to provide ETH to the router, which is later deposited to the bento
contract for swapping other assets or providing liquidity. However, in these two functions, the input parameter does not represent the actual amount of ETH to deposit, and users have to calculate the actual amount and send it to the router, causing a back-run vulnerability if there are ETH left after the operation.
exactInputSingleWithNativeToken
on the router with the corresponding parameters and params.amountIn
being 10. Before calling the function, he calculates bento.toAmount(wETH, 10, true) = 15
and thus send 15 ETH to the router.bento.toAmount(wETH, amount, true)
becomes to 14
, which could happen if someone calls harvest
on bento
to update the elastic
value of the wETH
token.refundETH
function.Referenced code: TridentRouter.sol#L318-L351
Directly push the remaining ETH to the sender to prevent any ETH left in the router.
#0 - maxsam4
2021-10-05T12:59:15Z
I think it's low risk because it's basically arbitrage and we have protection for the user in terms of "minOutputAmount". I will be reworking ETH handling to avoid this issue completely.
#1 - alcueca
2021-10-25T06:19:35Z
It's a loss of funds, not arbitrage. It should be prevented or documented. Sustained.
π Selected for report: nikitastupin
broccoli
The variable DOMAIN_SEPARATOR
in TridentERC20
is cached in the contract storage and will not change after the contract is initialized. However, if a hard fork happens after the contract deployment, the DOMAIN_SEPARATOR
would become invalid on one of the forked chains due to the block.chainid
has changed.
Referenced code: TridentERC20.sol#L30-L39
Consider using the implementation from OpenZeppelin, which recalculates the domain separator if the current block.chainid
is not the cached chain ID.
#0 - maxsam4
2021-10-05T05:37:40Z
π Selected for report: broccoli
70.5984 SUSHI - $882.48
broccoli
In the hybridPool's bonding curve. The liquidity is calculated as follows:
D = (((N_A * s) / A_PRECISION + 2 * dP) * D) / ((N_A / A_PRECISION - 1) * D + 3 * dP);
As A_PRECISION
= 100. N_A / A_PRECISION
would equals zero and cause underflow when N_A < 100
.
The pool's design does not support the A < 100. The user who deploys this pool may lose its fund. Also, there are some tokens that fit this kind of bonding curve. I consider this is a medium-risk issue.
We can trigger the bug by deploying the hybridPool with the following parameter.
deployed_code = encode_abi(["address","address","uint256", "uint256"], [ link.address, usdt.address, 1, 50 ]) tx_hash = master_deployer.functions.deployPool(hybrid_pool_factory.address, deployed_code).transact()
We can avoid underflow by re-writing the formula.
D = (((N_A * s) / A_PRECISION + 2 * dP) * D) / ((N_A / A_PRECISION - 1) * D + 3 * dP);
D = (((N_A * s) / A_PRECISION + 2 * dP) * D) / ((N_A * D / A_PRECISION - D) + 3 * dP);
#0 - maxsam4
2021-10-21T07:04:18Z
A is multiplied by A_PREC for increasing precision. 100 A actually implies 1 A. And yes, we don't support fractional A.
#1 - alcueca
2021-10-25T06:03:25Z
The limitation should be documented
9.2639 SUSHI - $115.80
broccoli
Unlocked/floating pragmas are used in the contracts (i.e., pragma solidity >=0.8.0;
). Locking the pragma ensures that the contracts are not accidentally deployed using an outdated compiler version with unfixed bugs.
Referenced code:
Please use grep -R "pragma" .
to find the unlocked pragmas.
Lock pragmas to a specific Solidity version. Consider the compiler bugs in the following links and ensure that they do not affect the contracts. It is also recommended to use the latest version of Solidity when writing and deploying contracts (see Solidity docs).
Related links: Solidity repo - known bugs Solidity repo - bugs by version
#0 - maxsam4
2021-10-05T06:07:16Z
The compiler version to use is fixed in hardhat settings.
#1 - alcueca
2021-10-25T05:38:54Z
Duplicated with #49
π Selected for report: broccoli
70.5984 SUSHI - $882.48
broccoli
The ConstantProductPool
constructor initializes the blockTimestampLast
variable to 1 if the pool supports TWAP. However, it should be initialized as the current timestamp instead. Otherwise, when the first price (determined by the first mint) updates the TWAP, it would have a weight of block.timstamp - 1
(equivalent to around 50 years) and thus dominate the result of TWAP afterward. An attacker could exploit such a bug to bias the outcome of TWAP to a preferred value and profit from other protocols relying on the TWAP.
Referenced code: ConstantProductPool.sol#L80 ConstantProductPool.sol#L297
Change blockTimestampLast = 1;
to blockTimestampLast = block.timestamp;
at line 77.
#0 - maxsam4
2021-10-05T12:45:00Z
The issue is real but perhaps the risk is low. Only the delta of CumulativePrice is helpful. Therefore, the invalid result will only happen if someone takes TWAP from pool's creation time. That's dangerous anway. TWAP should be trusted after the pool has gain activity.
#1 - alcueca
2021-10-25T05:25:06Z
According to the sponsor assessment, and agreeing with expected use of TWAP functions, downgraded to severity 1. The risk should be documented.
π Selected for report: broccoli
70.5984 SUSHI - $882.48
broccoli
The _computeLiquidityFromAdjustedBalances
function of HybridPool
should return in the if (s == 0)
statement, or it will cause a divison-by-zero error otherwise.
Referenced code: HybridPool.sol#L350-L352
Add return computed;
after computed = 0;
.
π Selected for report: broccoli
70.5984 SUSHI - $882.48
broccoli
The _getY
function of HybridPool
calcualtes the c
variable by a "divsion and divsion" formula:
c = (c * D) / ((N_A * 2) / A_PRECISION)
By dividing N_A * 2
with A_PRECISION
first, we could lost some precision on the A
value. For exmple, N_A * 2 / A_PRECISION
is 2 for both A = 100
and A = 120
.
Referenced code: HybridPool.sol#L375
Consider changing the formula as follows:
c = (c * D) * A_PRECISION / (N_A * 2)
#0 - alcueca
2021-10-30T04:40:22Z
Duplicate of #174
π Selected for report: broccoli
70.5984 SUSHI - $882.48
broccoli
The _updateReserves
function of HybridPool
checks whether the reserves do not overflow a 128-bit unsigned integer. However, the comparison <
is used instead of <=
.
Referenced code: HybridPool.sol#L261
Consider changing <
to <=
.
#0 - alcueca
2021-10-25T06:22:06Z
Incorrect function behaviour has a minimum sev of 1.