Santa's List

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

`buyPresent` charges 1 SantaToken despite a declared 2 SantaToken cost

[M-02] buyPresent charges 1 SantaToken despite a declared 2 SantaToken cost

Severity

Medium

Description

SantasList defines the cost of a purchased present as 2e18 SantaToken:

uint256 public constant PURCHASED_PRESENT_COST = 2e18;

However, buyPresent() never uses this constant. It calls SantaToken.burn(), and SantaToken.burn() always burns exactly 1e18:

function buyPresent(address presentReceiver) external {
i_santaToken.burn(presentReceiver);
_mintAndIncrement();
}
function burn(address from) external {
if (msg.sender != i_santasList) {
revert SantaToken__NotSantasList();
}
_burn(from, 1e18);
}

Affected code:

  • src/SantasList.sol:87-88

  • src/SantasList.sol:172-175

  • src/SantaToken.sol:28-33

Risk

Purchased presents are minted for half of the declared price. This breaks the protocol's token accounting and allows additional NFTs to be minted with fewer SantaTokens than intended.

The issue is especially visible for EXTRA_NICE users: they receive 1e18 SantaToken from collectPresent(), but the documented purchase price is 2e18. Despite not having enough tokens to pay the declared cost, they can still buy another present.

Impact

Medium.

The protocol mints purchased NFTs for less than the configured price. This weakens the intended scarcity and accounting model around SantaToken, because a user with only half the declared cost can still receive an additional NFT.

Likelihood

High.

Every buyPresent() call uses SantaToken.burn(), and that burn function always burns 1e18. The declared PURCHASED_PRESENT_COST of 2e18 is never enforced.

Proof of Concept

The exploit is covered by test_BuyPresentOnlyBurnsOneTokenDespiteTwoTokenCost in test/unit/SantasListTest.t.sol.

function test_BuyPresentOnlyBurnsOneTokenDespiteTwoTokenCost() public {
vm.startPrank(santa);
santasList.checkList(user, SantasList.Status.EXTRA_NICE);
santasList.checkTwice(user, SantasList.Status.EXTRA_NICE);
vm.stopPrank();
vm.warp(santasList.CHRISTMAS_2023_BLOCK_TIME() + 1);
vm.startPrank(user);
santasList.collectPresent();
assertEq(santaToken.balanceOf(user), 1e18);
assertEq(santasList.PURCHASED_PRESENT_COST(), 2e18);
santasList.buyPresent(user);
vm.stopPrank();
assertEq(santaToken.balanceOf(user), 0);
assertEq(santasList.balanceOf(user), 2);
}

Run:

forge test --match-test test_BuyPresentOnlyBurnsOneTokenDespiteTwoTokenCost -vvv

Result:

[PASS] test_BuyPresentOnlyBurnsOneTokenDespiteTwoTokenCost()

The user starts with only 1e18 SantaToken, while PURCHASED_PRESENT_COST is 2e18. The purchase still succeeds and mints an additional NFT.

Mitigation

Use the declared purchase cost when burning SantaToken:

function buyPresent(address presentReceiver) external {
i_santaToken.burn(msg.sender, PURCHASED_PRESENT_COST);
_safeMint(presentReceiver, s_tokenCounter++);
}

Update SantaToken.burn() to accept the burn amount:

function burn(address from, uint256 amount) external {
if (msg.sender != i_santasList) {
revert SantaToken__NotSantasList();
}
_burn(from, amount);
}

Add a regression test proving that a caller with only 1e18 SantaToken cannot buy a present when the declared cost is 2e18.

Updates

Lead Judging Commences

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