more contest details

Results Summary

Number of findings:

  • High: 4
  • Medium: 0
  • Low: 1

High Risk Findings

H-01. Address of USDC token in Deploy.s.sol is wrong causing the claiming process to fail

Submitted by CarlosAlbaWork, aymericrt, heavenz52, f3d0ss, k3ySk1lls, 4rdiii, CryptoKook, lefg, FalseGenius, Vesper, mgnfyview, kostov, paprikrumplikas, vile, 0xanmol, Josh4324, farismaulana, mirkopezo, 0xb0k0, seeu, 0xDimo, Thaddeus19, shikhar229169, naman1729, Coffee, n0kto, Louis, NightHawK, shivam21, Bube, 0xAee, BornTBH. Selected submission by: f3d0ss.

Relevant GitHub Links

https://github.com/Cyfrin/2024-04-airdropper/blob/781cf225664a4ad11e9654aaa39cc528016bf214/script/Deploy.s.sol#L8-L8

Summary

The s_zkSyncUSDC address in Deploy.s.sol is incorrectly set, leading to a failure in the claiming process. This error results in funds being stuck in the MerkleAirdrop contract due to the immutability of the token address.

Impact

All funds become permanently trapped in the MerkleAirdrop contract, rendering them inaccessible for claiming or transfer.

Proof of Concept:

To demonstrate the issue, a test contract can be added and executed using the following command:

forge test --zksync --rpc-url $RPC_ZKSYNC --mt testDeployOnZkSync

Use the RPC URL https://mainnet.era.zksync.io for testing.

Proof Of Code
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

import { MerkleAirdrop, IERC20 } from "../src/MerkleAirdrop.sol";
import { Test, console2 } from "forge-std/Test.sol";

contract MerkleAirdropTest is Test {
    MerkleAirdrop public s_airdrop;
    uint256 s_amountToCollect = (25 * 1e6); // 25.000000
    address s_collectorOne = 0x20F41376c713072937eb02Be70ee1eD0D639966C;

    bytes32 s_proofOne = 0x32cee63464b09930b5c3f59f955c86694a4c640a03aa57e6f743d8a3ca5c8838;
    bytes32 s_proofTwo = 0x8ff683185668cbe035a18fccec4080d7a0331bb1bbc532324f40501de5e8ea5c;
    bytes32[] s_proof = [s_proofOne, s_proofTwo];
    address public deployer;

    // From Deploy.t.sol
    bytes32 public s_merkleRoot = 0x3b2e22da63ae414086bec9c9da6b685f790c6fab200c7918f2879f08793d77bd;
    address public s_zkSyncUSDC = 0x1d17CBcF0D6D143135aE902365D2E5e2A16538D4;
    uint256 public s_amountToAirdrop = 4 * (25 * 1e6);

    function setUp() public {
        deployer = makeAddr("deployer");
        deal(0x1D17CbCf0D6d143135be902365d2e5E2a16538d4, deployer, 100 * 1e6);
        vm.deal(s_collectorOne, 100 ether);
    }

    function testDeployOnZkSync() public {
        if (block.chainid != 324) {
            return;
        }
        vm.startPrank(deployer);

        // From here there is the code from run()
        s_airdrop = deployMerkleDropper(s_merkleRoot, IERC20(s_zkSyncUSDC));
        // Send USDC -> Merkle Air Dropper
        IERC20(0x1d17CBcF0D6D143135aE902365D2E5e2A16538D4).transfer(address(s_airdrop), s_amountToAirdrop);
        // end code from run
        
        vm.stopPrank();
        vm.startPrank(s_collectorOne);
        s_airdrop.claim{ value: s_airdrop.getFee() }(s_collectorOne, s_amountToCollect, s_proof);
        vm.stopPrank();
    }

    function deployMerkleDropper(bytes32 merkleRoot, IERC20 zkSyncUSDC) public returns (MerkleAirdrop) {
        return (new MerkleAirdrop(merkleRoot, zkSyncUSDC));
    }
}

Tools Used

