Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions contracts/NFTStorefrontV2.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -896,9 +896,20 @@ access(all) contract NFTStorefrontV2 {
message: "NFTStorefrontV2.Storefront.cleanupGhostListings: Cannot cleanup listing with id \(listingResourceID) because it is not a ghost listing!"
)
let listing <- self.listings.remove(key: listingResourceID)!
// Fetch duplicates before removing the primary from listedNFTs.
// getDuplicateListingIDs uses `contains(listingResourceID)` as a guard — if the primary
// is already absent from listedNFTs it returns [] and duplicates are never cleaned up.
let duplicateListings = self.getDuplicateListingIDs(nftType: details.nftType, nftID: details.nftID, listingID: listingResourceID)

// Let's force removal of the listing in this storefront for the NFT that is being ghosted.
// Now remove the ghost listing's own entry from listedNFTs.
// Every other removal path (removeListing, cleanupPurchasedListings, cleanup) does this
// for the primary listing; cleanupGhostListings was the only path that omitted it,
// leaving a dangling listedNFTs entry after the duplicate-cleanup loop.
self.removeDuplicateListing(
nftIdentifier: details.nftType.identifier,
nftID: details.nftID,
listingResourceID: listingResourceID
)
// Let's force removal of the listing in this storefront for the NFT that is being ghosted.
for listingID in duplicateListings {
self.cleanup(listingResourceID: listingID)
}
Expand Down
6 changes: 3 additions & 3 deletions lib/go/contracts/internal/assets/assets.go

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions scripts/get_existing_listing_ids.cdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import "NFTStorefrontV2"

/// Returns all listing resource IDs tracked in `listedNFTs` for the given NFT type and ID.
/// This reads directly from the `listedNFTs` index (via `getExistingListingIDs`), which is
/// distinct from `getListingIDs()` — a ghost entry left behind by the Bug 1 fix would show
/// up here even after the listing has been removed from `self.listings`.
///
/// @param storefrontAddress Address of the account holding the storefront resource.
/// @param nftTypeIdentifier Fully-qualified type identifier of the NFT (e.g. "A.00…ExampleNFT.NFT").
/// @param nftID Resource ID of the NFT.
access(all) fun main(storefrontAddress: Address, nftTypeIdentifier: String, nftID: UInt64): [UInt64] {
let nftType = CompositeType(nftTypeIdentifier)
?? panic("Could not construct type from identifier: ".concat(nftTypeIdentifier))

return getAccount(storefrontAddress).capabilities.borrow<&{NFTStorefrontV2.StorefrontPublic}>(
NFTStorefrontV2.StorefrontPublicPath
)?.getExistingListingIDs(nftType: nftType, nftID: nftID)
?? panic("Could not borrow public storefront from address")
}
83 changes: 81 additions & 2 deletions tests/NFTStorefrontV2_test.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,85 @@ fun testCleanupPurchasedListings() {
Test.assertEqual((result.returnValue! as! [UInt64]).length, 0)
}

access(all)
fun testCleanupGhostListingsRemovesListedNFTsEntry() {
// Regression test for Bug 1: cleanupGhostListings must remove the primary ghost
// listing's entry from listedNFTs, not just from self.listings. Without the fix,
// a dangling entry remains in listedNFTs after cleanup, which getExistingListingIDs
// would continue to return even though the listing no longer exists.

mintNFTToSeller()

var code = loadCode("get_ids.cdc", "scripts/example-nft")
var result = Test.executeScript(code, [seller.address, /public/exampleNFTCollection])
Test.expect(result, Test.beSucceeded())
let nftID = (result.returnValue! as! [UInt64])[0]

// Create two listings for the same NFT (duplicate listings)
let sellCode = loadCode("sell_item.cdc", "transactions")
for _ in [1, 2] {
let tx = Test.Transaction(
code: sellCode,
authorizers: [seller.address],
signers: [seller],
arguments: [
nftID,
10.0,
"Custom",
0.1,
UInt64(2025908543),
[],
nftTypeIdentifier,
ftTypeIdentifier
],
)
let txResult = Test.executeTransaction(tx)
Test.expect(txResult, Test.beSucceeded())
}

let getListingIDCode = loadCode("read_storefront_ids.cdc", "scripts")
result = Test.executeScript(getListingIDCode, [seller.address])
Test.expect(result, Test.beSucceeded())
Test.assertEqual(2, (result.returnValue! as! [UInt64]).length)
let primaryListingID = (result.returnValue! as! [UInt64])[0]

// Both listings should appear in listedNFTs
var existingIDs = scriptExecutor("get_existing_listing_ids.cdc", [seller.address, nftTypeIdentifier, nftID])
Test.assertEqual(2, (existingIDs as! [UInt64]?)!.length)

// Burn the NFT — both listings become ghost listings
let burnNFTCode = loadCode("burn_nft.cdc", "transactions/example-nft")
let burnTx = Test.Transaction(
code: burnNFTCode,
authorizers: [seller.address],
signers: [seller],
arguments: [nftID]
)
let burnResult = Test.executeTransaction(burnTx)
Test.expect(burnResult, Test.beSucceeded())

// Clean up the primary ghost listing (the duplicate is cleaned up automatically)
let cleanupCode = loadCode("cleanup_ghost_listing.cdc", "transactions")
let cleanupTx = Test.Transaction(
code: cleanupCode,
authorizers: [buyer.address],
signers: [buyer],
arguments: [primaryListingID, seller.address]
)
let cleanupResult = Test.executeTransaction(cleanupTx)
Test.expect(cleanupResult, Test.beSucceeded())

// Both listings must be gone from self.listings
result = Test.executeScript(getListingIDCode, [seller.address])
Test.expect(result, Test.beSucceeded())
Test.assertEqual(0, (result.returnValue! as! [UInt64]).length)

// The listedNFTs entry must also be fully cleared — this is the bug under test.
// Before the fix, getExistingListingIDs would still return [primaryListingID] here.
existingIDs = scriptExecutor("get_existing_listing_ids.cdc", [seller.address, nftTypeIdentifier, nftID])
Test.assertEqual(0, (existingIDs as! [UInt64]?)!.length)
}

access(all)
fun testCleanupGhostListings() {
// Mint a new NFT
Expand Down Expand Up @@ -588,9 +667,9 @@ fun testRemoveItem() {
// Test that the proper events were emitted
var typ = Type<NFTStorefrontV2.ListingCompleted>()
var events = Test.eventsOfType(typ)
Test.assertEqual(6, events.length)
Test.assertEqual(8, events.length)

let completedEvent = events[5] as! NFTStorefrontV2.ListingCompleted
let completedEvent = events[7] as! NFTStorefrontV2.ListingCompleted
Test.assertEqual(listingID, completedEvent.listingResourceID)
Test.assertEqual(false, completedEvent.purchased)
Test.assertEqual(Type<@ExampleNFT.NFT>(), completedEvent.nftType)
Expand Down
Loading