Trick or Treat

First Flight #27
Beginner FriendlyFoundry
100 EXP
View results
Submission Details
Severity: medium
Invalid

M-02: User is not required to pay remainder on a "Tricked" Treat, before attempting to re-mint

Summary

The current design of the protocol assumes that if a user gets an unlucky roll, they will pay double for that NFT.
However, there is no incentive for the user to actually pay the remaining amount by calling on resolveTrick, with the exception that they might have REALLY bad luck if they call on trickOrTreat again.

Vulnerability Details

Consider the scenario in which a user getting an unlucky roll. They are met with the decision to either pay the remainder using resolveTrick, essentially paying an over-collateralized amount (double the cost of the NFT) of funds that they may not have, or instead try their luck again by calling on trickOrTreat. Calling on trickOrTreat again would mean either paying the normal amount or hopefully getting a lucky roll to pay half.
Either way the user has lost their money, but the issue here is that they may not be able to send double the funds again to resolve the trick and could instead continue to mint other NFTs. Furthermore, by not requiring the user to complete an NFT mint, the user could exploit sending in half the cost when minting, expecting the call on trickOrTreat to either revert, get lucky at half the price, or get unlucky and only lose half.

Impact

The impact of this vulnerability is a MEDIUM as it could result in a loss of potential funds from users trying to mint an NFT. Furthermore, while vulnerability is rare to come across, but could pose an issue due to its unintented side-effects.

POC

First, please create a test file SpookySwapTest.t.sol under ~/test/ with the following setup:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import { Test, console } from "forge-std/Test.sol";
import { SpookySwap } from "../src/TrickOrTreat.sol";
contract SpookySwapTest is Test {
SpookySwap public spookySwap;
uint256 userStartBalance = 3 ether;
address user = makeAddr("user");
address user = makeAddr("owner");
function setUp() public {
deal(user, userStartBalance);
SpookySwap.Treat[] memory treats = new SpookySwap.Treat[]();
treats[0] = SpookySwap.Treat("WereGummy", 1 ether, "");
vm.startPrank(owner);
spookySwap = new SpookySwap(treats);
vm.stopPrank();
}
function helper_GetTreat(string memory _treatName) public view returns (SpookySwap.Treat memory) {
(string memory treatName, uint256 treatCost, string memory treatMeta) = spookySwap.treatList(_treatName);
return SpookySwap.Treat(treatName, treatCost, treatMeta);
}
}

In this following test, I created a function trickOrTreat_Trick that would guarantee getting an unlucky roll inside SpookySwap. The only difference from this function to trickOrTreat is the hardcoded costMultiplierNumerator and costMultiplierDenominator variables:

function trickOrTreat_Trick(string memory _treatName) public payable nonReentrant {
Treat memory treat = treatList[_treatName];
require(treat.cost > 0, "Treat cost not set.");
@> uint256 costMultiplierNumerator = 2;
@> uint256 costMultiplierDenominator = 1;
uint256 requiredCost = (treat.cost * costMultiplierNumerator) / costMultiplierDenominator;
if (costMultiplierNumerator == 2 && costMultiplierDenominator == 1) {
// Double price case (trick)
if (msg.value >= requiredCost) {
// User sent enough ETH
mintTreat(msg.sender, treat);
} else {
// User didn't send enough ETH
// Mint NFT to contract and store pending purchase
uint256 tokenId = nextTokenId;
_mint(address(this), tokenId);
_setTokenURI(tokenId, treat.metadataURI);
nextTokenId += 1;
pendingNFTs[tokenId] = msg.sender;
pendingNFTsAmountPaid[tokenId] = msg.value;
tokenIdToTreatName[tokenId] = _treatName;
emit Swapped(msg.sender, _treatName, tokenId);
// User needs to call fellForTrick() to finish the transaction
}
} else {
// Normal price or half price
require(msg.value >= requiredCost, "Insufficient ETH sent for treat");
mintTreat(msg.sender, treat);
}
// Refund excess ETH if any
if (msg.value > requiredCost) {
uint256 refund = msg.value - requiredCost;
(bool refundSuccess,) = msg.sender.call{value: refund}("");
require(refundSuccess, "Refund failed");
}
}

Then add the following test:

function test_UserCanSkipPayingDouble() public {
string[] memory treats = spookySwap.getTreats();
string memory _treat = treats[0];
SpookySwap.Treat memory curTreat = helperGetTreat(_treat);
vm.startPrank(user);
spookySwap.trickOrTreat_Trick{value: 1 ether}(curTreat.name);
vm.stopPrank();
uint256 curTokenId = spookySwap.nextTokenId()-1;
console.log("curTokenId: ", curTokenId);
address curBuyer = spookySwap.pendingNFTs(curTokenId);
assertEq(curBuyer, user);
string memory pendingNFT = spookySwap.tokenIdToTreatName(curTokenId);
assertEq(pendingNFT, "WereGummy");
uint256 pendingCost = spookySwap.pendingNFTsAmountPaid(curTokenId);
assertEq(pendingCost, 1 ether);
vm.startPrank(user);
spookySwap.trickOrTreat{value: 0.5 ether}(curTreat.name);
vm.stopPrank();
}

Tools Used

  • Manual Review

Recommendations

To remedy this issue, a mapping could be added, which saves how much the user has left to pay:

mapping(address => bool) public userPaymentPending; //buyer address => if user has an NFT to pay remainder for

And then modify the trickOrTreat function as follows:

function trickOrTreat(string memory _treatName) public payable nonReentrant {
Treat memory treat = treatList[_treatName];
require(treat.cost > 0, "Treat cost not set.");
+ require(userPaymentPending[msg.sender] == false, "Must pay for pending NFT before minting again");
Updates

Appeal created

bube Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.