foundry-zksync

Recommendations

To resolve the issue, update the s_zkSyncUSDC address in Deploy.s.sol to the correct value:

-       address public s_zkSyncUSDC = 0x1D17CbCf0D6d143135be902365d2e5E2a16538d4; 
+       address public s_zkSyncUSDC = 0x1d17CBcF0D6D143135aE902365D2E5e2A16538D4;

H-02. Eligible users can claim their airdrop amounts over and over again, draining the contract

Submitted by 0x8840, CarlosAlbaWork, 0xb0k0, heavenz52, f3d0ss, k3ySk1lls, 4rdiii, CryptoKook, FalseGenius, good0000vegetable, lefg, mgnfyview, kostov, lilizhu, mirkopezo, Vesper, vile, imth3AK, paprikrumplikas, shivam21, Josh4324, papalardo, 0xanmol, seeu, Sajjad, Awacs, uba7, peterSR, simon0417, abhishekthakur, shikhar229169, mangocola, 0xDimo, farismaulana, Thaddeus19, Sungyuk1, 0xsalami, takeshikovacs, naman1729, jo13, Audix, Coffee, sakar, n0kto, Louis, NightHawK, 0xAee, Bube, BornTBH. Selected submission by: mgnfyview.

Description

A user eligible for the airdrop can verify themselves as being part of the merkle tree and claim their airdrop amount. However, there is no mechanism enabled to track the users who have already claimed their airdrop, and the merkle tree is still composed of the same user. This allows users to drain the MerkleAirdrop contract by calling the MerkleAirdrop::claim() function over and over again.

Impact

Severity: High
Likelihood: High

A malicious user can call the MerkleAirdrop::claim() function over and over again until the contract is drained of all its funds. This also means that other users won't be able to claim their airdrop amounts.

Proof of Code

Add the following test to ./test/MerkleAirdrop.t.sol,

    function testClaimAirdropOverAndOverAgain() public {
        vm.deal(collectorOne, airdrop.getFee() * 4);

        for (uint8 i = 0; i < 4; i++) {
            vm.prank(collectorOne);
            airdrop.claim{ value: airdrop.getFee() }(collectorOne, amountToCollect, proof);
        }

        assertEq(token.balanceOf(collectorOne), 100e6);
    }

The test passes, and the malicious user has drained the contract of all its funds.

Tools Used

Manual review and Foundry.

Recommended Mitigation

Use a mapping to store the addresses that have claimed their airdrop amounts. Check and update this mapping each time a user tries to claim their airdrop amount.

contract MerkleAirdrop is Ownable {
    using SafeERC20 for IERC20;

    error MerkleAirdrop__InvalidFeeAmount();
    error MerkleAirdrop__InvalidProof();
    error MerkleAirdrop__TransferFailed();
+   error MerkleAirdrop__AlreadyClaimed();

    uint256 private constant FEE = 1e9;
    IERC20 private immutable i_airdropToken;
    bytes32 private immutable i_merkleRoot;
+   mapping(address user => bool claimed) private s_hasClaimed;

    ...

    function claim(address account, uint256 amount, bytes32[] calldata merkleProof) external payable {
+       if (s_hasClaimed[account]) revert MerkleAirdrop__AlreadyClaimed();
        if (msg.value != FEE) {
            revert MerkleAirdrop__InvalidFeeAmount();
        }
        bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(account, amount))));
        if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) {
            revert MerkleAirdrop__InvalidProof();
        }
+       s_hasClaimed[account] = true;
        emit Claimed(account, amount);
        i_airdropToken.safeTransfer(account, amount);
    }

Now, let's unit test the changes,

    function testCannotClaimAirdropMoreThanOnceAnymore() public {
        vm.deal(collectorOne, airdrop.getFee() * 2);

        vm.prank(collectorOne);
        airdrop.claim{ value: airdrop.getFee() }(collectorOne, amountToCollect, proof);

        vm.prank(collectorOne);
        airdrop.claim{ value: airdrop.getFee() }(collectorOne, amountToCollect, proof);
    }

