pragma solidity 0.8.34;
import {Test, console2} from "forge-std/Test.sol";
import {NFTDealers} from "../src/NFTDealers.sol";
import {MockUSDC} from "../src/MockUSDC.sol";
* @title PoC_H3_ListingKeyMismatch
* @notice Proof of Concept: H-3 -- list() stores the Listing struct at
* s_listings[_tokenId] but emits listingsCounter as the listingId.
* Every consumer function (buy, cancelListing, collectUsdcFromSelling,
* updatePrice) looks up s_listings[_listingId]. When tokenId !=
* listingsCounter the emitted id routes to the wrong (or empty) slot.
*
* ROOT CAUSE:
* NFTDealers.sol:136 s_listings[_tokenId] = Listing({...})
* NFTDealers.sol:138 emit NFT_Dealers_Listed(msg.sender, listingsCounter)
*
* tokenId and listingsCounter are equal only when every token is listed
* exactly once in mint order. Any deviation -- a cancel + relist, or
* listings created in non-tokenId order -- breaks the mapping.
*
* The existing test suite never detects this because every test mints
* and lists a single token once; tokenId=1 == listingsCounter=1 by
* coincidence, masking the divergence entirely.
*
* THREE TESTS:
*
* testPoC_H3_BuyRevertsWithEmittedId
* mint -> list (emits id=1) -> cancel -> relist (emits id=2).
* buy(2) looks up s_listings[2] which is empty; reverts ListingNotActive.
* The NFT is only purchaseable via internal tokenId=1, which no off-chain
* consumer can derive from protocol events.
*
* testPoC_H3_WrongNFTPurchased
* Two tokens listed in reverse order. Emitted ids are swapped relative
* to the actual tokenIds. Buyer calls buy(emittedId=1) expecting token 2
* but receives token 1 -- a different NFT than agreed upon.
*
* testPoC_H3_SellerCannotCancelRelisting
* After cancel + relist, seller calls cancelListing(emittedId=2).
* s_listings[2] is empty; call reverts. The listing is permanently
* un-cancellable and the seller's lockAmount is permanently locked.
*/
contract PoC_H3_ListingKeyMismatch is Test {
NFTDealers public nftDealers;
MockUSDC public usdc;
string internal constant BASE_IMAGE =
"https://images.unsplash.com/photo-1541781774459-bb2af2f05b55";
address public owner = makeAddr("owner");
address public sellerA = makeAddr("sellerA");
address public sellerB = makeAddr("sellerB");
address public buyer = makeAddr("buyer");
uint256 public constant LOCK_AMOUNT = 20e6;
uint32 public constant LIST_PRICE = 100e6;
function setUp() public {
usdc = new MockUSDC();
nftDealers = new NFTDealers(
owner, address(usdc), "NFTDealers", "NFTD", BASE_IMAGE, LOCK_AMOUNT
);
vm.prank(owner);
nftDealers.revealCollection();
vm.prank(owner);
nftDealers.whitelistWallet(sellerA);
vm.prank(owner);
nftDealers.whitelistWallet(sellerB);
usdc.mint(sellerA, LOCK_AMOUNT * 2);
usdc.mint(sellerB, LOCK_AMOUNT);
usdc.mint(buyer, uint256(LIST_PRICE) * 2);
}
* @notice Sequence: mint -> list (emits id=1) -> cancel -> relist (emits id=2).
* s_listings[2] is empty. buy(2) reverts with ListingNotActive.
* The real listing sits at s_listings[1] (the tokenId), accessible
* only to callers who ignore the emitted id and use the tokenId directly.
*
* The final buy(tokenId=1) succeeds -- proving the NFT is not stuck
* in the contract, only unreachable via the protocol's public interface.
*/
function testPoC_H3_BuyRevertsWithEmittedId() public {
console2.log("=== H-3: BUY REVERTS WITH EMITTED LISTING ID ===");
vm.startPrank(sellerA);
usdc.approve(address(nftDealers), LOCK_AMOUNT);
nftDealers.mintNft();
vm.stopPrank();
uint256 tokenId = nftDealers.totalMinted();
vm.prank(sellerA);
nftDealers.list(tokenId, LIST_PRICE);
uint256 firstEmittedId = nftDealers.totalListings();
console2.log("[1] First listing:");
console2.log(" Emitted listingId :", firstEmittedId);
console2.log(" Storage key (tokenId) :", tokenId);
console2.log(" Match :", firstEmittedId == tokenId);
vm.prank(sellerA);
nftDealers.cancelListing(tokenId);
vm.prank(sellerA);
nftDealers.list(tokenId, LIST_PRICE);
uint256 secondEmittedId = nftDealers.totalListings();
console2.log("[2] Re-listing:");
console2.log(" Emitted listingId :", secondEmittedId);
console2.log(" Storage key (tokenId) :", tokenId);
console2.log(" Keys DIVERGE :", secondEmittedId != tokenId);
(, , , , bool activeAtTokenId) = nftDealers.s_listings(tokenId);
(, , , , bool activeAtEmittedId) = nftDealers.s_listings(secondEmittedId);
console2.log("[3] s_listings[tokenId=1].isActive :", activeAtTokenId);
console2.log(" s_listings[emittedId=2].isActive :", activeAtEmittedId);
vm.startPrank(buyer);
usdc.approve(address(nftDealers), uint256(LIST_PRICE) * 2);
vm.expectRevert(
abi.encodeWithSelector(NFTDealers.ListingNotActive.selector, secondEmittedId)
);
nftDealers.buy(secondEmittedId);
console2.log("[4] buy(emittedId=2) REVERTED -- ListingNotActive");
console2.log(" Purchaseable only via internal tokenId=1");
nftDealers.buy(tokenId);
vm.stopPrank();
console2.log("[5] buy(tokenId=1) succeeded -- internal key bypasses the mismatch");
console2.log(" Off-chain consumers have no way to derive this key from events");
assertTrue(activeAtTokenId,
"Real listing is active at s_listings[tokenId]");
assertFalse(activeAtEmittedId,
"Emitted id slot is empty -- buy(emittedId) always reverts after relist");
assertEq(nftDealers.ownerOf(tokenId), buyer,
"NFT purchaseable only via internal tokenId, not emitted listingId");
}
* @notice sellerA mints tokens 1 and 2, then lists token 2 FIRST (stored at
* s_listings[2], emits id=1) and token 1 SECOND (stored at
* s_listings[1], emits id=2). The emitted ids are transposed.
*
* A buyer calls buy(1) -- the emitted id for token 2's listing.
* buy(1) looks up s_listings[1], which holds token 1's listing,
* and transfers token 1 to the buyer.
*
* The buyer pays LIST_PRICE for token 2 and receives token 1.
*/
function testPoC_H3_WrongNFTPurchased() public {
console2.log("\n=== H-3: WRONG NFT PURCHASED ===");
vm.startPrank(sellerA);
usdc.approve(address(nftDealers), LOCK_AMOUNT * 2);
nftDealers.mintNft();
nftDealers.mintNft();
vm.stopPrank();
uint256 tokenId1 = 1;
uint256 tokenId2 = 2;
vm.prank(sellerA);
nftDealers.list(tokenId2, LIST_PRICE);
uint256 emittedIdForToken2 = nftDealers.totalListings();
vm.prank(sellerA);
nftDealers.list(tokenId1, LIST_PRICE);
uint256 emittedIdForToken1 = nftDealers.totalListings();
console2.log("[1] Token 2 listed: emitted id =", emittedIdForToken2, " storage key =", tokenId2);
console2.log(" Token 1 listed: emitted id =", emittedIdForToken1, " storage key =", tokenId1);
console2.log(" Emitted ids TRANSPOSED relative to tokenIds");
(, , , uint256 tokenAtEmittedSlot1, ) = nftDealers.s_listings(emittedIdForToken2);
(, , , uint256 tokenAtEmittedSlot2, ) = nftDealers.s_listings(emittedIdForToken1);
console2.log("[2] s_listings[emittedId=1].tokenId =", tokenAtEmittedSlot1);
console2.log(" (buyer expects token 2 at this slot, got token", tokenAtEmittedSlot1, ")");
console2.log(" s_listings[emittedId=2].tokenId =", tokenAtEmittedSlot2);
console2.log(" (buyer expects token 1 at this slot, got token", tokenAtEmittedSlot2, ")");
vm.startPrank(buyer);
usdc.approve(address(nftDealers), LIST_PRICE);
nftDealers.buy(emittedIdForToken2);
vm.stopPrank();
console2.log("[3] Buyer called buy(emittedId=1) expecting token 2");
console2.log(" Buyer actually received token 1");
console2.log(" Token 2 still owned by sellerA -- was never transferred");
assertEq(nftDealers.ownerOf(tokenId1), buyer,
"CRITICAL: buyer received token 1 -- wrong NFT delivered");
assertEq(nftDealers.ownerOf(tokenId2), sellerA,
"CRITICAL: intended NFT (token 2) never transferred -- buyer deceived");
console2.log("[4] IMPACT: buyer paid", uint256(LIST_PRICE) / 1e6, "USDC for token 2,");
console2.log(" received token 1. Token 2 listing still active at s_listings[2].");
}
* @notice After mint -> list (emits id=1) -> cancel -> relist (emits id=2),
* the seller calls cancelListing(2) -- the emitted id shown to them
* by any protocol frontend.
*
* s_listings[2] is empty (isActive=false). The call reverts with
* ListingNotActive(2). The seller cannot cancel, cannot update the
* price, and cannot collect proceeds. The NFT is permanently stuck
* in an active-listed state the seller cannot exit via the protocol.
*
* NOTE: The first cancelListing(tokenId=1) in this test also triggers
* H-2 (collateral returned by cancel), so collateralForMinting[1] = 0
* after the relist. The H-3 impact -- seller locked out of the listing
* -- is isolated and proven independently by the expectRevert calls.
*/
function testPoC_H3_SellerCannotCancelRelisting() public {
console2.log("\n=== H-3: SELLER LOCKED OUT OF RE-LISTED NFT ===");
vm.startPrank(sellerA);
usdc.approve(address(nftDealers), LOCK_AMOUNT);
nftDealers.mintNft();
vm.stopPrank();
uint256 tokenId = nftDealers.totalMinted();
vm.prank(sellerA);
nftDealers.list(tokenId, LIST_PRICE);
vm.prank(sellerA);
nftDealers.cancelListing(tokenId);
vm.prank(sellerA);
nftDealers.list(tokenId, LIST_PRICE);
uint256 relistEmittedId = nftDealers.totalListings();
console2.log("[1] Re-listing emitted id :", relistEmittedId);
console2.log(" Real storage key (tokenId) :", tokenId);
console2.log(" Seller must use emitted id (per any protocol frontend)");
vm.prank(sellerA);
vm.expectRevert(
abi.encodeWithSelector(NFTDealers.ListingNotActive.selector, relistEmittedId)
);
nftDealers.cancelListing(relistEmittedId);
console2.log("[2] cancelListing(emittedId=2) REVERTED -- ListingNotActive");
console2.log(" s_listings[2] is empty; cancel impossible via emitted id");
(, , , , bool activeAtTokenId) = nftDealers.s_listings(tokenId);
(, , , , bool activeAtEmittedId) = nftDealers.s_listings(relistEmittedId);
console2.log("[3] s_listings[tokenId=1].isActive :", activeAtTokenId);
console2.log(" s_listings[emittedId=2].isActive :", activeAtEmittedId);
vm.prank(sellerA);
vm.expectRevert("Only seller can call this function");
nftDealers.updatePrice(relistEmittedId, LIST_PRICE * 2);
console2.log("[4] updatePrice(emittedId=2) REVERTED -- seller check fails on empty slot");
uint256 lockedCollateral = nftDealers.collateralForMinting(tokenId);
console2.log("[5] collateralForMinting[1]:", lockedCollateral / 1e6, "USDC (zeroed by H-2 during first cancel)");
console2.log(" NFT owner:", nftDealers.ownerOf(tokenId) == sellerA ? "sellerA (stuck in active listing)" : "other");
console2.log(" s_listings[1].isActive=true but only reachable via tokenId, not emitted id");
assertTrue(activeAtTokenId,
"Listing active at tokenId but inaccessible via emitted id");
assertFalse(activeAtEmittedId,
"Emitted id slot empty -- cancel/update/collect all revert");
assertEq(nftDealers.ownerOf(tokenId), sellerA,
"CRITICAL: seller owns NFT but cannot delist it via the protocol interface");
assertEq(lockedCollateral, 0,
"Collateral zeroed by H-2 during first cancel -- H-3 and H-2 compound");
}
}
forge test --match-contract PoC_H3_ListingKeyMismatch -vv
[⠒] Compiling...
[⠒] Compiling 1 files with Solc 0.8.34
[⠢] Solc 0.8.34 finished in 367.66ms
Compiler run successful!
Ran 3 tests for test/PoC_H3_ListingKeyMismatch.t.sol:PoC_H3_ListingKeyMismatch
[PASS] testPoC_H3_BuyRevertsWithEmittedId() (gas: 348184)
Logs:
=== H-3: BUY REVERTS WITH EMITTED LISTING ID ===
[1] First listing:
Emitted listingId : 1
Storage key (tokenId) : 1
Match : true
[2] Re-listing:
Emitted listingId : 2
Storage key (tokenId) : 1
Keys DIVERGE : true
[3] s_listings[tokenId=1].isActive : true
s_listings[emittedId=2].isActive : false
[4] buy(emittedId=2) REVERTED -- ListingNotActive
Purchaseable only via internal tokenId=1
[5] buy(tokenId=1) succeeded -- internal key bypasses the mismatch
Off-chain consumers have no way to derive this key from events
[PASS] testPoC_H3_SellerCannotCancelRelisting() (gas: 283826)
Logs:
=== H-3: SELLER LOCKED OUT OF RE-LISTED NFT ===
[1] Re-listing emitted id : 2
Real storage key (tokenId) : 1
Seller must use emitted id (per any protocol frontend)
[2] cancelListing(emittedId=2) REVERTED -- ListingNotActive
s_listings[2] is empty; cancel impossible via emitted id
[3] s_listings[tokenId=1].isActive : true
s_listings[emittedId=2].isActive : false
[4] updatePrice(emittedId=2) REVERTED -- seller check fails on empty slot
[5] collateralForMinting[1]: 0 USDC (zeroed by H-2 during first cancel)
NFT owner: sellerA (stuck in active listing)
s_listings[1].isActive=true but only reachable via tokenId, not emitted id
[PASS] testPoC_H3_WrongNFTPurchased() (gas: 460539)
Logs:
=== H-3: WRONG NFT PURCHASED ===
[1] Token 2 listed: emitted id = 1 storage key = 2
Token 1 listed: emitted id = 2 storage key = 1
Emitted ids TRANSPOSED relative to tokenIds
[2] s_listings[emittedId=1].tokenId = 1
(buyer expects token 2 at this slot, got token 1 )
s_listings[emittedId=2].tokenId = 2
(buyer expects token 1 at this slot, got token 2 )
[3] Buyer called buy(emittedId=1) expecting token 2
Buyer actually received token 1
Token 2 still owned by sellerA -- was never transferred
[4] IMPACT: buyer paid 100 USDC for token 2,
received token 1. Token 2 listing still active at s_listings[2].
Suite result: ok. 3 passed; 0 failed; 0 skipped; finished in 5.54ms (4.33ms CPU time)
Ran 1 test suite in 38.59ms (5.54ms CPU time): 3 tests passed, 0 failed, 0 skipped (3 total tests)
Use a single, consistent namespace for both storage writes and emitted identifiers. Option A (preferred — supports relisting): key all listings by listingsCounter and track active listings per token separately: