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: 15/100
Findings: 6
Award: $1,057.95
🌟 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
Judge has assessed an item in Issue #294 as Medium risk. The relevant finding follows:
Owner can frontrun exercise to increase fees A malicious owner account can observe and frontrun calls to exercise and extract 100% of the strike price as a protocol fee.
Scenario:
A malicious owner observes a call to exercise in the mempool. The owner frontruns exercise and calls setFee to set feeRate to 100% The full strike price is paid as a protocol fee, and 0 ETH are credited to the vault beneficiary. Recommendation: Ensure the contract owner is a timelock proxy with a waiting period for parameter changes. Emit an event on changes to feeRate (See N-01 below).
#0 - HardlyDifficult
2022-06-06T00:16:59Z
🌟 Selected for report: BondiPestControl
Also found by: 0xf15ers, GimelSec, IllIllI, MadWookie, MiloTruck, Ruhum, VAD37, berndartmueller, cccz, csanuragjain, dipp, hake, horsefacts, jayjonah8, m9800, pedroais, throttle
31.6149 USDC - $31.61
Judge has assessed an item in Issue #294 as Medium risk. The relevant finding follows:
Beneficiary is credited additional ETH above premium The Cally#buyOption function ensures that the caller sends an ETH amount equal to or greater than the calculated premium:
require(msg.value >= premium, "Incorrect ETH amount sent");
It then credits the beneficiary with an amount equal to msg.value:
ethBalance[beneficiary] += msg.value;
If the caller of buyOption sends excess ETH above the premium amount, this additional amount is credited to the beneficiary.
Recommendation: If this is intentional, clearly document this behavior for end users. If not, consider requiring an exact premium amount rather than accepting additional ETH.
#0 - HardlyDifficult
2022-06-06T00:11:17Z
🌟 Selected for report: IllIllI
Also found by: horsefacts, smiling_heretic
921.3103 USDC - $921.31
https://github.com/code-423n4/2022-05-cally/blob/1849f9ee12434038aa80753266ce6a2f2b082c59/contracts/src/Cally.sol#L345 https://github.com/code-423n4/2022-05-cally/blob/1849f9ee12434038aa80753266ce6a2f2b082c59/contracts/src/Cally.sol#L296
If a vault is created with a rebasing ERC20 as its token
, additional balance accrued through rebases while the token is owned by the vault may be locked in the contract.
If the rebasing token balance decreases while owned by the vault, this is the same scenario as a fee on transfer token that blocks withdrawals and exercise. However, if the balance increases (for example, a positive rebasing token like stETH
), the original balance may be withdrawn, but the accrued amount will be locked.
Setup:
createVault
with a rebasing token
address and 1000 tokenIdOrAmount
.tokenIdOrAmount
equal to 1000.Cally.sol#createVault
transfers in 1000 tokens.Withdrawal:
4a. If Alice initiates and executes a withdrawal, Cally.sol#withdraw
will transfer out vault.tokenIdOrAmount
, or 1000 tokens. The additional 10 accrued tokens will be locked in the contract.
Exercise:
4b. If Bob buys and exercises an option, Cally.sol#exercise
will transfer out vault.tokenIdOrAmount
, or 1000 tokens. The additional 10 accrued tokens will be locked in the contract.
In the following test cases, the rebasing
token is a MockERC20
that simulates a 1% positive rebase when rebase(address)
is called.
Exercise.t.sol
:
function testRebasingLocksExcessBalance() public { // arrange vm.prank(babe); vaultId = c.createVault(tokenAmount, address(rebasing), 1, 1, 1, 0, Cally.TokenType.ERC20); vault = c.vaults(vaultId); optionId = c.buyOption{value: premium}(vaultId); uint256 balanceBefore = fees.balanceOf(address(this)); rebasing.rebase(address(c)); // act c.exercise{value: strike}(optionId); uint256 change = rebasing.balanceOf(address(this)) - balanceBefore; // assert assertEq(change, tokenAmount, "Should have transferred REBASE to exerciser"); assertEq(rebasing.balanceOf(address(c)), 0, "Should have transferred REBASE from Cally"); }
Withdraw.t.sol
:
function testWithdrawLocksExcessBalanceForRebasingToken() public { // arrange vaultId = c.createVault(tokenAmount, address(rebasing), 1, 1, 1, 0, Cally.TokenType.ERC20); c.initiateWithdraw(vaultId); skip(1); rebasing.rebase(address(c)); uint256 balanceBefore = rebasing.balanceOf(address(this)); // act c.withdraw(vaultId); uint256 balanceAfter = rebasing.balanceOf(address(this)); // assert assertEq(balanceAfter - balanceBefore, tokenAmount, "Should have transferred FEES to owner"); assertEq(rebasing.balanceOf(address(c)), 0, "Should have transferred FEES from Cally"); }
Supporting every weird ERC20 is difficult, and rebasing tokens are harder to detect than fee-on-transfer tokens. If feasible, use a token allowlist, or at least limit supported tokens in your UI and document the risks of nonstandard tokens for end users.
#0 - outdoteth
2022-05-15T14:38:21Z
we have no intention to support rebase tokens
#1 - outdoteth
2022-05-16T09:54:23Z
🌟 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
https://github.com/code-423n4/2022-05-cally/blob/1849f9ee12434038aa80753266ce6a2f2b082c59/contracts/src/Cally.sol#L345 https://github.com/code-423n4/2022-05-cally/blob/1849f9ee12434038aa80753266ce6a2f2b082c59/contracts/src/Cally.sol#L296
If a vault is created with a fee-on-transfer ERC20 as its token
, the underlying asset may be locked in the contract.
Setup:
createVault
with a fee-on-transfer token
address and 1000 tokenIdOrAmount
. The token takes a 1% fee on transfers.tokenIdOrAmount
equal to 1000.Cally.sol#createVault
transfers in 1000 tokens, but receives 990 tokens after fees.Withdrawal:
4a. If Alice initiates and executes a withdrawal, Cally.sol#withdraw
will attempt to transfer out vault.tokenIdOrAmount
tokens. Since the stored value is 1000, but the contract balance is only 990, this transfer will revert.
Exercise:
4b. If Bob buys and exercises an option, Cally.sol#exercise
will attempt to transfer out vault.tokenIdOrAmount
tokens. Since the stored value is 1000, but the contract balance is only 990, this transfer will revert.
In the following test cases, the fees
token is a MockERC20
that simulates a 1% fee on transfer.
Exercise.t.sol
:
function testExerciseRevertsForFeeOnTransfer() public { // arrange vm.prank(babe); vaultId = c.createVault(tokenAmount, address(fees), 1, 1, 1, 0, Cally.TokenType.ERC20); vault = c.vaults(vaultId); optionId = c.buyOption{value: premium}(vaultId); uint256 balanceBefore = fees.balanceOf(address(this)); // act c.exercise{value: strike}(optionId); uint256 change = fees.balanceOf(address(this)) - balanceBefore; // assert assertEq(change, tokenAmount, "Should have transferred FEES to exerciser"); assertEq(fees.balanceOf(address(c)), 0, "Should have transferred FEES from Cally"); }
Withdraw.t.sol
:
function testWithdrawRevertsForFeeOnTransfer() public { // arrange vaultId = c.createVault(tokenAmount, address(fees), 1, 1, 1, 0, Cally.TokenType.ERC20); c.initiateWithdraw(vaultId); skip(1); uint256 balanceBefore = fees.balanceOf(address(this)); // act c.withdraw(vaultId); uint256 balanceAfter = fees.balanceOf(address(this)); // assert assertEq(balanceAfter - balanceBefore, tokenAmount, "Should have transferred FEES to owner"); assertEq(fees.balanceOf(address(c)), 0, "Should have transferred FEES from Cally"); }
Supporting every weird ERC20 is difficult. If feasible, use a token allowlist, or at least limit supported tokens in your UI and document the risks of nonstandard tokens for end users.
Consider checking the balance before and after ERC20 transfers and reverting if the contract receives less than the expected amount. However, note that this is not a perfect solution either, since some tokens (like Tether and USDC) have transfer fees that are currently zero but may be enabled in the future.
#0 - outdoteth
2022-05-15T17:18:04Z
reference issue: https://github.com/code-423n4/2022-05-cally-findings/issues/39
🌟 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
54.8925 USDC - $54.89
exercise
to increase feesA malicious owner account can observe and frontrun calls to exercise
and extract 100% of the strike price as a protocol fee.
Scenario:
exercise
in the mempool.setFee
to set feeRate
to 100%Recommendation: Ensure the contract owner is a timelock proxy with a waiting period for parameter changes. Emit an event on changes to feeRate
(See N-01 below).
The Cally#buyOption
function ensures that the caller sends an ETH amount equal to or greater than the calculated premium:
require(msg.value >= premium, "Incorrect ETH amount sent");
It then credits the beneficiary with an amount equal to msg.value
:
ethBalance[beneficiary] += msg.value;
If the caller of buyOption
sends excess ETH above the premium amount, this additional amount is credited to the beneficiary.
Recommendation: If this is intentional, clearly document this behavior for end users. If not, consider requiring an exact premium amount rather than accepting additional ETH.
The permissioned function setFee
updates the feeRate
parameter, but does not emit an event. Consider emitting a SetFee
event that logs the previous and new feeRate
values. This enables you to monitor off chain for suspicious activity, and allows end users to observe and trust permissioned changes to this parameter.
/// @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_; }
#0 - outdoteth
2022-05-16T19:02:37Z
these can be bumped to medium severity: [L-01] Owner can frontrun exercise to increase fees: https://github.com/code-423n4/2022-05-cally-findings/issues/47 [L-02] Beneficiary is credited additional ETH above premium: https://github.com/code-423n4/2022-05-cally-findings/issues/84
#1 - HardlyDifficult
2022-05-30T18:49:04Z
#2 - HardlyDifficult
2022-05-30T18:51:01Z
🌟 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.0819 USDC - $31.08
isExercised
directly in exercise
Setting the single attribute isExercised
directly in exercise
is cheaper than storing the full vault
struct.
Current:
// mark the vault as exercised vault.isExercised = true; _vaults[vaultId] = vault;
Gas usage: 49631 avg, 65473 median, 87933 max
Optimized:
// mark the vault as exercised _vaults[vaultId].isExercised = true;
Gas usage: 48858 avg, 64413 median, 86873 max