The test correctly fails, with the following logs,

Failing tests:
Encountered 1 failing test in test/MerkleAirdropTest.t.sol:MerkleAirdropTest
[FAIL. Reason: MerkleAirdrop__AlreadyClaimed()] testCannotClaimAirdropMoreThanOnceAnymore() (gas: 96751)

H-03. Wrong Merkle Root used in Deploy.s.sol script causing eligible user cant claim

Submitted by CarlosAlbaWork, aymericrt, 0xb0k0, f3d0ss, 4rdiii, FalseGenius, paprikrumplikas, vile, Josh4324, 0xanmol, farismaulana, mirkopezo, Vesper, abhishekthakur, shikhar229169, Thaddeus19, shishir, n0kto, Louis, NightHawK, shivam21, blackSquirrel, Bube, BornTBH, naman1729. Selected submission by: farismaulana.

Relevant GitHub Links

https://github.com/Cyfrin/2024-04-airdropper/blob/781cf225664a4ad11e9654aaa39cc528016bf214/script/Deploy.s.sol#L9

Summary

Deploy.s.sol script provide the wrong value causing eligible address can not claim when the contract are deployed using the script.

Vulnerability Details

There are mismatch in value of merkle tree and merkle proof provided in Deploy.s.sol and MerkleAirdropTest.t.sol as the latter provided the correct value but the Deploy.s.sol script provide the wrong value causing eligible address can not claim when the contract are deployed using the script.

NOTE: this assume the value of s_zkSyncUSDC used in Deploy.s.sol are already corrected using 0x1d17CBcF0D6D143135aE902365D2E5e2A16538D4 otherwise it would revert and not showing MerkleAirdrop__InvalidProof error.

If we run yarn run makeMerkle the following value would be returned:

Merkle Root: 0xf69aaa25bd4dd10deb2ccd8235266f7cc815f6e9d539e9f4d47cae16e0c36a05
Proof for address: 0x20F41376c713072937eb02Be70ee1eD0D639966C with amount: 25000000000000000000:
 [
  '0x4fd31fee0e75780cd67704fbc43caee70fddcaa43631e2e1bc9fb233fada2394',
  '0xc88d18957ad6849229355580c1bde5de3ae3b78024db2e6c2a9ad674f7b59f84'
]

the Merkle Root value 0xf69aaa25bd4dd10deb2ccd8235266f7cc815f6e9d539e9f4d47cae16e0c36a05 are used in the Deploy.s.sol script:

@>  bytes32 public s_merkleRoot = 0xf69aaa25bd4dd10deb2ccd8235266f7cc815f6e9d539e9f4d47cae16e0c36a05;
    // 4 users, 25 USDC each
    uint256 public s_amountToAirdrop = 4 * (25 * 1e6);

this is problematic because the intended value for every claim is 25 USDC or 25 * 1e6 because USDC decimal is 6 instead of 18, and the above Merkle Root value are intended for 25 * 1e18.

the claim function cannot be called successfully because the contract only held s_amountToAirdrop = 4 * (25 * 1e6) USDC and user cannot claim 25 * 1e18 as this is wrong value.

PoC

Add your zksync rpc url ZKSYNC_MAINNET_RPC_URL to .env file.

Add this helper code to Deploy.s.sol so our test can capture the address of MerkleAirdrop and IERC20 contract:

