Santa's List

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

`SantaToken.burn` Hard-Codes `1e18` Instead of Using `PURCHASED_PRESENT_COST` (`2e18`)

Description

  • SantasList declares a public constant PURCHASED_PRESENT_COST = 2e18 that documents the number of SantaTokens required to purchase a present via buyPresent. The README confirms this: "A function that trades 2e18 of SantaToken for an NFT."

  • SantaToken.burn — the only function that destroys tokens — hard-codes a burn amount of 1e18 rather than accepting an amount parameter or referencing PURCHASED_PRESENT_COST. As a result, every call to buyPresent costs exactly half the documented price. The constant PURCHASED_PRESENT_COST is declared but never referenced anywhere in the codebase — it is dead documentation:

// SantasList.sol
// @> Declared cost is 2e18 — but this constant is never used in any function call
uint256 public constant PURCHASED_PRESENT_COST = 2e18;
// SantaToken.sol
function burn(address from) external {
if (msg.sender != i_santasList) {
revert SantaToken__NotSantasList();
}
// @> Hard-coded 1e18 — half the documented PURCHASED_PRESENT_COST
_burn(from, 1e18);
}

Risk

Likelihood:

  • Every single call to buyPresent (after fixing CRIT-02) will burn only 1e18 tokens — the discrepancy affects 100% of transactions.

  • No special conditions are needed; the mismatch is structural and permanent until a contract upgrade or redeployment.

Impact:

  • The economic model is broken: users pay half the price documented in the spec and the README.

  • Token supply deflation is half the intended rate, affecting the scarcity and incentive model for SantaTokens.

  • PURCHASED_PRESENT_COST existing as dead code creates a false sense of correctness for reviewers who see the constant and assume it is enforced.

Proof of Concept

// 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 HIGH01_BurnAmountMismatch is Test {
SantasList santasList;
SantaToken santaToken;
address santa = makeAddr("santa");
address user = makeAddr("user");
function setUp() public {
vm.prank(santa);
santasList = new SantasList();
santaToken = SantaToken(santasList.getSantaToken());
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.prank(user);
santasList.collectPresent(); // user gets 1 NFT + 1e18 SantaTokens
}
function test_BurnCostsHalfTheDocumentedPrice() public {
// Documented cost
uint256 documentedCost = santasList.PURCHASED_PRESENT_COST();
assertEq(documentedCost, 2e18, "documented cost is 2e18");
uint256 balanceBefore = santaToken.balanceOf(user);
assertEq(balanceBefore, 1e18);
// buyPresent burns only 1e18 (via SantaToken.burn), not 2e18
// NOTE: user must have tokens; this also demonstrates CRIT-02 is present
// For isolation, call burn directly on santaToken via prank as santasList
vm.prank(address(santasList));
santaToken.burn(user);
uint256 burned = balanceBefore - santaToken.balanceOf(user);
// Only 1e18 was burned — not the documented 2e18
assertEq(burned, 1e18, "only 1e18 burned");
assertNotEq(burned, documentedCost, "burn amount != PURCHASED_PRESENT_COST");
}
}

Explanation: The test reads PURCHASED_PRESENT_COST from SantasList (which is 2e18) and then exercises SantaToken.burn directly (simulating a buyPresent call). The actual amount burned is 1e18 — exactly half the documented constant. The test asserts they do not match, confirming the invariant violation.

Recommended Mitigation

// SantaToken.sol — accept an amount parameter instead of hard-coding
- 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);
}
// SantasList.sol — pass PURCHASED_PRESENT_COST to the burn call
function buyPresent(address presentReceiver) external {
- i_santaToken.burn(presentReceiver); // also fix CRIT-02 here
+ i_santaToken.burn(msg.sender, PURCHASED_PRESENT_COST);
_mintAndIncrement();
}

Explanation: Parameterising the burn amount removes the hard-coded value and delegates the cost decision to the caller (SantasList). SantasList then uses the existing PURCHASED_PRESENT_COST constant, making the constant live code rather than dead documentation. This ties the on-chain enforcement directly to the documented invariant. If the cost needs to change in a future upgrade, only one constant needs updating rather than hunting for magic numbers across multiple contracts.

Updates

Lead Judging Commences

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