Santa's List

AI First Flight #3
Beginner FriendlyFoundry
EXP
View results
Submission Details
Severity: medium
Valid

A present costs 1e18, not the documented PURCHASED_PRESENT_COST (2e18) — the cost constant is never used and SantaToken.burn hardcodes 1e18

Description

The protocol documents a present price of 2e18 and declares a constant for it:

// The cost of santa tokens for naughty people to buy presents
uint256 public constant PURCHASED_PRESENT_COST = 2e18;

The README agrees: buyPresent "trades 2e18 of SantaToken for an NFT." But PURCHASED_PRESENT_COST is never referenced anywhere in the code. The only burn that buyPresent performs runs through SantaToken.burn, which destroys a hardcoded 1e18:

// SantasList.buyPresent
i_santaToken.burn(presentReceiver);
// SantaToken
function burn(address from) external {
if (msg.sender != i_santasList) {
revert SantaToken__NotSantasList();
}
_burn(from, 1e18); // hardcoded — half of PURCHASED_PRESENT_COST
}

So a present actually costs 1e18, exactly half the documented and intended 2e18. The declared cost constant is dead code, and there is no way for the contract to charge the correct price without changing the burn.

This is an independent defect from the inverted-parties bug in buyPresent: even after buyPresent is fixed to burn from the buyer and mint to the receiver, the amount burned is still wrong, so buyers pay half price. Because EXTRA_NICE users mint SantaToken at 1e18 per collected present (SantaToken.mint_mint(to, 1e18)), the intended economics are "two presents earned to buy one" — at half price it becomes "one earned buys one," doubling the effective supply of purchasable NFTs.

Risk

Impact: Medium. The documented cost invariant (2e18 per present) is not enforced; every purchase settles at half price, inflating how many NFTs the token supply can buy and breaking the intended token sink.

Likelihood: High. Deterministic on every buyPresent call — the wrong amount is always burned.

Proof of Concept

function test_presentCostsHalfPrice() public {
address buyer = makeAddr("buyer");
_fundSantaTokens(buyer, 2e18); // buyer holds exactly the documented cost
// (fix the inverted-parties bug first, or read the burn source directly)
uint256 before = santaToken.balanceOf(buyer);
vm.prank(buyer);
santasList.buyPresent(buyer);
// documented cost is PURCHASED_PRESENT_COST (2e18); only 1e18 is actually burned
assertEq(before - santaToken.balanceOf(buyer), 1e18);
assertEq(santasList.PURCHASED_PRESENT_COST(), 2e18); // constant declared but unused
}

Expected: PURCHASED_PRESENT_COST (2e18) is burned per present. Actual: 1e18 is burned.

Recommended Mitigation

Make the burn amount the source-of-truth constant instead of a hardcoded literal. Parametrise SantaToken.burn and pass the cost from SantasList:

// SantaToken
function burn(address from, uint256 amount) external {
if (msg.sender != i_santasList) {
revert SantaToken__NotSantasList();
}
_burn(from, amount);
}
// SantasList.buyPresent
i_santaToken.burn(msg.sender, PURCHASED_PRESENT_COST);

This charges the documented 2e18 and removes the dead constant.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 2 hours ago
Submission Judgement Published
Validated
Assigned finding tags:

[M-01] Cost to buy NFT via SantasList::buyPresent is 2e18 SantaToken but it burns only 1e18 amount of SantaToken

## Description - The cost to buy NFT as mentioned in the docs is 2e18 via the `SantasList::buyPresent` function but in the actual implementation of buyPresent function it calls the SantaToken::burn function which doesn't take any parameter for amount and burns a fixed 1e18 amount of SantaToken, thus burning only half of the actual amount that needs to be burnt, and hence user can buy present for their friends at cheaper rates. - Along with this the user is able to buy present for themselves but the docs mentions that present can be bought only for other users. ## Vulnerability Details The vulnerability lies in the code in the function `SantasList::buyPresent` at line 173 and in `SantaToken::burn` at line 28. The function `burn` burns a fixed amount of 1e18 SantaToken whenever `buyPresent` is called but the true value of SantaToken that was expected to be burnt to mint an NFT as present is 2e18. ```cpp function buyPresent(address presentReceiver) external { @> i_santaToken.burn(presentReceiver); _mintAndIncrement(); } ``` ```cpp function burn(address from) external { if (msg.sender != i_santasList) { revert SantaToken__NotSantasList(); } @> _burn(from, 1e18); } ``` ## PoC Add the test in the file: `test/unit/SantasListTest.t.sol`. Run the test: ```cpp forge test --mt test_UsersCanBuyPresentForLessThanActualAmount ``` ```cpp function test_UsersCanBuyPresentForLessThanActualAmount() public { vm.startPrank(santa); // Santa checks user once as EXTRA_NICE santasList.checkList(user, SantasList.Status.EXTRA_NICE); // Santa checks user second time santasList.checkTwice(user, SantasList.Status.EXTRA_NICE); vm.stopPrank(); // christmas time 🌳🎁 HO-HO-HO vm.warp(santasList.CHRISTMAS_2023_BLOCK_TIME()); // user collects their present vm.prank(user); santasList.collectPresent(); // balance after collecting present uint256 userInitBalance = santaToken.balanceOf(user); // now the user holds 1e18 SantaToken assertEq(userInitBalance, 1e18); vm.prank(user); santaToken.approve(address(santasList), 1e18); vm.prank(user); // user buy present // docs mention that user should only buy present for others, but they can buy present for themselves santasList.buyPresent(user); // only 1e18 SantaToken is burnt instead of the true price (2e18) assertEq(santaToken.balanceOf(user), userInitBalance - 1e18); } ``` ## Impact - Protocol mentions that user should be able to buy NFT for 2e18 amount of SantaToken but users can buy NFT for their friends by burning only 1e18 tokens instead of 2e18, thus NFT can be bought at much cheaper rate which is half of the true amount that was expected to buy NFT. - User can buy a present for themselves but docs strictly mentions that present can be bought for someone else. ## Recommendations Include an argument inside the `SantaToken::burn` to specify the amount of token to burn and also update the `SantasList::buyPresent` function with updated parameter for `burn` function to pass correct amount of tokens to burn. - Update the `SantaToken::burn` function ```diff -function burn(address from) external { +function burn(address from, uint256 amount) external { if (msg.sender != i_santasList) { revert SantaToken__NotSantasList(); } - _burn(from, 1e18); + _burn(from, amount); } ``` - Update the `SantasList::buyPresent` function ```diff + error SantasList__ReceiverIsCaller(); function buyPresent(address presentReceiver) external { + if (msg.sender == presentReceiver) { + revert SantasList__ReceiverIsCaller(); + } - i_santaToken.burn(presentReceiver); + i_santaToken.burn(presentReceiver, PURCHASED_PRESENT_COST); _mintAndIncrement(); } ```

Support

FAQs

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

Give us feedback!