function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external
nonReentrant
{
if (receiver == address(0)) {
revert SA__ZeroAddress();
}
if (i_snow.balanceOf(receiver) == 0) {
revert SA__ZeroAmount();
}
if (!_isValidSignature(receiver, getMessageHash(receiver), v, r, s)) {
revert SA__InvalidSignature();
}
@> uint256 amount = i_snow.balanceOf(receiver);
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) {
revert SA__InvalidProof();
}
@> i_snow.safeTransferFrom(receiver, address(this), amount);
s_hasClaimedSnowman[receiver] = true;
emit SnowmanClaimedSuccessfully(receiver, amount);
@> i_snowman.mintSnowman(receiver, amount);
}
contract ForcedTokenBurningPoC is Test {
Snow public snow;
Snowman public snowman;
SnowmanAirdrop public airdrop;
MockWETH public weth;
address public collector = makeAddr("collector");
address public alice;
uint256 public aliceKey;
uint256 public constant BUY_FEE = 1;
bytes32 public constant MERKLE_ROOT = 0xc0b6787abae0a5066bc2d09eaec944c58119dc18be796e93de5b2bf9f80ea79a;
bytes32[] public ALICE_PROOF;
function setUp() public {
(alice, aliceKey) = makeAddrAndKey("alice");
weth = new MockWETH();
snow = new Snow(address(weth), BUY_FEE, collector);
string memory svgUri = "data:image/svg+xml;base64,PHN2Zy4uLi4+";
snowman = new Snowman(svgUri);
airdrop = new SnowmanAirdrop(MERKLE_ROOT, address(snow), address(snowman));
ALICE_PROOF.push(0xf99782cec890699d4947528f9884acaca174602bb028a66d0870534acf241c52);
ALICE_PROOF.push(0xbc5a8a0aad4a65155abf53bb707aa6d66b11b220ecb672f7832c05613dba82af);
ALICE_PROOF.push(0x971653456742d62534a5d7594745c292dda6a75c69c43a6a6249523f26e0cac1);
deal(alice, 10 ether);
}
* @notice CORE VULNERABILITY DEMONSTRATION
* @dev Proves that users are forced to burn their entire token balance
*/
function testForcedTokenBurningVulnerability() public {
console2.log("=== FORCED TOKEN BURNING VULNERABILITY ===");
console2.log("\n--- Phase 1: Alice Gets 1 Token (Merkle Tree Requirement) ---");
vm.prank(alice);
snow.earnSnow();
uint256 aliceBalance = snow.balanceOf(alice);
console2.log("Alice's Snow balance:", aliceBalance);
assertEq(aliceBalance, 1, "Alice should have 1 Snow token for merkle proof");
console2.log("\n--- Phase 2: Forced Token Burning Demonstration ---");
console2.log("SCENARIO: Alice wants to keep her Snow token but also get NFT");
console2.log("REALITY: Contract will force her to burn her token to get NFT");
console2.log("ISSUE: No option to claim without burning tokens");
vm.prank(alice);
snow.approve(address(airdrop), aliceBalance);
bytes32 messageHash = airdrop.getMessageHash(alice);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(aliceKey, messageHash);
uint256 aliceBalanceBefore = snow.balanceOf(alice);
uint256 contractBalanceBefore = snow.balanceOf(address(airdrop));
uint256 aliceNFTsBefore = snowman.balanceOf(alice);
console2.log("Before claim:");
console2.log(" Alice Snow balance:", aliceBalanceBefore);
console2.log(" Contract Snow balance:", contractBalanceBefore);
console2.log(" Alice NFT balance:", aliceNFTsBefore);
vm.prank(alice);
airdrop.claimSnowman(alice, ALICE_PROOF, v, r, s);
uint256 aliceBalanceAfter = snow.balanceOf(alice);
uint256 contractBalanceAfter = snow.balanceOf(address(airdrop));
uint256 aliceNFTsAfter = snowman.balanceOf(alice);
console2.log("\nAfter claim:");
console2.log(" Alice Snow balance:", aliceBalanceAfter);
console2.log(" Contract Snow balance:", contractBalanceAfter);
console2.log(" Alice NFT balance:", aliceNFTsAfter);
console2.log("\n=== VULNERABILITY PROVEN ===");
console2.log("CRITICAL: Alice was FORCED to burn her", aliceBalanceBefore, "Snow token(s)!");
console2.log("CRITICAL: No way to claim NFT without burning tokens!");
console2.log("CRITICAL: Users have ZERO choice in token consumption!");
assertEq(aliceBalanceAfter, 0, "Alice should have 0 Snow tokens left");
assertEq(contractBalanceAfter, aliceBalanceBefore, "Contract should hold all burned tokens");
assertEq(aliceNFTsAfter, aliceBalanceBefore, "Alice gets NFTs equal to ALL burned tokens");
console2.log("\nVULNERABILITY IMPACT:");
console2.log("1. Users cannot keep ANY Snow tokens after claiming");
console2.log("2. No partial claim functionality");
console2.log("3. Contract forces 100% token burning");
console2.log("4. Complete loss of user autonomy over assets");
console2.log("SEVERITY: MEDIUM - Forced asset consumption without consent");
}
Ran 1 test for test/ForcedTokenBurningPoC.t.sol:ForcedTokenBurningPoC
[PASS] testForcedTokenBurningVulnerability() (gas: 259776)
Logs:
=== FORCED TOKEN BURNING VULNERABILITY ===
--- Phase 1: Alice Gets 1 Token (Merkle Tree Requirement) ---
Alice's Snow balance: 1
--- Phase 2: Forced Token Burning Demonstration ---
SCENARIO: Alice wants to keep her Snow token but also get NFT
REALITY: Contract will force her to burn her token to get NFT
ISSUE: No option to claim without burning tokens
Before claim:
Alice Snow balance: 1
Contract Snow balance: 0
Alice NFT balance: 0
After claim:
Alice Snow balance: 0
Contract Snow balance: 1
Alice NFT balance: 1
=== VULNERABILITY PROVEN ===
CRITICAL: Alice was FORCED to burn her 1 Snow token(s)!
CRITICAL: No way to claim NFT without burning tokens!
CRITICAL: Users have ZERO choice in token consumption!
VULNERABILITY IMPACT:
1. Users cannot keep ANY Snow tokens after claiming
2. No partial claim functionality
3. Contract forces 100% token burning
4. Complete loss of user autonomy over assets
SEVERITY: MEDIUM - Forced asset consumption without consent
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 30.95ms (4.13ms CPU time)
Ran 1 test suite in 104.69ms (30.95ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
function claimSnowman(
address receiver,
+ uint256 amount,
bytes32[] calldata merkleProof,
uint8 v,
bytes32 r,
bytes32 s
) external nonReentrant {
if (receiver == address(0)) {
revert SA__ZeroAddress();
}
- if (i_snow.balanceOf(receiver) == 0) {
+ if (amount == 0 || amount > i_snow.balanceOf(receiver)) {
revert SA__ZeroAmount();
}
if (!_isValidSignature(receiver, getMessageHash(receiver), v, r, s)) {
revert SA__InvalidSignature();
}
- uint256 amount = i_snow.balanceOf(receiver);
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) {
revert SA__InvalidProof();
}
i_snow.safeTransferFrom(receiver, address(this), amount);
s_hasClaimedSnowman[receiver] = true;
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount);
}