Santa's List

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

# [H-03] Hardcoded Token Burn Amount and Unused `PURCHASED_PRESENT_COST` Causes 50% Protocol Economic Leakage

Root + Impact

Description

  • In `SantasList.sol`, the developer explicitly defines a state variable intended to enforce the economic cost of purchasing a holiday present NFT, set to 2 full tokens:

uint256 public constant PURCHASED_PRESENT_COST = 2e18;
  • However, a severe integration discrepancy exists between the core contract and the token asset implementation. Inside SantasList.sol::buyPresent, the developer completely fails to utilize or pass the PURCHASED_PRESENT_COST variable during execution. Concurrently, inside SantaToken.sol, the burn function ignores dynamic pricing and hardcodes the burn calculation to a static value of 1e18:

// src/SantaToken.sol
function burn(address from) external {
if (msg.sender != i_santasList) {
revert SantaToken__NotSantasList();
}
_burn(from, 1e18); // @> Root Cause: Static hardcoded assignment bypassing SantasList's pricing variable
}

Risk

Likelihood:

  • High. Every time a user initiates a valid call to buyPresent, the systemic calculation error triggers automatically by default.

Impact:

  • Protocol Economic Leakage: The application fails to burn the mandatory supply metrics specified in the documentation and codebase architecture (burning 1 SANTA instead of 2 SANTA), inflating the active circulating supply and damaging the token's structural value model.

Proof of Concept

  1. The project documentation and architecture state that purchasing a present costs 2e18 (2 SANTA tokens).

  2. Alice interacts with the deployment and invokes buyPresent.

  3. The system executes SantaToken.sol::burn, which only processes a deletion of 1e18 (1 SANTA token) due to the hardcoded parameter.

  4. Alice successfully obtains the NFT while retaining 1 full SANTA token that should have been permanently taken out of circulation.

function testPoCDecimalMismatchEconomicLeakage() public {
address victim = makeAddr("victim");
// 1. Mint 2 SantaTokens to victim.
// As developer expected, this should give victim 2 tokens (2e18 in decimals). But due to the hardcoded 1e18 in SantaToken's burn function, only 1 token will actually be burned during buyPresent, leaving victim with 1 token instead of 0 after purchase.
vm.startPrank(address(santasList));
santaToken.mint(victim);
santaToken.mint(victim);
vm.stopPrank();
uint256 balanceBefore = santaToken.balanceOf(victim);
assertEq(balanceBefore, 2e18); // Confirm victim has 2 tokens before purchase, which is the expected state before the exploit.
// --- LOG BEFORE ATTACK ---
console2.log("--- BEFORE BUY PRESENT ---");
console2.log("Victim Token Balance :", balanceBefore);
console2.log("Intended Price Cost :", santasList.PURCHASED_PRESENT_COST());
// 2. Victim buys present for themselves, which should burn 2 tokens and mint an NFT to victim. But due to the bug, only 1 token will be burned, so victim will still have 1 token left after purchase, effectively giving them a 50% discount on the present.
vm.prank(victim);
santasList.buyPresent(victim);
uint256 balanceAfter = santaToken.balanceOf(victim);
// --- LOG AFTER ATTACK ---
console2.log("--- AFTER BUY PRESENT ---");
console2.log("Victim Token Balance :", balanceAfter);
// --- ASSERTION PROOF OF BUG ---
// If the burn function worked as intended, victim's balance should be 0 after buying the present (2e18 - 2e18 = 0). But due to the hardcoded burn amount of 1e18, victim still has 1e18 left, proving that the burn did not work correctly and there is a decimal mismatch bug.
// This also proves that the victim effectively got a 50% discount on the present, since they only lost 1 token instead of 2.
assertEq(balanceAfter, 1e18);
// This proves that the economic impact of the bug is that users can buy presents for half the intended cost, which could lead to significant losses if many users exploit this vulnerability.
assertTrue(balanceAfter > (balanceBefore - santasList.PURCHASED_PRESENT_COST()));
}

Recommended Mitigation

  1. Refactor SantaToken.sol::burn to dynamically accept an amount parameter so it can support variable or structured pricing logic.

  2. Update SantasList.sol::buyPresent to explicitly pass the PURCHASED_PRESENT_COST state variable during the cross-contract execution call.

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

Lead Judging Commences

ai-first-flight-judge Lead Judge 2 days 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!