Snowman Merkle Airdrop

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

Missing Upper Bound Validation on Buy Fee Allows Price Manipulation

  • The Snow token allows users to purchase tokens during the farming period, with the price determined by s_buyFee set during contract deployment.

  • The constructor validates that _buyFee is non-zero but imposes no upper limit. A deployer could set an excessively high fee, making token purchases economically unfeasible and breaking the intended dual acquisition model (free weekly earning + affordable purchases).

https://github.com/CodeHawks-Contests/2025-06-snowman-merkle-airdrop/blob/b63f391444e69240f176a14a577c78cb85e4cf71/src/Snow.sol#L73-

Risk

Likelihood: Medium

  • The contract owner/deployer controls the fee parameter during initialization

  • No technical constraints prevent setting an arbitrarily high value incentives

  • The validation occurs only once during contract creation

Impact: Medium

  • Token purchases become prohibitively expensive or impossible

  • Breaks the economic model where users should be able to supplement earned tokens with purchases

  • Users could lose funds if they accidentally purchase at inflated prices

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test, console} from "forge-std/Test.sol";
import {Snow} from "../src/Snow.sol";
contract SnowBuyFeeTest is Test {
address public constant WETH = address(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
address public collector = address(0xCollector);
address public user = address(0xUser);
function test_ExcessiveBuyFee_PoC() public {
console.log(
console.log("Deploying Snow contract with absurdly high fee...");
uint256 excessiveFee = 1000 ether;
Snow snow = new Snow(WETH, excessiveFee, collector);
console.log("\n1. Deployed with fee:", excessiveFee / 1e18, "ETH per Snow token");
console.log(" Actual cost for 1 token:", excessiveFee * 1e18 / 1e18, "ETH");
vm.deal(user, 2000 ether);
vm.prank(user);
console.log("\n2. User attempts to buy 1 Snow token...");
console.log(" Required ETH:", excessiveFee * 1e18 / 1e18, "ETH");
console.log(" User balance: 2000 ETH");
console.log
uint256 maxFee = type(uint256).max;
console.log("Attempting to deploy with max uint256 fee...");
try new Snow(WETH, maxFee, collector) {
console.log(" Deployment succeeded (should have overflowed)");
} catch {
console.log("Deployment failed due to overflow");
}
console.log("\n=== Scenario 3: 10 ETH fee ===");
uint256 highButPossible = 10 ether;
Snow snow2 = new Snow(WETH, highButPossible, collector);
console.log("Deployed with fee:", highButPossible / 1e18, "ETH");
console.log("Cost for 10 tokens:", (highButPossible * 1e18 * 10) / 1e18, "ETH");
console.log(" = $", (highButPossible * 1e18 * 10 * 3000) / 1e18, "at $3k/ETH");
}
function test_Comparison_WithAndWithoutLimit() public
console.log();
uint256[] memory testFees = new uint256[](4);
testFees[0] = 1 ether;
testFees[1] = 10 ether,
testFees[2] = 100 ether;
testFees[3] = 1000 ether;
for (uint256 i = 0; i < testFees.length; i++) {
console.log(" Fee", i + 1, ":", testFees[i] / 1e18, "ETH");
console.log(" Cost for 5 tokens:", (testFees[i] * 1e18 * 5) / 1e18, "ETH");
}
console.log("\n2. WITH 1 ETH UPPER LIMIT:");
uint256 MAX_LIMIT = 1 ether;
for (uint256 i = 0; i < testFees.length; i++) {
if (testFees[i] <= MAX_LIMIT) {
console.log(" Fee", testFees[i] / 1e18, "ETH: Allowed");
} else {
console.log(" Fee", testFees[i] / 1e18, "ETH: Rejected");
}
}
}
function test_RealisticAttackScenario() public {
uint256 intendedFee = 0.001 ether;
uint256 wrongFee = 1000;
console.log("\nIntended parameter:", intendedFee);
console.log("Wrong parameter:", wrongFee);
console.log("Difference:", (wrongFee - intendedFee) / 1e18, "ETH");
Snow snowWrong = new Snow(WETH, wrongFee, collector);
console.log("\nResult: Deployed with", wrongFee, "ETH fee");
console.log("Actual cost:", (wrongFee * 1e18) / 1e18, "ETH per token");
console.log("Error factor:", wrongFee * 1e18 / intendedFee, "x too high!");
}
function test_StatisticalImpact() public {
uint256[] memory feeLevels = new uint256[](5);
feeLevels[0] = 0.001 ether;
feeLevels[1] = 0.01 ether;
feeLevels[2] = 0.1 ether;
feeLevels[3] = 1 ether;
feeLevels[4] = 10 ether;
uint256 ethPrice = 3000;
console.log("Fee Level | ETH Cost | USD Cost | Accessibility");
console.log;
for (uint256 i = 0; i < feeLevels.length; i++) {
uint256 usdCost = (feeLevels[i] * 1e18 * ethPrice) / 1e18;
string memory accessibility;
if (feeLevels[i] <= 0.01 ether) {
accessibility = "Good";
} else if (feeLevels[i] <= 0.1 ether) {
accessibility = "Moderate";
} else if (feeLevels[i] <= 1 ether) {
accessibility = "Poor";
} else {
accessibility = "Inaccessible";
}
console.log(
string(abi.encodePacked(
vm.toString(feeLevels[i] / 1e18), " ETH",
" | ", vm.toString(feeLevels[i] * 1e18 / 1e18),
" | $", vm.toString(usdCost),
" | ", accessibility
))
);
}

Recommended Mitigation

constructor(address _weth, uint256 _buyFee, address _collector)
ERC20("Snow", "S")
Ownable(msg.sender)
{
if (_weth == address(0)) {
revert S__ZeroAddress();
}
if (_buyFee == 0) {
revert S__ZeroValue();
}
+ // Add upper bound validation
+ uint256 MAX_BUY_FEE = 1 ether; // Maximum 1 ETH per token
+ if (_buyFee > MAX_BUY_FEE) {
+ revert S__InvalidValue();
+ }
if (_collector == address(0)) {
revert S__ZeroAddress();
}
i_weth = IERC20(_weth);
s_buyFee = _buyFee * PRECISION;
s_collector = _collector;
i_farmingOver = block.timestamp + FARMING_DURATION;
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 4 hours 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!