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: 24/100
Findings: 3
Award: $146.99
π Selected for report: 0
π Solo Findings: 0
π Selected for report: IllIllI
Also found by: 0x52, 0xf15ers, 0xsanson, Bludya, BondiPestControl, Czar102, GimelSec, Kumpa, _Adam, berndartmueller, catchup, crispymangoes, eccentricexit, ellahi, hake, horsefacts, pedroais, peritoflores, reassor, shenwilly, shung, smiling_heretic, sseefried, throttle
8.1655 USDC - $8.17
https://github.com/code-423n4/2022-05-cally/blob/1849f9ee12434038aa80753266ce6a2f2b082c59/contracts/src/Cally.sol#L119-L121 https://github.com/code-423n4/2022-05-cally/blob/1849f9ee12434038aa80753266ce6a2f2b082c59/contracts/src/Cally.sol#L283-L286
A malicious owner can keep the feeRate
at 1%, but if a user exercises their position, the owner can frontrun the transaction and set the feeRate
to 1e18 (100%) and steal all of the vault beneficiary's funds.
T
amount of time has passedexercise()
sending 164.3 ETH to the contract.setFee()
function and sets the feeRate
to equal 100%./// @notice Sets the fee that is applied on exercise /// @param feeRate_ The new fee rate: fee = 1% = (1 / 100) * 1e18 function setFee(uint256 feeRate_) external onlyOwner { feeRate = feeRate_; }
fee
effectively equals msg.value
.// collect protocol fee uint256 fee = 0; if (feeRate > 0) { fee = (msg.value * feeRate) / 1e18; protocolUnclaimedFees += fee; } // increment vault beneficiary's ETH balance ethBalance[getVaultBeneficiary(vaultId)] += msg.value - fee;
msg.value
= 164.3 ETH.
fee
= (164.3 ETH * 1e18) / 1e18 = 164.3 ETH.
protocolUnclaimedFees
+= 164.3 ETH.
ethBalance[getVaultBeneficiary(vaultId)]
+= 0.
ethBalance
doesn't increment since msg.value - fee
= 0.withdrawProtocolFees()
and has effectively stolen 164.3 ETH.In reality, the owner does not need to frontrun any transaction. The feeRate
can be changed at any time and go unnoticed until it is too late for affected user(s) to realize what has happened.
Manual.
To increase community trust, it is suggested to add an upper bound when setting the new feeRate
(like 5% max fee), and it may be ideal to add a timelock on top of it as well.
#0 - outdoteth
2022-05-15T18:57:28Z
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
π 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
108.446 USDC - $108.45
Contracts implementing access control's, e.g. owner
, should consider implementing a Two-Step Transfer pattern.
Otherwise it's possible that the role mistakenly transfers ownership to the wrong address, resulting in a loss of the role
Consider adding a pendingOwner
where the new owner will have to accept the ownership.
address owner; address pendingOwner; // ... function setPendingOwner(address newPendingOwner) external { require(msg.sender == owner, "!owner"); emit NewPendingOwner(newPendingOwner); pendingOwner = newPendingOwner; } function acceptOwnership() external { require(msg.sender == pendingOwner, "!pendingOwner"); emit NewOwner(pendingOwner); owner = pendingOwner; pendingOwner = address(0); }
Ownable.sol
functions.Currently, the function transferOwnership
and renounceOwnership
can be called by the owner in Cally.sol
. Both functions are a one-step process, and can be called accidentally, resulting in loss of ownership. All protocol fee's would get stuck if this is the case.
Override the functions to avoid such scenario.
Cally.sol#L359-L369
. Check @audit
tag.
/// @notice Sends any unclaimed ETH (premiums/strike) to the msg.sender function harvest() public returns (uint256 amount) { // reset premiums amount = ethBalance[msg.sender]; ethBalance[msg.sender] = 0; emit Harvested(msg.sender, amount); // transfer premiums to owner // @audit which owner? Unclear, be more specific. payable(msg.sender).safeTransferETH(amount); }
Comment may result in confusion. It is probably meant to say ...to msg.sender
or ... to vault owner
.
Fix comment.
/// @notice Withdraws the protocol fees and sends to current owner function withdrawProtocolFees() external onlyOwner returns (uint256 amount) {
/// @param tokenType The type of the underlying asset (NFT or ERC20) function createVault( uint256 tokenIdOrAmount, address token, uint8 premiumIndex, uint8 durationDays, uint8 dutchAuctionStartingStrikeIndex, uint256 dutchAuctionReserveStrike, TokenType tokenType ) external returns (uint256 vaultId) {
/// @param vaultId The tokenId of the vault to buy the option from function buyOption(uint256 vaultId) external payable returns (uint256 optionId) {
/// @notice Sends any unclaimed ETH (premiums/strike) to the msg.sender function harvest() public returns (uint256 amount) {
/// @param vaultId The tokenId of the vault to fetch the details for function vaults(uint256 vaultId) external view returns (Vault memory) {
Add @return
's.
manual, slither
constant
s should be defined rather than using magic numbers.Cally.sol::284 => fee = (msg.value * feeRate) / 1e18; Cally.sol::418 => uint256 progress = (1e18 * delta) / AUCTION_DURATION; Cally.sol::419 => uint256 auctionStrike = (progress * progress * startingStrike) / (1e18 * 1e18);
#0 - outdoteth
2022-05-16T16:25:48Z
high quality report
π 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
30.3739 USDC - $30.37
Reading array length at each iteration of the loop takes 6 gas (3 for mload and 3 to place memory_offset) in the stack. Caching the array length in the stack saves around 3 gas per iteration.
CallyNft.sol::244 => for (uint256 i = 0; i < data.length; i++) {
Store the arrayβs length in a variable before the for-loop.
!= 0
instead of > 0
for Unsigned Integer Comparison in require statements.!= 0
is cheapear than > 0
when comparing unsigned integers in require statements.
Cally.sol::170 => require(durationDays > 0, "durationDays too small");
Use != 0
instead of > 0
.
Custom errors from Solidity 0.8.4 are cheaper than revert strings (cheaper deployment cost and runtime cost when the revert condition is met)
Use custom errors instead of revert strings.
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.
Cally.sol::94 => uint256 feeRate = 0; Cally.sol::95 => uint256 protocolUnclaimedFees = 0; Cally.sol::282 => uint256 fee = 0; CallyNft.sol::244 => for (uint256 i = 0; i < data.length; i++) {
Remove explicit zero initialization.
++i
costs less gas compared to i++
or i += 1
++i
costs less gas compared to i++
or i += 1
for unsigned integer, as pre-increment is cheaper (about 5 gas per iteration). This statement is true even with the optimizer enabled.
CallyNft.sol::244 => for (uint256 i = 0; i < data.length; i++) {
Use ++i
instead of i++
to increment the value of an uint variable.
Same thing for --i
and i--
.
A division/multiplication by any number x
being a power of 2 can be calculated by shifting log2(x)
to the right/left. While the DIV
opcode uses 5 gas, the SHR
opcode only uses 3 gas. Furthermore, Solidity's division operation also includes a division-by-0 prevention which is bypassed using shifting.
CallyNft.sol::241 => bytes memory str = new bytes(2 + data.length * 2); CallyNft.sol::245 => str[2 + i * 2] = alphabet[uint256(uint8(data[i] >> 4))]; CallyNft.sol::246 => str[3 + i * 2] = alphabet[uint256(uint8(data[i] & 0x0f))]; base64.sol::78 => uint256 decodedLen = (data.length / 4) * 3;
Bad
uint256 b = a / 2; uint256 c = a / 4; uint256 d = a * 8;
Good
uint256 b = a >> 1; uint256 c = a >> 2; uint256 d = a << 3;
Use SHR/SHL.
c4udit, manual, slither