Santa's List

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

buyPresent burns 1e18 SantaToken instead of the documented 2e18 because PURCHASED_PRESENT_COST is never used and SantaToken.burn hardcodes 1e18, so every present sells at half the specified price

Root + Impact

Description

  • The README says "buyPresent: A function that trades 2e18 of SantaToken for an NFT". The contract declares PURCHASED_PRESENT_COST = 2e18 under the comment "The cost of santa tokens for naughty people to buy presents". One EXTRA_NICE reward is 1e18, so a purchased present should cost two rewards.

  • PURCHASED_PRESENT_COST is never referenced. buyPresent calls SantaToken.burn(address), which takes no amount and always burns a hardcoded 1e18, so every purchase costs half the specified price.

    • This is independent of whose balance is burned. Changing the burn source to msg.sender (a separate submission) still charges 1e18, because burn has no amount parameter.

// src/SantasList.sol, lines 87-88
// The cost of santa tokens for naughty people to buy presents
@> uint256 public constant PURCHASED_PRESENT_COST = 2e18;
​
// src/SantasList.sol, lines 172-175
function buyPresent(address presentReceiver) external {
@> i_santaToken.burn(presentReceiver);
_mintAndIncrement();
}
​
// src/SantaToken.sol, lines 28-33
@> function burn(address from) external {
if (msg.sender != i_santasList) {
revert SantaToken__NotSantasList();
}
@> _burn(from, 1e18);
}

Risk

Likelihood:

  • It occurs on every buyPresent call by any holder, with no precondition.

  • Integrators and users who read the public PURCHASED_PRESENT_COST() see 2e18 on every call, while every purchase charges 1e18.

Impact:

  • Presents sell at 50% of the specified price.

    • One EXTRA_NICE reward of 1e18 buys a present, although at the documented price it should not.

    • In the PoC, 2e18, the documented price of one present, buys two. NFT issuance per SantaToken is double the design.

  • The public constant misreports the price. No user funds are lost by this defect alone; the protocol's only token sink is mispriced on every call.

Proof of Concept

alice and bob are checked EXTRA_NICE twice and collect after Christmas, so each holds 1 NFT and 1e18 SantaToken.

  • test_baseline_documentedPriceExceedsOneReward: PURCHASED_PRESENT_COST() is 2e18, one reward is 1e18, and debiting 2e18 from alice's 1e18 under the token's checked arithmetic reverts.

  • test_buyPresent_chargesHalfTheDocumentedPrice: alice holds exactly 1e18 and calls buyPresent(alice). The call succeeds and emits Transfer(alice, 0x0, 1e18). alice is charged 1e18, total supply falls by 1e18, which is exactly half of PURCHASED_PRESENT_COST, and alice gains an NFT.

  • test_twoE18_buysTwoPresentsInsteadOfOne: holding 2e18, alice buys two presents. A third purchase reverts.

  • test_burnAmountHardcodedIndependentOfCaller: called by SantasList, SantaToken.burn(alice) removes exactly 1e18. Fixing the burn source therefore would not fix the price.

