Snowman Merkle Airdrop

AI First Flight #10
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Impact: medium
Likelihood: high
Invalid

buySnow decides the payment currency by exact equality on msg.value and Snow has no refund path, so any ETH paid outside that exact match is only reachable by the collector

Description

buySnow(uint256 amount) chooses between two payment currencies with a strict equality test (src/Snow.sol L79–L90):

function buySnow(uint256 amount) external payable canFarmSnow {
    if (msg.value == (s_buyFee * amount)) {      // L80 — exact match only, no >= and no overflow guard
        _mint(msg.sender, amount);               // ETH path: accepted, nothing is returned to the payer
    } else {
        i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount));  // L83 — everything else
        _mint(msg.sender, amount);               //   is treated as a WETH payment
    }
    s_earnTimer = block.timestamp;
    emit SnowBought(msg.sender, amount);
}

msg.value is only ever read in that one comparison. There is no refund path anywhere in Snow.sol: no per-payer ETH balance, no withdraw, no refund, and the contract has no receive()/fallback() that could account for value sent alongside a failed purchase. So the moment msg.value is anything other than exactly s_buyFee * amount, the user's ETH stops being the user's money — it either reverts the whole call (if the WETH transfer also fails) or it sits in the contract with nothing that can send it back to the person who sent it.

The only exit for value held by Snow is collectFee() (L101–L107), and it pays whoever holds the collector role at that instant, not whoever paid:

function collectFee() external onlyCollector {
    uint256 collection = i_weth.balanceOf(address(this));
    i_weth.transfer(s_collector, collection);                                          // all WETH -> s_collector
    (bool collected,) = payable(s_collector).call{value: address(this).balance}("");  // ALL remaining ETH -> s_collector
    require(collected, "Fee collection failed!!!");
}

function changeCollector(address _newCollector) external onlyCollector {   // L109 — no owner, no timelock
    if (_newCollector == address(0)) revert S__ZeroAddress();
    s_collector = _newCollector;
    emit NewCollector(_newCollector);
}

collectFee sweeps address(this).balance, so it does not distinguish the protocol's fee income from ETH that arrived by mistake, and changeCollector is guarded only by onlyCollector: the outgoing collector can repoint the destination at any address it controls, immediately and permanently. The cost parameter is also a plain multiplier — with the deployed _buyFee = 5 and PRECISION = 1e18 (script/DeploySnow.s.sol), s_buyFee * amount is unbounded in amount, so a large order silently moves to the WETH branch instead of being rejected.

Risk

  • User funds are permanently captured by a role, not returned to their owner. ETH sent with a purchase that does not match exactly can only leave Snow through collectFee, which pays s_collector. There is no code path that returns it to the payer, so an ordinary rounding mistake becomes a donation to whoever holds the collector role.

  • The exact-match requirement makes payments fail for no useful reason. A payment of fee - 1 wei or fee + 1 wei is not accepted as ETH and instead triggers a WETH transferFrom; users who hold only ETH (the common case, since the contract accepts ETH at all) get their transaction reverted and pay gas for it. The strict equality is the only reason this happens — nothing in the protocol needs an exact match to price an order.

  • The collector role controls all value with no counterweight. collectFee and changeCollector both need only onlyCollector, there is no timelock, no owner override and no accounting of who contributed what. A collector can hand the entire balance — fee income plus misdirected user ETH — to an address it chose, in one transaction.

Impact is medium (user funds are lost or unreachable, and the fee destination is unilateral; the protocol's core airdrop logic is not corrupted) and likelihood is high: any msg.value that is not exactly the cost is a normal user mistake, and ETH payment is the path the contract itself invites.

Proof of Concept

Overview: buySnow decides the currency by exact equality on msg.value, and no function in Snow.sol can return ETH to a payer — the only exit is collectFee(), which pays whoever is s_collector at that moment, and changeCollector() needs only the collector role.

Actors — Attacker: the current collector (can move the fee destination to any address it controls). Victim: users whose msg.value does not match the cost exactly (a normal rounding mistake), whose ETH either reverts the whole transaction or stays in the contract. Protocol: Snow (buySnow payment branch, collectFee / changeCollector sweep).

test/FindingSnowmanAirdrop.t.sol::test_F3_overpaid_eth_is_only_reachable_by_the_collector

uint256 fee = snow.s_buyFee();                 // 5 * PRECISION (script/DeploySnow.s.sol)
MockWETH w = MockWETH(_wethAddress());
vm.prank(jerry);
w.mint(jerry, 10 * fee);                       // jerry CAN pay in WETH ...

// ... but he deliberately does not approve it and overpays ETH by 1 whole ether.
// msg.value (fee + 1e18) != s_buyFee * 1 -> the else branch runs -> safeTransferFrom fails.
vm.prank(jerry);
(bool ok,) = address(snow).call{value: fee + 1 ether}(abi.encodeWithSelector(Snow.buySnow.selector, 1));

assertEq(ok, false);                            // overpaying is NOT accepted as an ETH purchase
assertEq(snow.balanceOf(jerry), 0);             // nothing was minted
assertEq(w.balanceOf(jerry), 10 * fee);         // and the WETH branch took nothing either
assertEq(address(snow).balance, contractBefore);// no refund logic exists anywhere in Snow.sol

address collector = snow.getCollector();
vm.prank(collector);
snow.collectFee();                              // the only way out pays s_collector, never the payer
assertEq(address(snow).balance, 0);

test_F3b_eth_payment_must_be_exact — one wei short reverts, the exact amount succeeds:

vm.prank(u1);
vm.expectRevert();                              // fee - 1: not the ETH branch -> WETH transferFrom fails
snow.buySnow{value: fee - 1}(1);

vm.prank(u1);
snow.buySnow{value: fee}(1);                   // only an exact match is accepted as an ETH purchase
assertEq(snow.balanceOf(u1), 1);

test_F4_collector_can_take_all_eth_after_changeCollector — a user pays exactly, the collector role repoints the destination, then all of it is swept there:

vm.prank(victory);
snow.buySnow{value: snow.s_buyFee()}(1);
assertEq(address(snow).balance, fee);          // the contract holds a user's ETH
vm.prank(snow.getCollector());
snow.changeCollector(makeAddr("new_collector"));   // only onlyCollector is required — no owner, no timelock
vm.prank(newCollector);
snow.collectFee();
assertEq(newCollector.balance, fee);           // every wei a user paid now belongs to an address the previous collector chose

$ forge test --match-test "regex:test_F3|test_F4_collector" -vvvv
[PASS] test_F3_overpaid_eth_is_only_reachable_by_the_collector() (gas: 1073691220)
Logs:
overpaid payment: tx failed, no refund path exists; collectFee pays the collector only
[PASS] test_F3b_eth_payment_must_be_exact() (gas: 145946)
[PASS] test_F4_collector_can_take_all_eth_after_changeCollector() (gas: 205766)
Suite result: ok. 3 passed; 0 failed

Full suite: forge test -> 20 tests passed, 0 failed. test_F3b is the direct proof of the strict-equality branch (fee - 1 reverts, fee succeeds); test_F4 shows the consequence (a user's ETH ends up at an address chosen by the outgoing collector).

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 35 minutes ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!