Platform: Code4rena
Start Date: 23/05/2022
Pot Size: $50,000 USDC
Total HM: 44
Participants: 99
Period: 5 days
Judge: hickuphh3
Total Solo HM: 11
Id: 129
League: ETH
Rank: 6/99
Findings: 12
Award: $2,233.61
🌟 Selected for report: 3
🚀 Solo Findings: 0
🌟 Selected for report: xiaoming90
Also found by: 0x52, PP1004, sashik_eth, shenwilly
BathBuddy
just divides the portion of rewards based on BathToken
's shares. But it doesn't care about how long that user has deposited to the BathToken. It will lead to the scenario that the user can call deposit
and withdraw
many times to claim more rewards.
For more detail pls read and add this file to your test folder and run it (also note that add one more function setRewardsVestingWallet()
that I note in the file for easy to test).
I described all the detail of the issue through a simple example.
javascript, truffle
The logic of BathBuddy
should follow the logic of this one
#0 - bghughes
2022-05-30T22:52:29Z
Duplicate of #208
#1 - HickupHH3
2022-06-21T01:52:55Z
Duplicate of #109
🌟 Selected for report: MiloTruck
Also found by: CertoraInc, PP1004, SmartSek, VAD37, WatchPug, berndartmueller, cccz, minhquanym, oyc_109, sorrynotsorry, unforgiven
https://github.com/code-423n4/2022-05-rubicon/blob/8c312a63a91193c6a192a9aab44ff980fbfd7741/contracts/rubiconPools/BathToken.sol#L557-L585 https://github.com/code-423n4/2022-05-rubicon/blob/8c312a63a91193c6a192a9aab44ff980fbfd7741/contracts/rubiconPools/BathToken.sol#L756-L759
The first user can claim all the rewards, and maybe no1 can join the pool
Rich user can manipulate the deposit
function follow these steps:
Step 1: Deposit 1 underlyingtoken
to BathToken and get 1 share.
Step 2: Execute a direct transfer 1M underlyingToken
to BathToken
==> After this step, 1 share of BathToken will cost (1M + 1) underlyingToken
. Assume underlyingToken
is USDC, so it will make the share too expensive for normal (poor) people to join (they will need at least 1M + 1 USDC to get 1 share ...).
==> User can claim all of the rewards when he is the first one joins the pool
manual review
Instead of calculating underlyingBalance()
by IERC20(underlyingToken).balanceOf(address(this))
, we can create a variable totalUnderlyingToken
that will be increased anytime someone call deposit()
function.
#0 - bghughes
2022-06-03T23:25:06Z
Duplicate of #323
🌟 Selected for report: xiaoming90
Also found by: 0xNoah, PP1004, hubble, pauliax, reassor, sashik_eth, shenwilly, sseefried
142.2857 USDC - $142.29
BathBuddy
contract uselessBathToken
variable rewardsVestingWallet
in BathToken
is used to store BathBuddy
contract. BathBuddy
make it easy for Bath Tokens
to payout their withdrawers any bonusToken
they may have accrued while staking in Bath Token (incentives).
But in contract BathToken.sol
, there is no function to initialize/set variable rewardVestingWallet
.
manual review
Add one more function to let BathHouse set variable rewardsVestingWallet
.
function setRewardsVestingWallet(IBathBuddy newRewardsVestingWallet) onlyBathHouse { rewardsVestingWallet = newRewardsVestingWallet; }
#0 - bghughes
2022-06-03T22:06:20Z
This is a good issue, though can be easily added (as intended) by a proxy admin upgrading the contract. We have done this many times in practice, so I think Medium as this will naturally get added when there's a need to update the value.
#1 - HickupHH3
2022-06-16T08:10:17Z
As per the rulebook, https://github.com/code-423n4/org/issues/11, upgradeability should not be used as an excuse to reduce the severity of a finding.
Because there is a potential loss of bonus token rewards, I will keep the severity as high.
#2 - HickupHH3
2022-06-16T08:11:26Z
Taking #107 as the primary issue because its writeup is better.
🌟 Selected for report: kenzo
Also found by: IllIllI, PP1004, blackscale, hansfriese
The wrong fee calculation can cause a loss to users' fund and this loss will be stuck in RubiconRouter
We have the default $feeBPS = 20, BPS = 10000$
Let's assume that alice call RubiconRouter.swap(pay_amt=1000000)
Through router, alice will have to transfer in:
$amountTransferIn = 1000000 \times (BPS + feeBPS) / BPS = 1002000$
However in the internal _swap
function, the actual amount being pass into RubiconMarket.sellAllAmount()
is
$amountTransferIn - amountTransferIn \times feeBPS / BPS = 999996$
So in this case, $4$ over $1002000$ unit of user's fund were stuck in the router and not being used to swap.
The formula in L232-L234 in Rubicon Router should be changed to:
currentAmount.sub( currentAmount.mul(expectedMarketFeeBPS).div(expectedMarketFeeBPS + 10000) )
#0 - HickupHH3
2022-06-16T14:41:43Z
duplicate of #104
8.2687 USDC - $8.27
https://github.com/code-423n4/2022-05-rubicon/blob/8c312a63a91193c6a192a9aab44ff980fbfd7741/contracts/RubiconRouter.sol#L356 https://github.com/code-423n4/2022-05-rubicon/blob/8c312a63a91193c6a192a9aab44ff980fbfd7741/contracts/RubiconRouter.sol#L374 https://github.com/code-423n4/2022-05-rubicon/blob/8c312a63a91193c6a192a9aab44ff980fbfd7741/contracts/RubiconRouter.sol#L434 https://github.com/code-423n4/2022-05-rubicon/blob/8c312a63a91193c6a192a9aab44ff980fbfd7741/contracts/RubiconRouter.sol#L451 https://github.com/code-423n4/2022-05-rubicon/blob/8c312a63a91193c6a192a9aab44ff980fbfd7741/contracts/RubiconRouter.sol#L491 https://github.com/code-423n4/2022-05-rubicon/blob/8c312a63a91193c6a192a9aab44ff980fbfd7741/contracts/RubiconRouter.sol#L548
User can't claim their native token back
To withdraw native token using transfer()
will fail inevitably when :
https://consensys.net/diligence/blog/2019/09/stop-using-soliditys-transfer-now/
Manual review
use call()
to send eth (native token)
#0 - bghughes
2022-06-04T21:41:45Z
Duplicate of #82
🌟 Selected for report: berndartmueller
Also found by: 0x1f8b, 0xDjango, 0xsomeone, ACai, Bahurum, BouSalman, CertoraInc, Deivitto, Dravee, GimelSec, IllIllI, JMukesh, Kaiziron, PP1004, Ruhum, SmartSek, VAD37, WatchPug, _Adam, aez121, antonttc, blockdev, broccolirob, camden, cccz, cryptphi, defsec, dipp, ellahi, fatherOfBlocks, gzeon, horsefacts, ilan, jayjonah8, joestakey, kenta, kenzo, minhquanym, oyc_109, pauliax, pedroais, peritoflores, sashik_eth, shenwilly, simon135, throttle, xiaoming90, z3s
0.1022 USDC - $0.10
It is good to add a require() statement that checks the return value of token transfers or to use something like OpenZeppelin’s safeTransfer/safeTransferFrom unless one is sure the given token reverts in case of a failure. Failure to do so will cause silent failures of transfers and affect token accounting in contract.
While most places use a require or safeTransfer/safeTransferFrom, there are three missing cases in the withdrawal of staking token and rescue of arbitrary tokens sent to the FeeDistributor contract.
Reference this similar medium-severity finding from Consensys Diligence Audit of Fei Protocol: https://consensys.net/diligence/audits/2021/01/fei-protocol/#unchecked-return-value-for-iweth-transfer-call
manual review
Consider using safeTransfer/safeTransferFrom or require() consistently.
#0 - bghughes
2022-06-03T20:06:08Z
Duplicate of #316
🌟 Selected for report: xiaoming90
Also found by: GimelSec, IllIllI, MaratCerby, PP1004, WatchPug, berndartmueller, blockdev, ilan
Different between amount of pay_gem
that offer actual has and pay_amt
==> User can't buy that offer because there is not enough fund to transfer
There are ERC20 tokens that may make certain customizations to their ERC20 contracts.
One type of these tokens is deflationary tokens that charge a certain fee for every transfer()
or transferFrom()
Assume that pay_gem is Deflationary token, so after line 416, the actual amount that contract gained is less than buy_amt
, because pay_gem
will take a fee on trasferFrom()
function.
manual review
set value of pay_amt
equal to the difference between of pay_gem
after and before transferFrom()
function.
#0 - bghughes
2022-06-04T01:23:08Z
Duplicate of #112
🌟 Selected for report: PP1004
Also found by: GimelSec, camden, unforgiven
162.6494 USDC - $162.65
Function openBathTokenSpawnAndSignal
will alway revert when newBathTokenUnderlying
or desiredPairedAsset
is deflationary token
There are ERC20 tokens that may make certain customizations to their ERC20 contracts.
One type of these tokens is deflationary tokens that charge a certain fee for every transfer()
or transferFrom()
For example, I will assume that newBathTokenUnderlying
is deflationary token. After line 163, the actual amount of newBathTokenUnderlying
that BathHouse gained will be smaller than initialLiquidityNew
. It will make the deposit call reverted because there are not enough fund to transfer.
Manual review
set initialLiquidityNew = newBathTokenUnderlying.balanceOf(address(this))
after line 163 and initialLiquidityExistingBathToken = desiredPairedAsset.balanceOf(address(this))
after line 178
#0 - bghughes
2022-06-03T20:24:31Z
This is correct, though I believe un needed. If the user wants to create a vault for a deflationary token they need only account for said transfer fee when calculating their initialLiquidityNew
value.
#1 - HickupHH3
2022-06-22T17:00:52Z
Not sure how you can account for transfer fee in initialLiquidityNew
since it's the same amount used for approval and deposit: IBathToken(newOne).deposit(initialLiquidityNew, msg.sender);
It simply means that deflationary / FoT tokens arent supported at all, which isn't necessarily a bad thing. There isn't a loss of assets, though function of the protocol or its availability could be impacted
. Keeping it at medium severity, although could've potentially lowered to QA too.
🌟 Selected for report: kenzo
Also found by: CertoraInc, PP1004, pedroais
https://github.com/code-423n4/2022-05-rubicon/blob/8c312a63a91193c6a192a9aab44ff980fbfd7741/contracts/rubiconPools/BathToken.sol#L505-L521 https://github.com/code-423n4/2022-05-rubicon/blob/8c312a63a91193c6a192a9aab44ff980fbfd7741/contracts/rubiconPools/BathToken.sol#L489-L502
Function previewWithdraw()
return the wrong amount of shares
needed to burn to withdraw a given asset
. It can make function withdraw(uint256 assets, address receiver, address owner)
invalid to use.
For easy to describe the issue, I assume that feeBps = 0
.
For example we have 1000 underlyingToken
and 100 shares. If we want to withdraw 9 underlyingToken
, we will need to burn at least shares = assets * totalSupply / totalAssets() = 9 * 100 / 1000 = 0
==> So we will need to burn 0
shares to get 9 tokens ? (make no sense).
We can call withdraw(9, receiver, owner)
for test, the function will revert because assetsReceived
is equal to 0 < 9
manual review
Instead of using function convertToShare
in function previewWithdraw
, we can use the following formula instead
function ceil(uint a, uint m) constant returns (uint ) { return ((a + m - 1) / m) * m; } function previewWithdraw(uint256 assets) public view returns (uint256 shares) { if (totalSupply == 0) { shares = 0; } else { uint256 amountWithdrawn; uint256 _fee = assets.mul(feeBPS).div(10000); amountWithdrawn = assets.sub(_fee); (totalSupply == 0) ? shares = assets : shares = ceil( assets.mul(totalSupply).div(totalAssets())); } }
#0 - bghughes
2022-06-03T23:19:04Z
Duplicate of #140
The RubiconRouter function maxSellAllAmount does not transfer user's fund into its address, causing the function to always revert
Since there is no fund transferred into router during the maxSellAllAmount call, it will always revert when RubiconMarket tries to take tokens from it.
Add a transfer of fund to the function
/// @dev this function takes a user's entire balance for the trade in case they want to do a max trade so there's no leftover dust function maxSellAllAmount( ERC20 pay_gem, ERC20 buy_gem, uint256 min_fill_amount ) external returns (uint256 fill) { //swaps msg.sender entire balance in the trade uint256 maxAmount = ERC20(buy_gem).balanceOf(msg.sender); ERC20(buy_gem).safeTransferFrom(msg.sender, address(this), maxAmount); fill = RubiconMarket(RubiconMarketAddress).sellAllAmount( pay_gem, maxAmount, buy_gem, min_fill_amount ); ERC20(buy_gem).transfer(msg.sender, fill); }
#0 - bghughes
2022-06-04T21:30:09Z
Duplicate of #282
#1 - HickupHH3
2022-06-25T07:14:43Z
primary issue for router not transferring funds in
🌟 Selected for report: PP1004
Also found by: hansfriese
401.6035 USDC - $401.60
https://github.com/code-423n4/2022-05-rubicon/blob/8c312a63a91193c6a192a9aab44ff980fbfd7741/contracts/RubiconRouter.sol#L290 https://github.com/code-423n4/2022-05-rubicon/blob/8c312a63a91193c6a192a9aab44ff980fbfd7741/contracts/RubiconRouter.sol#L307
The two functions maxSellAllAmount and maxBuyAllAmount will always revert in case at least (100-fee)% of user's balance can be matched with orders.
Let say Bob placed an order selling 100 USDC with a low USDT price of 1:0.95.
Alice currently has 50 USDT and they want to maxSellAllAmount into USDC.
The function will pass 50 as amount into RubiconMarket's buyAll function where it fully matches with Bob's order. Here, the buy() function will first transfer alice's 50 USDT in and later 50 * feeBPS / BPS as fee. In this case, alice can not afford to pay.
Therefore, the two functions maxSellAllAmount and maxBuyAllAmount are useless in case user's request can be fully matched.
Add the fee calculating before passing the amount to the RubiconMarket's buyAll, sellAll function.
/// @dev this function takes a user's entire balance for the trade in case they want to do a max trade so there's no leftover dust function maxBuyAllAmount( ERC20 buy_gem, ERC20 pay_gem, uint256 max_fill_amount ) external returns (uint256 fill) { //swaps msg.sender's entire balance in the trade uint256 maxAmount = _calcAmountAfterFee(ERC20(buy_gem).balanceOf(msg.sender)); fill = RubiconMarket(RubiconMarketAddress).buyAllAmount( buy_gem, maxAmount, pay_gem, max_fill_amount ); ERC20(buy_gem).transfer(msg.sender, fill); } /// @dev this function takes a user's entire balance for the trade in case they want to do a max trade so there's no leftover dust function maxSellAllAmount( ERC20 pay_gem, ERC20 buy_gem, uint256 min_fill_amount ) external returns (uint256 fill) { //swaps msg.sender entire balance in the trade uint256 maxAmount = _calcAmountAfterFee(ERC20(buy_gem).balanceOf(msg.sender)); fill = RubiconMarket(RubiconMarketAddress).sellAllAmount( pay_gem, maxAmount, buy_gem, min_fill_amount ); ERC20(buy_gem).transfer(msg.sender, fill); } function _calcAmountAfterFee(uint256 amount) internal view returns (uint256) { uint256 feeBPS = RubiconMarket(RubiconMarketAddress).getFeeBPS(); return amount.sub(amount.mul(feeBPS).div(10000)); }
#0 - HickupHH3
2022-06-25T07:14:08Z
Realised this is a separate issue from not transferring funds into the router. #376 will be the primary issue for that issue.
🌟 Selected for report: IllIllI
Also found by: 0x1337, 0x1f8b, 0x4non, 0xDjango, 0xKitsune, 0xNazgul, 0xf15ers, ACai, AlleyCat, Bahurum, BouSalman, CertoraInc, Chom, Dravee, ElKu, FSchmoede, Funen, GimelSec, Hawkeye, JC, JMukesh, Kaiziron, MaratCerby, Metatron, PP1004, Picodes, Ruhum, SmartSek, StErMi, TerrierLover, UVvirus, UnusualTurtle, WatchPug, Waze, _Adam, asutorufos, berndartmueller, blackscale, blockdev, broccolirob, c3phas, catchup, cryptphi, csanuragjain, defsec, delfin454000, dipp, eccentricexit, ellahi, fatherOfBlocks, gzeon, hansfriese, horsefacts, hubble, ilan, joestakey, kebabsec, minhquanym, oyc_109, parashar, pauliax, rotcivegaf, sach1r0, sashik_eth, shenwilly, simon135, sorrynotsorry, sseefried, throttle, unforgiven, xiaoming90
53.2504 USDC - $53.25
There is a typo in this comment.
gaurentee -> guarantee
The comment for this function is wrong
/// @notice Admin-only function to set a Bath Token's timeDelay function setBathTokenMarket(address bathToken, address newMarket) external onlyAdmin { IBathToken(bathToken).setMarket(newMarket); }
Remove redundent declaration for gas saving
function isApprovedPair(address pair) public view returns (bool) { return pair == approvedPairContract; }
Should use address(0) here for readability
Can return ++last_stratTrade_id to save on read from storage.
This part of code can be shortenned to
if (info.askId != 0) { // if delta > 0 - delta is fill => handle any amount of fill here if (askDelta > 0) { logFill(askDelta, info.strategist, info.askAsset); IBathToken(bathAssetAddress).removeFilledTradeAmount(askDelta); } if (offer1.pay_amt) { IBathToken(bathAssetAddress).cancel( info.askId, offer1.pay_amt ); } }
to save gas
Same as above comment
Since this function is only used for removing a single matching element from the array.
It is better to just make it remove the element inside itself to save gas
function removeElement(uint256 uid, uint256[] storage array) internal view { for (uint256 index = 0; index < array.length; index++) { if (uid == array[index]) { array[index] = array[array.length - 1]; return; } } revert("Didnt Find that element in live list, cannot scrub"); }
There should be a check for filledAssetToRebalance != underlying here to revert on malicious case.
#0 - HickupHH3
2022-06-27T14:22:24Z
There should be a check for filledAssetToRebalance != underlying here to revert on malicious case.
could have been a dup of #211, but insufficient details on how it's malicious.