-   function run() public {
+   function run() public returns (MerkleAirdrop, IERC20) {
        vm.startBroadcast();
        MerkleAirdrop airdrop = deployMerkleDropper(s_merkleRoot, IERC20(s_zkSyncUSDC));
        // Send USDC -> Merkle Air Dropper
        IERC20(0x1d17CBcF0D6D143135aE902365D2E5e2A16538D4).transfer(address(airdrop), s_amountToAirdrop);
        vm.stopBroadcast();
+       // add helper
+       return (airdrop, IERC20(s_zkSyncUSDC));
    }

Add MerkleAirdropDeployScriptTest.t.sol to test folder.

MerkleAirdropDeployScriptTest.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

import { MerkleAirdrop, IERC20 } from "../src/MerkleAirdrop.sol";
import { AirdropToken } from "./mocks/AirdropToken.sol";
import { Deploy } from "script/Deploy.s.sol";
import { Test } from "forge-std/Test.sol";
import { console } from "forge-std/Test.sol";

contract MerkleAirdropTest is Test, Deploy {
    MerkleAirdrop public airdrop;
    IERC20 public tokenIERC20;
    AirdropToken public token;

    uint256 amountToCollect = (25 * 1e6); // 25.000000
    uint256 amountToSend = amountToCollect * 4;
    address collectorOne = 0x20F41376c713072937eb02Be70ee1eD0D639966C;

    // using proof from makeMerkle.js
    bytes32 proofOne = 0x4fd31fee0e75780cd67704fbc43caee70fddcaa43631e2e1bc9fb233fada2394;
    bytes32 proofTwo = 0xc88d18957ad6849229355580c1bde5de3ae3b78024db2e6c2a9ad674f7b59f84;
    bytes32[] public proof = [proofOne, proofTwo];

    function setUp() public {
        // assume deployer have enough USDC balance, we use deal
        address correctUSDCAddress = 0x1d17CBcF0D6D143135aE902365D2E5e2A16538D4;
        deal(address(IERC20(correctUSDCAddress)), address(msg.sender), amountToSend);
        console.log("USDC before:", IERC20(correctUSDCAddress).balanceOf(address(msg.sender)));
        // running the Deploy.s.sol script
        (airdrop, tokenIERC20) = Deploy.run();
        console.log("USDC after:", IERC20(correctUSDCAddress).balanceOf(address(msg.sender)));
    }

    function testPOCUsingDeployScriptContractBrokenBecauseWrongMerkleRoot() public {
        // assume the correct USDC address are used in deploying MerkleAirdrop.sol
        uint256 startingBalance = tokenIERC20.balanceOf(collectorOne);
        vm.deal(collectorOne, airdrop.getFee());

        vm.startPrank(collectorOne);
        airdrop.claim{ value: airdrop.getFee() }(collectorOne, amountToCollect, proof);
        vm.stopPrank();

        uint256 endingBalance = tokenIERC20.balanceOf(collectorOne);
        assertEq(endingBalance - startingBalance, amountToCollect);
    }

after that run the following forge test --zksync --fork-url $ZKSYNC_MAINNET_RPC_URL --mt testPOCUsingDeployScriptContractBrokenBecauseWrongMerkleRoot the test will FAIL:

Failing tests:
Encountered 1 failing test in test/MerkleAirdropDeployScriptTest.t.sol:MerkleAirdropTest
[FAIL. Reason: MerkleAirdrop__InvalidProof()] testPOCUsingDeployScriptContractBrokenBecauseWrongMerkleRoot() (gas: 32340)

Impact

user cannot claim the airdrop

Tools Used

manual review and foundry

Recommendations

Changing the correct Merkle Root can solve this problem.

Code

Using modified makeMerkle.js we can generate the correct value:

.
.
.
/*//////////////////////////////////////////////////////////////
                             INPUTS
//////////////////////////////////////////////////////////////*/
- const amount = (25 * 1e18).toString();
+ const amount = (25 * 1e6).toString();
const userToGetProofOf = "0x20F41376c713072937eb02Be70ee1eD0D639966C";

// (1)
const values = [
	[userToGetProofOf, amount],
	["0x277D26a45Add5775F21256159F089769892CEa5B", amount],
	["0x0c8Ca207e27a1a8224D1b602bf856479b03319e7", amount],
	["0xf6dBa02C01AF48Cf926579F77C9f874Ca640D91D", amount],
];

/*//////////////////////////////////////////////////////////////
                            PROCESS
//////////////////////////////////////////////////////////////*/
// (2)
const tree = StandardMerkleTree.of(values, ["address", "uint256"]);

// (3)
console.log("Merkle Root:", tree.root);

// (4)
for (const [i, v] of tree.entries()) {
	// if (v[0] === userToGetProofOf) {
	// (3)
	const proof = tree.getProof(i);
+   console.log(`Proof for address: ${v} with amount: ${amount}:\n`, proof);
-   console.log(`Proof for address: ${userToGetProofOf} with amount: ${amount}:\n`, proof);
-   }
}
.
.
.

after that run yarn run makeMerkle and the following should returned:

Merkle Root: 0x3b2e22da63ae414086bec9c9da6b685f790c6fab200c7918f2879f08793d77bd
Proof for address: 0x20F41376c713072937eb02Be70ee1eD0D639966C,25000000 with amount: 25000000:
 [
  '0x32cee63464b09930b5c3f59f955c86694a4c640a03aa57e6f743d8a3ca5c8838',
  '0x8ff683185668cbe035a18fccec4080d7a0331bb1bbc532324f40501de5e8ea5c'
]
Proof for address: 0x277D26a45Add5775F21256159F089769892CEa5B,25000000 with amount: 25000000:
 [
  '0x2683f462a4457349d6d7ef62d4208ef42c89c2cff9543cd8292d9269d832c3e8',
  '0xdcad361f30c4a5b102a90b4ec310ffd75c577ccdff1c678adb20a6f02e923366'
]
Proof for address: 0x0c8Ca207e27a1a8224D1b602bf856479b03319e7,25000000 with amount: 25000000:
 [
  '0xee1cda884ead2c9f34338f48263e7edd6e5f35bf4f09c9c0930d995911004eed',
  '0x8ff683185668cbe035a18fccec4080d7a0331bb1bbc532324f40501de5e8ea5c'
]
Proof for address: 0xf6dBa02C01AF48Cf926579F77C9f874Ca640D91D,25000000 with amount: 25000000:
 [
  '0x1e6784ff835523401f4db6e3ab48fa5bdf523a46a5bc0410a5639d837352b194',
  '0xdcad361f30c4a5b102a90b4ec310ffd75c577ccdff1c678adb20a6f02e923366'
]

using the correct value above, we should change the Deploy.s.sol value of s_merkleRoot: Deploy.s.sol:

-   bytes32 public s_merkleRoot = 0xf69aaa25bd4dd10deb2ccd8235266f7cc815f6e9d539e9f4d47cae16e0c36a05;
+       bytes32 public s_merkleRoot = 0x3b2e22da63ae414086bec9c9da6b685f790c6fab200c7918f2879f08793d77bd;
    // 4 users, 25 USDC each
    uint256 public s_amountToAirdrop = 4 * (25 * 1e6);

after that add the following code to MerkleAirdropDeployScriptTest.t.sol that we already created beforehand.

MerkleAirdropDeployScriptTest.t.sol:

    function testUsingDeployScriptContractCorrectMerkleRoot() public {
        // assume the correct USDC address are used in deploying MerkleAirdrop.sol
        // using the correctly generated merkleProof
        bytes32[] memory correctProofs = new bytes32[](8);
        correctProofs[0] = 0x32cee63464b09930b5c3f59f955c86694a4c640a03aa57e6f743d8a3ca5c8838;
        correctProofs[1] = 0x8ff683185668cbe035a18fccec4080d7a0331bb1bbc532324f40501de5e8ea5c;
        correctProofs[2] = 0x2683f462a4457349d6d7ef62d4208ef42c89c2cff9543cd8292d9269d832c3e8;
        correctProofs[3] = 0xdcad361f30c4a5b102a90b4ec310ffd75c577ccdff1c678adb20a6f02e923366;
        correctProofs[4] = 0xee1cda884ead2c9f34338f48263e7edd6e5f35bf4f09c9c0930d995911004eed;
        correctProofs[5] = 0x8ff683185668cbe035a18fccec4080d7a0331bb1bbc532324f40501de5e8ea5c;
        correctProofs[6] = 0x1e6784ff835523401f4db6e3ab48fa5bdf523a46a5bc0410a5639d837352b194;
        correctProofs[7] = 0xdcad361f30c4a5b102a90b4ec310ffd75c577ccdff1c678adb20a6f02e923366;

        // list of eligible addresses
        address[4] memory eligibleAddress = [
            0x20F41376c713072937eb02Be70ee1eD0D639966C,
            0x277D26a45Add5775F21256159F089769892CEa5B,
            0x0c8Ca207e27a1a8224D1b602bf856479b03319e7,
            0xf6dBa02C01AF48Cf926579F77C9f874Ca640D91D
        ];
        for (uint256 i = 0; i < eligibleAddress.length; ++i) {
            uint256 startingBalance = tokenIERC20.balanceOf(eligibleAddress[i]);
            vm.deal(eligibleAddress[i], airdrop.getFee());

            // get corresponding proof for address i
            bytes32[] memory proofs = new bytes32[](2);
            proofs[0] = correctProofs[i * 2];
            proofs[1] = correctProofs[i * 2 + 1];

            vm.startPrank(eligibleAddress[i]);
            airdrop.claim{ value: airdrop.getFee() }(eligibleAddress[i], amountToCollect, proofs);
            vm.stopPrank();

            uint256 endingBalance = tokenIERC20.balanceOf(eligibleAddress[i]);
            assertEq(endingBalance - startingBalance, amountToCollect);
        }
    }

then run the following command forge test --zksync --fork-url $ZKSYNC_MAINNET_RPC_URL --mt testUsingDeployScriptContractCorrectMerkleRoot

the test result PASS:

Ran 1 test for test/MerkleAirdropDeployScriptTest.t.sol:MerkleAirdropTest
[PASS] testUsingDeployScriptContractCorrectMerkleRoot() (gas: 63544)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 76.19s (23.89s CPU time)
## H-04. Unable to receive airdrop due to account abstraction

Submitted by mgnfyview, NightHawK, Vesper, Bube. Selected submission by: Bube.

Relevant GitHub Links

https://github.com/Cyfrin/2024-04-airdropper/blob/781cf225664a4ad11e9654aaa39cc528016bf214/src/MerkleAirdrop.sol#L30

Summary

The users that use account abstraction wallets have different addresses across chains for the same account.

Vulnerability Details

In the docs is said:

"Our team is looking to airdrop 100 USDC tokens on the zkSync era chain to 4 lucky addresses based on their activity on the Ethereum L1. The Ethereum addresses are:

0x20F41376c713072937eb02Be70ee1eD0D639966C
0x277D26a45Add5775F21256159F089769892CEa5B
0x0c8Ca207e27a1a8224D1b602bf856479b03319e7
0xf6dBa02C01AF48Cf926579F77C9f874Ca640D91D"

The user can claim his/her USDC tokens through the MerkleAirdrop::claim function. This function requires account, amount and proof array. With the help of this three arguments the merkle proof will ensure that the caller is eligible to claim. But in the generated merkle root are used the Ethereum addresses of the lucky users. But the protocol will be deployed on the zkSync era chain. If any of them uses account abstraction wallet, this lucky user will not be able to claim his/her tokens. The account abstraction wallets have different addresses in the different chains for the same account.

Impact

The users that use account abstraction wallets have different addresses on the zkSync era chain. That means these users will not be able to claim their USDC tokens, because the merkle root will require another account address (this on Ethereum).

Tools Used

Manual Review

Recommendations

Ensure that the addresses in makeMerkle file for the lucky users are their addresses for the zkSync era chain.

Medium Risk Findings

Low Risk Findings

L-01. It Can Be Economically Impractical for the Contract Owner to Claim Airdrop Fees

Submitted by vile, imth3AK, n0kto, blackSquirrel. Selected submission by: vile.

Relevant GitHub Links

https://github.com/Cyfrin/2024-04-airdropper/blob/main/src/MerkleAirdrop.sol#L15

https://github.com/Cyfrin/2024-04-airdropper/blob/main/src/MerkleAirdrop.sol#L42-L47

Summary

The low MerkleAirdrop::FEE (1 Gwei) makes it economically impractical (ETH-wise) for the owner to claim fees, even with the low gas cost of the zkSync chain. The fee should either be removed or increased to make it economically practical to claim by the owner.

Vulnerability Details

The low MerkleAirdrop::FEE (1 Gwei) makes it economically impractical (ETH-wise) for the owner to claim fees, even with the low gas cost of the zkSync chain.

The gas cost for the owner to call MerkleAirdrop::claimFees is 30,479 gas units. Using the average zkSync gas price of 0.02 Gwei, the effective total gas cost would be ~609 Gwei or 0.000000609 Ether. For it to be economically sensible to claim fees (using the current fee price of 1 Gwei), there would need to be greater than or equal to 609 successful airdrop claims to meet or exceed the gas cost. Compared to the current number of addresses that are a part of the merkle tree, there is a significant discrepancy.

POC

MerkleAirdropTest.t.sol

    address owner = vm.addr(1);

    ...

    // deploy contracts as an EOA instead of contract
    function setUp() public {
        vm.startPrank(owner);
        token = new AirdropToken();
        airdrop = new MerkleAirdrop(merkleRoot, token);
        token.mint(owner, amountToSend);
        token.transfer(address(airdrop), amountToSend);
        vm.stopPrank();
    }

    ...
    
    function test_GasExeceedsFeeClaimAmount() public {
        uint256 assumedZksyncGasPrice = 0.00000000002 ether; // 0.02 Gwei
        uint256 airdropFee = airdrop.getFee();
        vm.deal(collectorOne, airdropFee);

        vm.startPrank(collectorOne);
        airdrop.claim{ value: airdropFee }(collectorOne, amountToCollect, proof);
        vm.stopPrank();

        // assert the contract and owner have the proper balances
        assertEq(address(airdrop).balance, airdropFee);
        assertEq(owner.balance, 0);

        vm.startPrank(owner);
        uint256 gasBeforeClaim = gasleft();
        airdrop.claimFees();
        uint256 gasAfterClaim = gasleft();
        vm.stopPrank();

        // assert the contract has had its fees claimed by owner
        assertEq(address(airdrop).balance, 0);

        // assert that the amount of gas spent is greater than the fees obtained (in wei)
        uint256 gasDelta = gasBeforeClaim - gasAfterClaim;
        assertGt((gasDelta * assumedZksyncGasPrice), owner.balance);
    }

Run Test

forge test --match-test test_GasExeceedsFeeClaimAmount --gas-report -vvvv

Example Output

Ran 1 test for test/MerkleAirdropTest.t.sol:MerkleAirdropTest
[PASS] test_GasExeceedsFeeClaimAmount() (gas: 129297)
Traces:
  [129297] MerkleAirdropTest::test_GasExeceedsFeeClaimAmount()
    │ ...
    ├─ [0] VM::assertGt(620640000000 [6.206e11], 1000000000 [1e9]) [staticcall]
    │   └─ ← ()
    └─ ← ()

Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.38ms (690.20µs CPU time)
| src/MerkleAirdrop.sol:MerkleAirdrop contract |                 |       |        |       |         |
| -------------------------------------------- | --------------- | ----- | ------ | ----- | ------- |
| Deployment Cost                              | Deployment Size |       |        |       |         |
| 540806                                       | 2502            |       |        |       |         |
| Function Name                                | min             | avg   | median | max   | # calls |
| claim                                        | 59686           | 59686 | 59686  | 59686 | 1       |
| claimFees                                    | 30479           | 30479 | 30479  | 30479 | 1       | <---
| getFee                                       | 225             | 225   | 225    | 225   | 1       |

...

Ran 1 test suite in 5.26ms (2.38ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Impact

There exists an economic disinsentive for the owner to claim fees from the contract.

Tools Used

Manual Analysis, Foundry Tests & Gas Report

Recommendations

Either remove the need for a fee to be paid during a claim or increase the claim fee to make it economically practical.