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
31 changes: 30 additions & 1 deletion contracts/NFTStorefrontV2.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -252,8 +252,22 @@ access(all) contract NFTStorefrontV2 {
/// hasListingBecomeGhosted
/// Tells whether listed NFT is present in provided capability.
/// If it returns `false` then it means listing becomes ghost or sold out.
///
/// DEPRECATED: The return value of this function is semantically inverted — it returns `true`
/// when the NFT is still present (i.e. NOT ghosted) and `false` when the NFT is absent
/// (i.e. IS ghosted). This is the opposite of what the function name implies. The function
/// is kept as-is to avoid breaking existing integrations that already compensate for the
/// inversion. Use `isGhostListing()` instead, which returns `true` when the listing is
/// ghosted and `false` when it is still valid.
access(all) view fun hasListingBecomeGhosted(): Bool

/// isGhostListing
/// Returns `true` if the listed NFT is no longer present in the seller's collection
/// (i.e. the listing is ghosted and cannot be purchased), and `false` if the NFT is
/// still available. This is the correctly-named replacement for `hasListingBecomeGhosted()`,
/// which has inverted return semantics.
access(all) view fun isGhostListing(): Bool

}


Expand Down Expand Up @@ -345,13 +359,28 @@ access(all) contract NFTStorefrontV2 {
/// hasListingBecomeGhosted
/// Tells whether listed NFT is present in provided capability.
/// If it returns `false` then it means listing becomes ghost or sold out.
///
/// DEPRECATED: The return value is semantically inverted relative to the function name.
/// This function returns `true` when the NFT is still present (not ghosted) and `false`
/// when the NFT is absent (ghosted). Use `isGhostListing()` instead.
access(all) view fun hasListingBecomeGhosted(): Bool {
if let providerRef = self.nftProviderCapability.borrow() {
return providerRef.borrowNFT(self.details.nftID) != nil
}
return false
}

/// isGhostListing
/// Returns `true` if the listed NFT is no longer present in the seller's collection
/// (i.e. the listing is ghosted and cannot be purchased), and `false` if the NFT is
/// still available. This is the correctly-named replacement for `hasListingBecomeGhosted()`.
access(all) view fun isGhostListing(): Bool {
if let providerRef = self.nftProviderCapability.borrow() {
return providerRef.borrowNFT(self.details.nftID) == nil
}
return true
}

/// purchase
/// Purchase the listing, buying the token.
/// This pays the beneficiaries and commission to the facilitator and returns extra token to the buyer.
Expand Down Expand Up @@ -863,7 +892,7 @@ access(all) contract NFTStorefrontV2 {
message: "NFTStorefrontV2.Storefront.cleanupGhostListings: Cannot cleanup listing with id \(listingResourceID) because it is already purchased!"
)
assert(
!listingRef.hasListingBecomeGhosted(),
listingRef.isGhostListing(),
message: "NFTStorefrontV2.Storefront.cleanupGhostListings: Cannot cleanup listing with id \(listingResourceID) because it is not a ghost listing!"
)
let listing <- self.listings.remove(key: listingResourceID)!
Expand Down
4 changes: 4 additions & 0 deletions contracts/utility/test/MaliciousStorefrontV2.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@ access(all) contract MaliciousStorefrontV2 {
return self.storefrontCap.borrow()!.borrowListing(listingResourceID: self.listingResourceID)!.hasListingBecomeGhosted()
}

access(all) view fun isGhostListing(): Bool {
return self.storefrontCap.borrow()!.borrowListing(listingResourceID: self.listingResourceID)!.isGhostListing()
}

// purchase will return the "wrong" nft
access(all) fun purchase(
payment: @{FungibleToken.Vault},
Expand Down
26 changes: 22 additions & 4 deletions docs/documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,8 @@ resource interface ListingPublic {
): @{NonFungibleToken.NFT}
access(all) view fun getDetails(): ListingDetails
access(all) fun getAllowedCommissionReceivers(): [Capability<&{FungibleToken.Receiver}>]?
access(all) fun hasListingBecomeGhosted(): Bool
access(all) fun hasListingBecomeGhosted(): Bool // DEPRECATED — see below
access(all) view fun isGhostListing(): Bool
}
```
An interface providing a useful public interface to a Listing.
Expand Down Expand Up @@ -187,13 +188,30 @@ If it returns `nil` then commission paid to the receiver by default.

---

**fun `hasListingBecomeGhosted()`**
**fun `isGhostListing()`**

```cadence
fun isGhostListing(): Bool
```
Returns `true` if the listing is ghosted — i.e. the underlying NFT is no longer present in the
seller's collection and the listing cannot be purchased. Returns `false` if the NFT is still
available. Use this function to check ghost state.

---

**fun `hasListingBecomeGhosted()` _(deprecated)_**

```cadence
fun hasListingBecomeGhosted(): Bool
```
Tells whether a listed NFT that was put up for sale is still available in the provided listing.
If it returns `true` then it means the listing is "ghosted" because there is no available nft to fulfill the listing.
**Deprecated.** The return value of this function is semantically inverted relative to its name:
it returns `true` when the NFT **is still present** (the listing is _not_ ghosted) and `false`
when the NFT **is absent** (the listing _is_ ghosted). This is the opposite of what the function
name implies.

The function is preserved to avoid breaking existing integrations that already compensate for
the inversion (e.g. by calling `!hasListingBecomeGhosted()`). New code should use
`isGhostListing()` instead.

---

Expand Down
12 changes: 6 additions & 6 deletions lib/go/contracts/internal/assets/assets.go

Large diffs are not rendered by default.

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

/// DEPRECATED: This script calls `hasListingBecomeGhosted()`, whose return value is semantically
/// inverted — it returns `true` when the NFT is still present (NOT a ghost listing) and `false`
/// when the NFT is absent (IS a ghost listing). Use `is_ghost_listing.cdc` instead, which
/// calls `isGhostListing()` and returns `true` when the listing is ghosted.
///
/// This script tells whether the provided `listingID` under the provided `storefront` address
/// has a ghost listing.
access(all) fun main(storefrontAddress: Address, listingID: UInt64): Bool {
Expand Down
21 changes: 21 additions & 0 deletions scripts/is_ghost_listing.cdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import "NFTStorefrontV2"

/// Returns `true` if the listing with the given `listingID` under the given `storefrontAddress`
/// is a ghost listing — i.e. the underlying NFT is no longer present in the seller's collection
/// and the listing cannot be purchased. Returns `false` if the NFT is still available.
///
/// This script uses `isGhostListing()`, which has correct semantics. Prefer this over the
/// deprecated `has_listing_become_ghosted.cdc`, whose underlying function returns an inverted value.
///
/// @param storefrontAddress Address of the account holding the storefront resource.
/// @param listingID Resource ID of the listing to check.
access(all) fun main(storefrontAddress: Address, listingID: UInt64): Bool {
let storefrontPublicRef = getAccount(storefrontAddress).capabilities.borrow<&{NFTStorefrontV2.StorefrontPublic}>(
NFTStorefrontV2.StorefrontPublicPath
) ?? panic("Given account does not have a storefront resource")

let listingRef = storefrontPublicRef.borrowListing(listingResourceID: listingID)
?? panic("Provided listingID doesn't exist under the given storefront address")

return listingRef.isGhostListing()
}
5 changes: 5 additions & 0 deletions scripts/read_all_unique_ghost_listings.cdc
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import "NFTStorefrontV2"

/// DEPRECATED: This script works correctly but relies on `hasListingBecomeGhosted()`, which has
/// inverted return semantics (returns `true` when NOT ghosted, `false` when ghosted). The script
/// compensates internally with `!`, but callers should migrate to `read_all_unique_ghost_listings_v2.cdc`,
/// which uses `isGhostListing()` and has clearer semantics.
///
/// This script provides the array of listing resource Id which got ghosted It automatically skips the duplicate listing
/// as duplicate listings would get automatically delete once the primary one.
///
Expand Down
42 changes: 42 additions & 0 deletions scripts/read_all_unique_ghost_listings_v2.cdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import "NFTStorefrontV2"

/// Returns the listing resource IDs of all unique ghost listings under the given storefront.
/// A ghost listing is one where the underlying NFT is no longer present in the seller's collection.
/// Duplicate listings (those sharing the same NFT type and ID as another listing) are excluded,
/// since they are cleaned up automatically when the primary listing is removed.
///
/// This script uses `isGhostListing()`, which has correct semantics. Prefer this over the
/// deprecated `read_all_unique_ghost_listings.cdc`.
///
/// @param storefrontAddress Address of the account holding the storefront resource.
access(all) fun main(storefrontAddress: Address): [UInt64] {

var duplicateListings: [UInt64] = []
var ghostListings: [UInt64] = []

let storefrontPublicRef = getAccount(storefrontAddress).capabilities.borrow<&{NFTStorefrontV2.StorefrontPublic}>(
NFTStorefrontV2.StorefrontPublicPath
) ?? panic("Given account does not have a storefront resource")

let availableListingIds = storefrontPublicRef.getListingIDs()

for id in availableListingIds {
if !duplicateListings.contains(id) {
let listingRef = storefrontPublicRef.borrowListing(listingResourceID: id)!
if listingRef.isGhostListing() {
ghostListings.append(id)
let listingDetails = listingRef.getDetails()
let dupListings = storefrontPublicRef.getDuplicateListingIDs(
nftType: listingDetails.nftType,
nftID: listingDetails.nftID,
listingID: id
)
if dupListings.length > 0 {
duplicateListings.appendAll(dupListings)
}
}
}
}

return ghostListings
}
80 changes: 78 additions & 2 deletions tests/NFTStorefrontV2_test.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,82 @@ fun testCleanupGhostListings() {
Test.assertEqual((result.returnValue! as! [UInt64]).length, 0)
}

access(all)
fun testIsGhostListing() {
// Mint a new NFT
mintNFTToSeller()

// Get the newly minted NFT's ID
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 a listing for the NFT
code = loadCode("sell_item.cdc", "transactions")
var tx = Test.Transaction(
code: code,
authorizers: [seller.address],
signers: [seller],
arguments: [
nftID,
10.0,
"Custom",
0.1,
UInt64(2025908543),
[],
nftTypeIdentifier,
ftTypeIdentifier
],
)
var 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())
let listingID = (result.returnValue! as! [UInt64])[0]

// Before burning: isGhostListing() should return false (NFT is still present)
var isGhost = scriptExecutor("is_ghost_listing.cdc", [seller.address, listingID])
Test.assertEqual(isGhost!, false)

// read_all_unique_ghost_listings_v2 should return an empty array
var allGhostListingIDs = scriptExecutor("read_all_unique_ghost_listings_v2.cdc", [seller.address])
Test.assertEqual((allGhostListingIDs as! [UInt64]?)!.length, 0)

// Burn the NFT to ghost the listing
let burnNFTCode = loadCode("burn_nft.cdc", "transactions/example-nft")
tx = Test.Transaction(
code: burnNFTCode,
authorizers: [seller.address],
signers: [seller],
arguments: [nftID]
)
txResult = Test.executeTransaction(tx)
Test.expect(txResult, Test.beSucceeded())

// After burning: isGhostListing() should return true
isGhost = scriptExecutor("is_ghost_listing.cdc", [seller.address, listingID])
Test.assertEqual(isGhost!, true)

// read_all_unique_ghost_listings_v2 should now return the ghosted listing ID
allGhostListingIDs = scriptExecutor("read_all_unique_ghost_listings_v2.cdc", [seller.address])
Test.assertEqual((allGhostListingIDs as! [UInt64]?)!.length, 1)
Test.assertEqual((allGhostListingIDs as! [UInt64]?)![0], listingID)

// Clean up
let cleanupCode = loadCode("cleanup_ghost_listing.cdc", "transactions")
tx = Test.Transaction(
code: cleanupCode,
authorizers: [buyer.address],
signers: [buyer],
arguments: [listingID, seller.address]
)
txResult = Test.executeTransaction(tx)
Test.expect(txResult, Test.beSucceeded())
}


access(all)
fun testSellItemWithMarketplaceCut() {
Expand Down Expand Up @@ -512,9 +588,9 @@ fun testRemoveItem() {
// Test that the proper events were emitted
var typ = Type<NFTStorefrontV2.ListingCompleted>()
var events = Test.eventsOfType(typ)
Test.assertEqual(5, events.length)
Test.assertEqual(6, events.length)

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