File test/PoC_SL-06.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.22;
​
import {Test, console2} from "forge-std/Test.sol";
import {SantasList} from "../src/SantasList.sol";
import {SantaToken} from "../src/SantaToken.sol";
​
/*
* SL-06: buyPresent charges 1e18 SantaToken instead of the documented 2e18.
* README: "`buyPresent`: A function that trades `2e18` of `SantaToken` for an NFT."
* SantasList declares PURCHASED_PRESENT_COST = 2e18 but never uses it;
* SantaToken.burn(address) takes no amount and always burns 1e18.
*/
contract PoC_SL06 is Test {
SantasList internal santasList;
SantaToken internal santaToken;
​
event Transfer(address indexed from, address indexed to, uint256 amount);
​
address internal santa = makeAddr("santa");
address internal alice = makeAddr("alice");
address internal bob = makeAddr("bob");
​
function setUp() public {
vm.prank(santa);
santasList = new SantasList();
santaToken = SantaToken(santasList.getSantaToken());
​
// Santa marks alice and bob EXTRA_NICE through the intended two-step flow.
vm.startPrank(santa);
santasList.checkList(alice, SantasList.Status.EXTRA_NICE);
santasList.checkTwice(alice, SantasList.Status.EXTRA_NICE);
santasList.checkList(bob, SantasList.Status.EXTRA_NICE);
santasList.checkTwice(bob, SantasList.Status.EXTRA_NICE);
vm.stopPrank();
​
// Christmas: each collects one NFT plus exactly one reward of 1e18 SantaToken.
vm.warp(santasList.CHRISTMAS_2023_BLOCK_TIME() + 1);
vm.prank(alice);
santasList.collectPresent();
vm.prank(bob);
santasList.collectPresent();
}
​
/// Baseline: the documented price is 2e18, one EXTRA_NICE reward is 1e18,
/// so a single reward should NOT be enough to buy a present. A 2e18 debit
/// from a 1e18 balance reverts under the token's checked arithmetic.
function test_baseline_documentedPriceExceedsOneReward() public {
uint256 documentedCost = santasList.PURCHASED_PRESENT_COST();
assertEq(documentedCost, 2e18, "declared price is 2e18");
assertEq(santaToken.balanceOf(alice), 1e18, "one EXTRA_NICE reward = 1e18");
assertLt(santaToken.balanceOf(alice), documentedCost, "one reward < documented price");
​
// Same checked subtraction as solmate _burn: debiting 2e18 from a 1e18 holder reverts.
vm.prank(alice);
vm.expectRevert(); // arithmetic underflow
santaToken.transfer(bob, documentedCost);
}
​
/// Attack path: alice holds exactly one reward (1e18), calls buyPresent(alice)
/// as advertised; the call succeeds, burns only 1e18 and mints her an NFT.
function test_buyPresent_chargesHalfTheDocumentedPrice() public {
uint256 documentedCost = santasList.PURCHASED_PRESENT_COST();
uint256 supplyBefore = santaToken.totalSupply();
uint256 balBefore = santaToken.balanceOf(alice);
uint256 nftsBefore = santasList.balanceOf(alice);
​
// The burn event carries exactly 1e18, not PURCHASED_PRESENT_COST.
vm.expectEmit(true, true, false, true, address(santaToken));
emit Transfer(alice, address(0), 1e18);
​
vm.prank(alice);
santasList.buyPresent(alice); // should revert under a 2e18 price; it does not
​
uint256 charged = balBefore - santaToken.balanceOf(alice);
uint256 burnedFromSupply = supplyBefore - santaToken.totalSupply();
console2.log("PURCHASED_PRESENT_COST :", documentedCost);
console2.log("actually charged to buyer :", charged);
console2.log("removed from totalSupply :", burnedFromSupply);
​
assertEq(charged, 1e18, "buyer charged 1e18");
assertEq(burnedFromSupply, 1e18, "sink removed 1e18");
assertEq(charged * 2, documentedCost, "charged is exactly half the documented price");
assertEq(santaToken.balanceOf(alice), 0, "alice spent her single reward");
assertEq(santasList.balanceOf(alice), nftsBefore + 1, "alice received a purchased NFT");
}
​
/// Issuance doubles: 2e18 SantaToken (the documented price of ONE present)
/// buys TWO presents.
function test_twoE18_buysTwoPresentsInsteadOfOne() public {
// Bob gives alice his reward so she holds exactly PURCHASED_PRESENT_COST.
vm.prank(bob);
santaToken.transfer(alice, 1e18);
assertEq(santaToken.balanceOf(alice), santasList.PURCHASED_PRESENT_COST());
​
uint256 nftsBefore = santasList.balanceOf(alice);
​
vm.startPrank(alice);
santasList.buyPresent(alice);
// Under the documented price alice would now hold 0 and the next buy would revert.
assertEq(santaToken.balanceOf(alice), 1e18, "1e18 left after first purchase");
santasList.buyPresent(alice);
vm.stopPrank();
​
uint256 presentsBought = santasList.balanceOf(alice) - nftsBefore;
console2.log("presents bought with 2e18 :", presentsBought);
assertEq(presentsBought, 2, "2e18 bought two presents; spec says one");
assertEq(santaToken.balanceOf(alice), 0);
​
// A third purchase finally reverts: the floor is 1e18 per present, not 2e18.
vm.prank(alice);
vm.expectRevert();
santasList.buyPresent(alice);
}
​
/// Limb 4: the amount is hardcoded inside SantaToken.burn, independent of the
/// `from` argument. Even a corrected caller (burn(msg.sender)) gets 1e18 per call,
/// because burn(address) has no amount parameter.
function test_burnAmountHardcodedIndependentOfCaller() public {
vm.prank(bob);
santaToken.transfer(alice, 1e18); // alice: 2e18
​
uint256 before = santaToken.balanceOf(alice);
vm.prank(address(santasList)); // the only authorised caller, as a fixed buyPresent would be
santaToken.burn(alice);
assertEq(before - santaToken.balanceOf(alice), 1e18, "burn(address) always removes 1e18");
}
}

Run from the repository root:

FOUNDRY_FFI=false FOUNDRY_DISABLE_NIGHTLY_WARNING=1 forge test --match-path test/PoC_SL-06.t.sol -vv

Result:

Ran 4 tests for test/PoC_SL-06.t.sol:PoC_SL06
[PASS] test_baseline_documentedPriceExceedsOneReward() (gas: 25901)
[PASS] test_burnAmountHardcodedIndependentOfCaller() (gas: 34212)
[PASS] test_buyPresent_chargesHalfTheDocumentedPrice() (gas: 82838)
Logs:
PURCHASED_PRESENT_COST : 2000000000000000000
actually charged to buyer : 1000000000000000000
removed from totalSupply : 1000000000000000000
​
[PASS] test_twoE18_buysTwoPresentsInsteadOfOne() (gas: 112152)
Logs:
presents bought with 2e18 : 2
​
Suite result: ok. 4 passed; 0 failed; 0 skipped

Recommended Mitigation

Give burn an amount and pass the declared price. This diff changes only the amount. The burn source should also become msg.sender, which is the separate buyPresent burn-source submission.

--- a/src/SantaToken.sol
+++ b/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);
}
--- a/src/SantasList.sol
+++ b/src/SantasList.sol
function buyPresent(address presentReceiver) external {
- i_santaToken.burn(presentReceiver);
+ i_santaToken.burn(presentReceiver, PURCHASED_PRESENT_COST);
_mintAndIncrement();
}
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!