mapping(uint256 => Listing) public s_listings;
modifier onlySeller(uint256 _listingId) {
require(s_listings[_listingId].seller == msg.sender, "Only seller can call this function");
_;
}
function list(uint256 _tokenId, uint32 _price) external onlyWhitelisted {
...
s_listings[_tokenId] =
Listing({seller: msg.sender, price: _price, nft: address(this), tokenId: _tokenId, isActive: true});
...
}
function buy(uint256 _listingId) external payable {
Listing memory listing = s_listings[_listingId];
...
_safeTransfer(listing.seller, msg.sender, listing.tokenId, "");
s_listings[_listingId].isActive = false;
...
}
function collectUsdcFromSelling(uint256 _listingId) external onlySeller(_listingId) {
Listing memory listing = s_listings[_listingId];
...
}
function testPermanentLossOfSellerFundsDueToOverwrittenListings() public revealed whitelisted {
uint256 tokenId = 1;
uint32 firstSalePrice = 1000e6;
uint32 relistPrice = 1500e6;
uint256 feeOnFirstSale = nftDealers.calculateFees(firstSalePrice);
uint256 originalSellerExpectedPayout = uint256(firstSalePrice) + nftDealers.lockAmount() - feeOnFirstSale;
mintAndListNFTForTesting(tokenId, firstSalePrice);
console2.log("Original seller:", userWithCash);
console2.log("Initial buyer / future relister:", userWithEvenMoreCash);
console2.log("Original sale price:", uint256(firstSalePrice));
console2.log("Expected original seller payout:", originalSellerExpectedPayout);
vm.prank(owner);
nftDealers.whitelistWallet(userWithEvenMoreCash);
vm.startPrank(userWithEvenMoreCash);
usdc.approve(address(nftDealers), firstSalePrice);
nftDealers.buy(tokenId);
vm.stopPrank();
(
address sellerAfterFirstSale,
uint32 priceAfterFirstSale,,
uint256 storedTokenIdAfterFirstSale,
bool isActiveAfterFirstSale
) = nftDealers.s_listings(tokenId);
console2.log("Owner after first sale:", nftDealers.ownerOf(tokenId));
console2.log("Stored seller after first sale:", sellerAfterFirstSale);
console2.log("Stored price after first sale:", uint256(priceAfterFirstSale));
console2.log("Stored tokenId after first sale:", storedTokenIdAfterFirstSale);
console2.log("Stored active flag after first sale:", isActiveAfterFirstSale ? uint256(1) : uint256(0));
console2.log("Contract USDC balance after first sale:", usdc.balanceOf(address(nftDealers)));
console2.log("Original seller USDC balance after first sale:", usdc.balanceOf(userWithCash));
assertEq(nftDealers.ownerOf(tokenId), userWithEvenMoreCash, "sanity: buyer now owns token");
assertEq(
sellerAfterFirstSale, userWithCash, "sanity: old listing still points to original seller before overwrite"
);
assertEq(priceAfterFirstSale, firstSalePrice, "sanity: old sale price still present before overwrite");
assertFalse(isActiveAfterFirstSale, "sanity: original listing becomes inactive after buy");
assertEq(usdc.balanceOf(userWithCash), 0, "sanity: original seller has not collected yet");
vm.prank(userWithEvenMoreCash);
nftDealers.list(tokenId, relistPrice);
(
address sellerAfterOverwrite,
uint32 priceAfterOverwrite,,
uint256 storedTokenIdAfterOverwrite,
bool isActiveAfterOverwrite
) = nftDealers.s_listings(tokenId);
console2.log("Owner after relist:", nftDealers.ownerOf(tokenId));
console2.log("Stored seller after overwrite:", sellerAfterOverwrite);
console2.log("Stored price after overwrite:", uint256(priceAfterOverwrite));
console2.log("Stored tokenId after overwrite:", storedTokenIdAfterOverwrite);
console2.log("Stored active flag after overwrite:", isActiveAfterOverwrite ? uint256(1) : uint256(0));
assertEq(sellerAfterOverwrite, userWithEvenMoreCash, "BUG: original seller address was overwritten");
assertEq(priceAfterOverwrite, relistPrice, "BUG: original sale price was overwritten");
assertEq(storedTokenIdAfterOverwrite, tokenId, "storage slot is still keyed only by tokenId");
assertTrue(isActiveAfterOverwrite, "new owner's listing is now the only record at this key");
vm.startPrank(userWithCash);
vm.expectRevert("Only seller can call this function");
nftDealers.collectUsdcFromSelling(tokenId);
vm.stopPrank();
uint256 originalSellerBalanceAfterFailure = usdc.balanceOf(userWithCash);
uint256 contractBalanceAfterFailure = usdc.balanceOf(address(nftDealers));
console2.log("Original seller balance after failed collect:", originalSellerBalanceAfterFailure);
console2.log("Contract balance after failed collect:", contractBalanceAfterFailure);
console2.log("Expected payout now locked away from original seller:", originalSellerExpectedPayout);
assertEq(
originalSellerBalanceAfterFailure,
0,
"BUG: original seller still cannot access proceeds from their completed sale"
);
assertEq(
contractBalanceAfterFailure,
nftDealers.lockAmount() + uint256(firstSalePrice),
"contract still holds the original sale funds while the rightful seller is locked out"
);
}
[⠊] Compiling...
No files changed, compilation skipped
Ran 1 test for test/NFTDealersTest.t.sol:NFTDealersTest
[PASS] testPermanentLossOfSellerFundsDueToOverwrittenListings() (gas: 538290)
Logs:
Original seller: 0x22CdC71E987473D657FCe79C9C0C0B1A62148056
Initial buyer / future relister: 0x533575789af8F38A73C7747E36C17C1835FDF44a
Original sale price: 1000000000
Expected original seller payout: 1010000000
Owner after first sale: 0x533575789af8F38A73C7747E36C17C1835FDF44a
Stored seller after first sale: 0x22CdC71E987473D657FCe79C9C0C0B1A62148056
Stored price after first sale: 1000000000
Stored tokenId after first sale: 1
Stored active flag after first sale: 0
Contract USDC balance after first sale: 1020000000
Original seller USDC balance after first sale: 0
Owner after relist: 0x533575789af8F38A73C7747E36C17C1835FDF44a
Stored seller after overwrite: 0x533575789af8F38A73C7747E36C17C1835FDF44a
Stored price after overwrite: 1500000000
Stored tokenId after overwrite: 1
Stored active flag after overwrite: 1
Original seller balance after failed collect: 0
Contract balance after failed collect: 1020000000
Expected payout now locked away from original seller: 1010000000
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 4.39ms (2.09ms CPU time)
Ran 1 test suite in 16.55ms (4.39ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
- mapping(uint256 => Listing) public s_listings;
+ mapping(uint256 => Listing) public s_listings;
+ mapping(uint256 => uint256) public activeListingIdByTokenId;
struct Listing {
address seller;
uint32 price;
address nft;
uint256 tokenId;
bool isActive;
+ bool wasSold;
+ bool proceedsClaimed;
}
function list(uint256 _tokenId, uint32 _price) external onlyWhitelisted {
require(_price >= MIN_PRICE, "Price must be at least 1 USDC");
require(ownerOf(_tokenId) == msg.sender, "Not owner of NFT");
- require(s_listings[_tokenId].isActive == false, "NFT is already listed");
+ uint256 currentListingId = activeListingIdByTokenId[_tokenId];
+ require(!s_listings[currentListingId].isActive, "NFT is already listed");
require(_price > 0, "Price must be greater than 0");
listingsCounter++;
activeListingsCounter++;
- s_listings[_tokenId] =
+ s_listings[listingsCounter] =
Listing({seller: msg.sender, price: _price, nft: address(this), tokenId: _tokenId, isActive: true});
+ activeListingIdByTokenId[_tokenId] = listingsCounter;
emit NFT_Dealers_Listed(msg.sender, listingsCounter);
}
function buy(uint256 _listingId) external payable {
Listing memory listing = s_listings[_listingId];
if (!listing.isActive) revert ListingNotActive(_listingId);
require(listing.seller != msg.sender, "Seller cannot buy their own NFT");
activeListingsCounter--;
bool success = usdc.transferFrom(msg.sender, address(this), listing.price);
require(success, "USDC transfer failed");
_safeTransfer(listing.seller, msg.sender, listing.tokenId, "");
s_listings[_listingId].isActive = false;
+ s_listings[_listingId].wasSold = true;
+ activeListingIdByTokenId[listing.tokenId] = 0;
emit NFT_Dealers_Sold(msg.sender, listing.price);
}
function collectUsdcFromSelling(uint256 _listingId) external onlySeller(_listingId) {
Listing memory listing = s_listings[_listingId];
require(!listing.isActive, "Listing must be inactive to collect USDC");
+ require(listing.wasSold, "Listing was not sold");
+ require(!listing.proceedsClaimed, "Proceeds already claimed");
+ s_listings[_listingId].proceedsClaimed = true;
...
}