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: 25/100
Findings: 5
Award: $132.86
π Selected for report: 0
π Solo Findings: 0
16.9712 USDC - $16.97
https://github.com/code-423n4/2022-05-cally/blob/1849f9ee12434038aa80753266ce6a2f2b082c59/contracts/src/Cally.sol#L119 https://github.com/code-423n4/2022-05-cally/blob/1849f9ee12434038aa80753266ce6a2f2b082c59/contracts/src/Cally.sol#L283-L285
SETFEE()
There is no timelock on setFee()
. This is the fee that is applied in exercise()
, and determines how much the Vault Creator is actually credited upon a call option being exercised. But:
feeRate
is set to its new value when setFee()
is created.Users will be incited to create vaults if the fee is low (feeRate
is initially 0
). A malicious owner could effectively wait for vaults being created and call options to be bought, then set a very high fee, which would result in any exercised call option sending all the strike ETH to the Cally owner
.
Medium
Let's take a similar example as in the Readme file:
T
amount of time has passedThe malicious owner now calls setFee(1e18)
, setting feeRate
as 100%.
The BAYC is sent to Bob. But because of the new fee, and of this part of the code, the strike amount is collected as protocol fees, and Alice's ETH balance does not get increased. Alice has effectively lost her BAYC.
Manual Analysis
Two things can be done:
setFee()
setFee()
, ensuring all active options are not affected by the change in feeRate
.#0 - outdoteth
2022-05-15T19:23:52Z
owner can change fee at any time; https://github.com/code-423n4/2022-05-cally-findings/issues/47 owner can set fee greater than 100%: https://github.com/code-423n4/2022-05-cally-findings/issues/48
16.9712 USDC - $16.97
https://github.com/code-423n4/2022-05-cally/blob/1849f9ee12434038aa80753266ce6a2f2b082c59/contracts/src/Cally.sol#L258 https://github.com/code-423n4/2022-05-cally/blob/1849f9ee12434038aa80753266ce6a2f2b082c59/contracts/src/Cally.sol#L318
In exercise()
and withdraw()
, the ERC721 transferFrom()
method is used to transfer the premium asset from the contract to the caller. If the caller is a contract that has not implemented the onERC721Received
method properly, the NFT could be locked.
Medium
Instances include:
Manual Analysis
use OZ's safeTransferFrom()
instead of TransferFrom()
. This way, if someone uses a contract to buy the call option and that contract has not implemented the ERC721 Receiving method properly, the execute()
function will revert.
#0 - outdoteth
2022-05-15T20:49:04Z
use safeTransferFrom to prevent stuck NFTs: https://github.com/code-423n4/2022-05-cally-findings/issues/38
π 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
58.9094 USDC - $58.91
Few vulnerabilities were found examining the contracts. The main concerns are with the lack of checks in setters.
It is not recommended to emit events before the end of the computations, as the function might revert based on conditions ahead of the event emission
Low
Instances include:
Cally.sol: 195 emit NewVault(vaultId, msg.sender, token);//emitted before tokens transferred to contract. Cally.sol: 291 emit ExercisedOption(optionId, msg.sender);//emitted before tokens transferred to user. Cally.sol: 337 emit Withdrawal(vaultId, msg.sender);//emitted before harvest() call and tokens transferred to user. Cally.sol: 365 emit Harvested(msg.sender, amount);//emitted before ETH transferred to user.
Manual Analysis
Place the event emission in the last position in the function.
Setters should emit an event so that Dapps can detect important changes to storage
Low
Instances include:
Cally.sol:119: setFee(uint256 feeRate_) Cally.sol:351: setVaultBeneficiary(uint256 vaultId, address beneficiary)
Manual Analysis
Emit an event in all setters.
Some functions are missing comments.
Non-Critical
Instances include:
Cally.sol:455: function tokenURI(uint256 tokenId)
CallyNft.sol:51: function renderJSON() CallySvg.sol:136: function renderSvg() CallyNft.sol:236: function addressToString()
Manual Analysis
Add comments to these functions
When there are mappings that use the same key value, having separate fields is error prone, for instance in case of deletion or with future new fields.
Non-Critical
Instances include:
Cally.sol:103: mapping(uint256 => Vault) private _vaults; Cally.sol:108: mapping(uint256 => address) private _vaultBeneficiaries;
Manual Analysis
Group the related data in a struct and use one mapping. For instance, for the Cally.sol
mappings, the mitigation could be:
struct VaultInformation { Vault _vault; address beneficiary; }
And it would be used as a state variable:
mapping(uint256 => VaultInformation) private vaultInformation;
Some external functions calling the ERC20 methods safeTransfer
or safeTransferFrom
do not have the nonReentrant modifier and are hence unprotected to reentrancy (besides the gas limit on the methods). No funds are directly at loss but it is best practice to avoid reentrancy altogether.
Low
Instances include:
Cally.sol:158: function createVault() Cally.sol:258: function exercise() Cally.sol:368: function harvest()
Manual Analysis
Use the nonReentrant
modifier on these functions.
feeRate
can break core protocol functionThere is no maximum input value on setFee()
in Cally.sol
. But if the owner sets it to a uint greater than 1e18, the users will not be able to call exercice()
as the function will revert, breaking the protocol's functionality.
Low
Instances include:
Cally.sol:284: fee = (msg.value * feeRate) / 1e18;
If feeRate is set so that ethBalance[getVaultBeneficiary(vaultId)]
+ msg.value < fee, and the following statement will revert
Cally.sol:289: ethBalance[getVaultBeneficiary(vaultId)] += msg.value - fee;
Manual Analysis
Add a check in setFee
to ensure the new fee rate is less than a maximum maxFeeRate
. Its value depends on different factors, but considering it determines how much ETH a vault creator is receiving from a strike, it should be reasonably low (ie less than 0.5 * 1e18)
Setters should check the input value - ie make revert if it is the zero address. Here, if the vault beneficiary is set as the zero address, all the strike ETH associated with the vault will be locked.
Low
Instances include:
Cally.sol:351: setVaultBeneficiary()
Manual Analysis
Add a zero address check
#0 - outdoteth
2022-05-16T18:57:56Z
this can be bumped to medium severity: High feeRate can break core protocol function; https://github.com/code-423n4/2022-05-cally-findings/issues/48
#1 - HardlyDifficult
2022-05-22T20:41:27Z
#2 - HardlyDifficult
2022-05-30T18:56:47Z
Moved High feeRate to https://github.com/code-423n4/2022-05-cally-findings/issues/325
π 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
31.8362 USDC - $31.84
when calling ERC20 transfer functions, it is good to check the amount is non-zero, to avoid an unnecessary function call
Instances include:
Cally.sol:158: function createVault( uint256 tokenIdOrAmount) //if it is an ERC20 token, it does not make sense to create a vault for 0 tokens. Cally.sol:200 : ERC20(vault.token).safeTransferFrom(msg.sender, address(this), vault.tokenIdOrAmount); Cally.sol:296 : ERC20(vault.token).safeTransfer(msg.sender, vault.tokenIdOrAmount);
Manual Analysis
check that tokenIdOrAmount != 0
before these calls. For createVault()
add it only in the case of an ERC20 (some ERC721 may have a token with an id of 0.)
>0
is less gas efficient than !0
if you enable the optimizer at 10k AND youβre in a require statement.
Detailed explanation with the opcodes here
Instances include:
Cally.sol:170
Manual Analysis
Replace > 0
with !0
In the EVM, there is no opcode for >=
or <=
.
When using greater than or equal, two operations are performed: >
and =
.
Using strict comparison operators hence saves gas
Instances include:
Cally.sol:224: require(msg.value >= premium, "Incorrect ETH amount sent"); Cally.sol:228: require(block.timestamp >= auctionStartTimestamp, "Auction not started");
Manual Analysis
Replace <=
with <
, and >=
with >
. Do not forget to increment/decrement the compared variable
example:
-block.timestamp >= auctionStartTimestamp +block.timestamp > auctionStartTimestamp - 1
However, if 1
is negligible compared to the value of the variable, we can omit the increment.
example:
-block.timestamp >= auctionStartTimestamp +block.timestamp > auctionStartTimestamp
Custom errors from Solidity 0.8.4 are cheaper than revert strings (cheaper deployment cost and runtime cost when the revert condition is met) while providing the same amount of information, as explained here
Custom errors are defined using the error statement
Instances include:
Cally.sol:167 Cally.sol:168 Cally.sol:169 Cally.sol:170 Cally.sol:171 Cally.sol:211 Cally.sol:214 Cally.sol:217 Cally.sol:220 Cally.sol:224 Cally.sol:228 Cally.sol:260 Cally.sol:263 Cally.sol:269 Cally.sol:272 Cally.sol:304 Cally.sol:307 Cally.sol:320 Cally.sol:323 Cally.sol:328 Cally.sol:329 Cally.sol:330 Cally.sol:353 Cally.sol:354 Cally.sol:436 Cally.sol:437 Cally.sol:438 Cally.sol:456
CallyNFT.sol:15 CallyNFT.sol:16 CallyNFT.sol:36 CallyNFT.sol:42
Manual Analysis
Replace require and revert statements with custom errors.
For instance, in Cally.sol
:
Replace
require(_ownerOf[tokenId] != address(0), "URI query for NOT_MINTED token");
with
if (_ownerOf[tokenId] == address(0)) { revert IsNotMinted(tokenId); }
and define the custom error in the contract
error IsNotMinted(uint256 _tokenId_);
If a variable is not set/initialized, it is assumed to have the default value (0, false, 0x0 etc depending on the data type). Explicitly initializing it with its default value is an anti-pattern and wastes gas.
Instances include:
Cally.sol:94: uint256 public feeRate = 0; Cally.sol:95: uint256 public protocolUnclaimedFees = 0; Cally.sol:282: uint256 fee = 0;
CallyNft.sol:244: uint256 i = 0;
Manual Analysis
Remove explicit initialization for default values.
Prefix increments are cheaper than postfix increments.
Instances include:
CallyNft.sol:244: i++
Manual Analysis
change i++
to ++i
.
The default "checked" behavior costs more gas when adding/diving/multiplying, because under-the-hood those checks are implemented as a series of opcodes that, prior to performing the actual arithmetic, check for under/overflow and revert if it is detected.
if it can statically be determined there is no possible way for your arithmetic to under/overflow (such as a condition in an if statement), surrounding the arithmetic in an unchecked
block will save gas
Instances include:
CallyNft.sol:244: i bounded by data.length, which is bounded as data.length = 20, overflow check unnecessary
Manual Analysis
Place the arithmetic operations in an unchecked
block
If a value is known, it is more efficient to hardcode it than read it from memory
Instances include:
CallyNft.sol:241 CallyNft.sol:244 account is an address, so data.length == 20
Manual Analysis
Replace
data.length
with
20