Santa's List

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

buyPresent charges 1e18 instead of the documented PURCHASED_PRESENT_COST = 2e18

Description

  • SantasList declares uint256 public constant PURCHASED_PRESENT_COST = 2e18 and the natspec for buyPresent states the function "trades 2e18 of SantaToken for an NFT". This sets the protocol price of one present at 2e18 SantaTokens.

  • The amount that is actually destroyed is hardcoded to 1e18 inside SantaToken.burn, and PURCHASED_PRESENT_COST is never read or passed anywhere. A buyer therefore pays half of the documented price. Since EXTRA_NICE users receive exactly 1e18 SantaTokens from collectPresent, a single rewarded user can buy one full present — the economy that requires accumulating two rewards per present is silently broken.

// src/SantasList.sol
@> uint256 public constant PURCHASED_PRESENT_COST = 2e18; // declared but unused
function buyPresent(address presentReceiver) external {
@> i_santaToken.burn(presentReceiver); // amount is hidden inside burn()
_mintAndIncrement();
}
// src/SantaToken.sol
function burn(address from) external {
if (msg.sender != i_santasList) revert SantaToken__NotSantasList();
@> _burn(from, 1e18); // 1e18 burned, not 2e18
}

Risk

Likelihood: High — every call to buyPresent exhibits the bug.

Impact: Medium — protocol economics are silently halved. Present supply doubles relative to the documented tokenomics, devaluing every EXTRA_NICE reward and breaking the intended scarcity model. No direct theft, but the price advertised on-chain (PURCHASED_PRESENT_COST) does not match the price enforced at runtime.

Proof of Concept

Place this test in test/SantasListTest.t.sol and run with forge test --mt test_BuyPresentChargesHalfThePrice -vvv.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.22;
import {Test} from "forge-std/Test.sol";
import {SantasList} from "../src/SantasList.sol";
import {SantaToken} from "../src/SantaToken.sol";
contract BuyPresentPriceMismatchTest is Test {
SantasList santasList;
SantaToken santaToken;
address santa = makeAddr("santa");
address buyer = makeAddr("buyer");
function setUp() public {
vm.prank(santa);
santasList = new SantasList();
santaToken = SantaToken(santasList.getSantaToken());
// Buyer is EXTRA_NICE — collects exactly 1e18 SantaTokens.
vm.startPrank(santa);
santasList.checkList(buyer, SantasList.Status.EXTRA_NICE);
santasList.checkTwice(buyer, SantasList.Status.EXTRA_NICE);
vm.stopPrank();
vm.warp(santasList.CHRISTMAS_2023_BLOCK_TIME() + 1);
vm.prank(buyer);
santasList.collectPresent();
assertEq(santaToken.balanceOf(buyer), 1e18);
}
function test_BuyPresentChargesHalfThePrice() public {
// Per the public constant, one present should cost PURCHASED_PRESENT_COST = 2e18.
// Buyer only holds 1e18 SantaTokens — they should NOT be able to afford a present.
assertEq(santasList.PURCHASED_PRESENT_COST(), 2e18);
assertLt(santaToken.balanceOf(buyer), santasList.PURCHASED_PRESENT_COST());
// Yet the purchase succeeds: buyer pays 1e18 (half price), receives an NFT.
uint256 nftCountBefore = santasList.balanceOf(buyer);
vm.prank(buyer);
santasList.buyPresent(buyer); // burns 1e18 from buyer (see F-03 for receiver bug)
assertEq(santaToken.balanceOf(buyer), 0);
assertEq(santasList.balanceOf(buyer), nftCountBefore + 1);
}
}

Expected output:

[PASS] test_BuyPresentChargesHalfThePrice() (gas: ~85k)

Recommended Mitigation

Parameterise the burn amount and pass PURCHASED_PRESENT_COST from the caller site, ensuring the on-chain constant is the source of truth:

// src/SantaToken.sol
- 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);
}
// src/SantasList.sol
function buyPresent(address presentReceiver) external {
- i_santaToken.burn(presentReceiver);
+ i_santaToken.burn(msg.sender, PURCHASED_PRESENT_COST);
_safeMint(presentReceiver, s_tokenCounter++);
}

Apply consistently with F-03 (burn from msg.sender) and F-04 (mint to presentReceiver).

Updates

Lead Judging Commences

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