Platform: Code4rena
Start Date: 10/05/2022
Pot Size: $50,000 USDC
Total HM: 13
Participants: 100
Period: 5 days
Judge: HardlyDifficult
Total Solo HM: 1
Id: 122
League: ETH
Rank: 18/100
Findings: 6
Award: $590.99
🌟 Selected for report: 2
🚀 Solo Findings: 0
🌟 Selected for report: hickuphh3
Also found by: BondiPestControl, GimelSec, VAD37, sseefried
447.7568 USDC - $447.76
vault.durationDays
is of type uint8
, thus allowing a maximum value of 255. 1 days = 86400
, thus fitting into a uint24
. Solc creates a temporary variable to hold the result of the intermittent multiplication vault.durationDays * 1 days
using the data type of the larger operand.
In this case, the intermittent data type used would be uint24
, which has a maximum value of 2**24 - 1 = 16777215
. The maximum number allowable before overflow achieved is therefore (2**24 - 1) / 86400 = 194
.
Insert this test case into BuyOption.t.sol
function testCannotBuyDueToOverflow() public { vm.startPrank(babe); bayc.mint(babe, 2); // duration of 195 days vaultId = c.createVault(2, address(bayc), premiumIndex, 195, strikeIndex, 0, Cally.TokenType.ERC721); vm.stopPrank(); vm.expectRevert(stdError.arithmeticError); c.buyOption{value: premium}(vaultId); }
Then run
forge test --match-contract TestBuyOption --match-test testCannotBuyDueToOverflow
This was the 1 high-severity bug that I wanted to mention at the end of the C4 TrustX showcase but unfortunately could not due to a lack of time :( It can be found in the vulnerable lottery contract on L39. Credits to Pauliax / Thunder for the recommendation and raising awareness of this bug =p
Cast the multiplication into uint32
.
vault.currentExpiration = uint32(block.timestamp) + uint32(vault.durationDays) * 1 days;
#0 - outdoteth
2022-05-15T10:15:49Z
Agree that this is high risk - a user can unintentionally create vaults that would never have been able to have been filled and result in them losing funds because the vault creation was useless. They then also have to initiate a withdraw and then actually withdraw before they can create another vault.
In terms of gas prices at 100 gwei (which it frequently was a few months ago) the total gas cost of this bug/incorrect vault creation is not insignificant.
#1 - outdoteth
2022-05-17T11:30:09Z
This issue is fixed here; https://github.com/outdoteth/cally/pull/3
#2 - HardlyDifficult
2022-05-21T01:47:11Z
This is an easy way someone could create a vault where it's not possible to buy an option, and without using an unreasonably high duration value. If this were to occur, the vault creator could immediately initiateWithdraw
and then withdraw
. No time delay is required and the only funds lost is the gas cost of those 3 transactions.
Lowering to 2 (Medium) since there's an easy recovery and no assets lost.
16.9712 USDC - $16.97
Judge has assessed an item in Issue #41 as Medium risk. The relevant finding follows:
L02: Lack of upper bound for feeRate Line References https://github.com/code-423n4/2022-05-cally/blob/1849f9ee12434038aa80753266ce6a2f2b082c59/contracts/src/Cally.sol#L120
Description Fees can be set above 1e18, preventing options from being exercised.
Recommended Mitigation Steps Consider having a hard cap of x% < 100%.
// Eg. cap protocol fee to max 5% require(feeRate_ < 5e16, "feeRate limit exceeded");
#0 - HardlyDifficult
2022-06-06T00:18:09Z
🌟 Selected for report: 0x1337
Also found by: 0x52, 0xDjango, 0xsanson, BondiPestControl, BowTiedWardens, GimelSec, IllIllI, MaratCerby, MiloTruck, PPrieditis, TrungOre, VAD37, WatchPug, berndartmueller, cccz, dipp, hake, hickuphh3, horsefacts, hubble, minhquanym, reassor, shenwilly, smiling_heretic
10.8874 USDC - $10.89
In addition to ERC721s, ERC20 based call options are also supported. There are some ERC20 tokens that charge a fee (FoT tokens) for every transfer()
/ transferFrom()
invocation.
The contract assumes that the amount receivable from the call option creation is equivalent to the vault.tokenIdOrAmount
in createVault()
, which isn’t necessarily the case. This will affect token transfers for option exercises and withdrawals, where the actual balance held by the contract could be less than vault.tokenIdOrAmount
.
One possible mitigation is to measure the asset change right before and after the safeTransferFrom()
invocation in createVault()
.
if (vault.tokenType == TokenType.ERC721) { ERC721(vault.token).transferFrom(msg.sender, address(this), vault.tokenIdOrAmount) } else { uint256 balanceBefore = ERC20(vault.token).balanceOf(address(this)); ERC20(vault.token).safeTransferFrom(msg.sender, address(this), vault.tokenIdOrAmount); _vaults[vaultId].tokenIdOrAmount = ERC20(vault.token).balanceOf(address(this)) - balanceBefore; }
#0 - outdoteth
2022-05-17T16:27:44Z
this issue is fixed here: https://github.com/outdoteth/cally/pull/6
#1 - HardlyDifficult
2022-05-24T00:32:06Z
Switching https://github.com/code-423n4/2022-05-cally-findings/issues/61 to be the primary.
16.9712 USDC - $16.97
https://github.com/code-423n4/2022-05-cally/blob/1849f9ee12434038aa80753266ce6a2f2b082c59/contracts/src/Cally.sol#L199 https://github.com/code-423n4/2022-05-cally/blob/1849f9ee12434038aa80753266ce6a2f2b082c59/contracts/src/Cally.sol#L295 https://github.com/code-423n4/2022-05-cally/blob/1849f9ee12434038aa80753266ce6a2f2b082c59/contracts/src/Cally.sol#L344
The transferFrom()
method is used instead of safeTransferFrom()
, presumably to save gas. I however argue that this isn’t recommended because:
transferFrom()
, use safeTransferFrom()
whenever possibleonERC721Received()
function, which is only triggered in the safeTransferFrom()
function and not in transferFrom()
Call the safeTransferFrom()
method instead of transferFrom()
for NFT transfers. Note that the CallyNft
contract should inherit the ERC721TokenReceiver
contract as a consequence.
abstract contract CallyNft is ERC721("Cally", "CALL"), ERC721TokenReceiver {...}
#0 - outdoteth
2022-05-17T11:55:36Z
the fix for this issue is here; https://github.com/outdoteth/cally/pull/4
🌟 Selected for report: hubble
Also found by: 0x1337, 0x1f8b, 0x4non, 0xDjango, 0xf15ers, 0xsanson, 242, Aits, AlleyCat, Bludya, BondiPestControl, BouSalman, BowTiedWardens, CertoraInc, Cityscape, Czar102, FSchmoede, Funen, Hawkeye, IllIllI, JDeryl, Kenshin, Kumpa, MaratCerby, MiloTruck, Picodes, Ruhum, TrungOre, VAD37, WatchPug, Waze, antonttc, bobirichman, catchup, cccz, cryptphi, csanuragjain, delfin454000, dipp, dirk_y, djxploit, eccentricexit, ellahi, fatherOfBlocks, hake, hansfriese, hickuphh3, horsefacts, hyh, jah, joestakey, mics, minhquanym, pedroais, pmerkleplant, radoslav11, reassor, rfa, robee, seanamani, shenwilly, shung, sikorico, sorrynotsorry, sseefried, z3s
56.0439 USDC - $56.04
durationDays
is of type uint8
, thus having a maximum value of 255. I’m unsure if it’s a bug or feature, but it would prevent creating a year-long call option.
Perhaps increase it to type uint16
. A maximum value of 65535 days ~= 170 years seems more than sufficient for a call option duration.
feeRate
Fees can be set above 1e18
, preventing options from being exercised.
Consider having a hard cap of x% < 100%.
// Eg. cap protocol fee to max 5% require(feeRate_ < 5e16, "feeRate limit exceeded");
The revert reason for checking that the dutchAuctionReserveStrike
is smaller than the starting price is "Reserve strike too small"
, when it should be "Reserve strike too large"
.
require(dutchAuctionReserveStrike < strikeOptions[dutchAuctionStartingStrikeIndex], "Reserve strike too large");
msg.value
to be strictly equal to calculated premiumThere isn’t a reason why a user should pay more than the calculated premium. It would therefore be better to ensure strict equality of msg.value
and the premium in buyOption()
.
require(msg.value == premium, "Incorrect ETH amount sent");
dutchAuctionReserveStrike
value is in ether weiFor better clarity, it would be good to state the value is in ether wei.
/// @param dutchAuctionReserveStrike The reserve strike (in ether wei) for each dutch auction
#0 - kartoonjoy
2022-05-11T12:18:17Z
Added L04 for warden hickuphh3. Kindly help add this issue in, thanks!
msg.value
to be strictly equal to calculated premium#1 - HardlyDifficult
2022-05-29T17:31:58Z
🌟 Selected for report: IllIllI
Also found by: 0v3rf10w, 0x1f8b, 0x4non, 0xDjango, 0xNazgul, 0xf15ers, 0xkatana, 0xsanson, Bludya, BowTiedWardens, CertoraInc, Cityscape, DavidGialdi, FSchmoede, Fitraldys, Funen, Hawkeye, Kenshin, MadWookie, MaratCerby, MiloTruck, Picodes, RagePit, Tadashi, TerrierLover, TomFrenchBlockchain, VAD37, WatchPug, Waze, _Adam, antonttc, bobirichman, catchup, defsec, delfin454000, djxploit, ellahi, fatherOfBlocks, gzeon, hake, hansfriese, hickuphh3, horsefacts, ignacio, joestakey, jonatascm, mics, minhquanym, oyc_109, pmerkleplant, rfa, robee, rotcivegaf, samruna, shung, sikorico, simon135, z3s
42.3576 USDC - $42.36
Gas optimizations have been benchmarked against the current configuration. This can be done by running
forge snapshot
then, after modifications, run
forge snapshot --diff
The number of solc runs used for contract compilation is 1000. This number can be bumped significantly to produce more gas efficient code (max value of 2**32-1
). More information can be found in the solidity docs.
6 to 528
The maximum number of runs attainable before the max contract size is exceeded is about 5000. While deployment costs will increase, functions will cost less gas to execute.
[default] solc_version = "0.8.13" fuzz_runs = 1000 optimizer_runs = 5000 optimizer = true
TokenType
checkThe type check on the enum TokenType
is redundant because Solidity implictly enforces this check.
66
Remove the check.
ownerOf
checkThe null ownerOf
is checked in Solmate already, making this a duplicate check.
253
Remove the check. Alternatively, consider overriding the ownerOf()
method to remove the null check in Solmate because a number of instances check ownerOf()
against msg.sender
which is guaranteed to be non-null.
The dutch auction price calculation can be unchecked because the variables are bounded, where, when the function is called internally, the following are true:
auctionEndTimestamp - block.timestamp
is at most AUCTION_DURATION = 86400
startingStrike
is a value from the strikeOptions
array, which contains a Fibonacci sequence of ether values up to 6765 ether
.Therefore, we can be sure that there are no overflow in the calculations:
0
≤ delta
≤ 86400
uint256 progress = (1e18 * delta) / AUCTION_DURATION;
means the largest value of the intermediate calculation is 1e18 * 86400
0
≤ progress
≤ 1e18
uint256 auctionStrike = (progress * progress * startingStrike) / (1e18 * 1e18);
means the largest value of the intermediate calculation is 1e18 * 1e18 * startingStrike = 1e18 * 1e18 * 6765 * 1e18 = 6.765e+57
which is within type(uint256).max
0
≤ auctionStrike
≤ startingStrike = 6765 ether = 6765 * 1e18
The assumptions made might of course not hold when called externally. Nevertheless, the gas savings seem substantial enough to consider implementing the change.
382 or 421
uint256 delta; uint256 auctionStrike; unchecked { if (auctionEndTimestamp > block.timestamp) delta = auctionEndTimestamp - block.timestamp; uint256 progress = (1e18 * delta) / AUCTION_DURATION; auctionStrike = (progress * progress * startingStrike) / (1e18 * 1e18); }
The new Yul-based optimizer that is shipped with 0.8.13
”can do much higher-level optimization across functions”. However, when I turned it on together with the increase in solc runs, an increase in gas usage is observed instead.
The optimizer can be turned on by setting via_ir = true
in the foundry.toml
file.
#0 - outdoteth
2022-05-16T19:20:22Z
high quality report