Realm detail
nftmarket2
gno.land/r/g1hx4z2kwrnzd9up3g0gd4hspc6v78e4r90jkke3/nftmarket2
Indexed deployment identity with independently loaded latest RPC source, functions, and Render.
Indexed deployment
Identity
- Package path
- gno.land/r/g1hx4z2kwrnzd9up3g0gd4hspc6v78e4r90jkke3/nftmarket2
- Block
- 312554
- Deployed (UTC)
- Transaction
- ff8uTkOZZO/UvFh+nkoNsPbYcDXELZisjavJHvna99w=
Latest RPC state
Source
// Package nftmarket2 is the v2 non-custodial GNOT marketplace for
// collections minted through gno.land/r/g1hx4z2.../nft2.
//
// Non-custodial: listing a token never moves it into this realm. The
// seller approves this realm's address as an operator on nft2, and List
// only records the intent to sell. At Buy time this realm cross-calls
// nft2.TransferFrom on the seller's behalf, which only succeeds while the
// approval still stands — so a seller can invalidate every open listing at
// once by transferring the token or revoking the approval.
//
// WHAT CHANGED VS V1 (gno.land/r/g1hx4z2.../nftmarket)
//
// 1. Authorization uses cur.Previous().Address() instead of
// unsafe.OriginCaller(). See nft2's package doc for the full
// reasoning; in short, OriginCaller is gno's tx.origin and lets any
// realm the user calls act as that user.
//
// 2. Payment verification is hardened. v1 read unsafe.OriginSend() with
// no guard on how it was reached. The tx's --send envelope is
// credited to whichever realm the tx calls *directly*, but
// OriginSend() reports it to every realm in the call chain — so a
// malicious realm could take the user's coins itself, then cross-call
// Buy, which would see a funded envelope it never received and pay
// the seller out of the marketplace's own balance. gno.land's stdlib
// prescribes exactly one remedy for this ("pair with
// runtime.AssertOriginCall() AND ...IsUserCall()"), which
// assertDirectPayment below implements.
//
// 3. A listing is deactivated BEFORE any coins move. v1 flipped
// active=false after the payouts and the cross-realm transfer, which
// left a window where re-entrant code could observe a still-active,
// already-paid listing.
//
// 4. Real pagination and indexes. v1's ListActive paged over ALL
// listings and filtered afterwards, so a page could come back empty
// while active listings existed further along. v2 keeps a dedicated
// active-listing tree plus seller and collection indexes, so the
// frontend no longer has to fetch everything and filter client-side.
//
// 5. UpdatePrice, so re-pricing no longer needs cancel + re-list (two
// transactions, two gas fees, and a gap where someone else can list).
package nftmarket2
import (
"chain"
"chain/banker"
"chain/runtime"
"chain/runtime/unsafe"
"strconv"
"gno.land/p/nt/avl/v0"
"gno.land/r/g1hx4z2kwrnzd9up3g0gd4hspc6v78e4r90jkke3/nft2"
)
// Listing is one active or historical sale offer.
type Listing struct {
id string
collectionID string
tokenID string
seller address
priceUgnot int64
active bool
createdAt int64
}
// ListingInfo is the exported, read-only view of a Listing.
type ListingInfo struct {
ID string
CollectionID string
TokenID string
Seller address
PriceUgnot int64
Active bool
CreatedAt int64
}
var (
listings = avl.NewTree() // listingID -> *Listing
activeIndex = avl.NewTree() // paddedListingID -> listingID (active only)
byToken = avl.NewTree() // "collectionId:tokenId" -> listingID (one active listing per token)
bySeller = avl.NewTree() // "seller:paddedListingID" -> listingID (active only)
byCollection = avl.NewTree() // "collectionId:paddedListingID" -> listingID (active only)
nextID int64
activeCount int64
)
// assertDirectPayment rejects any call path where the tx's coin envelope
// cannot be trusted to have actually landed in this realm. Both checks are
// required: AssertOriginCall() rejects MsgRun and realm-to-realm calls,
// and IsUserCall() additionally rejects the ephemeral /e/<addr>/run realm,
// which can pre-consume the envelope before this frame reads it.
func assertDirectPayment(cur realm) {
runtime.AssertOriginCall()
if !cur.Previous().IsUserCall() {
panic("this function must be called directly by a wallet, not through another realm")
}
}
// List offers a token for sale at priceUgnot (1 GNOT = 1_000_000 ugnot).
// The caller must own the token and must have approved this realm's
// address on nft2 beforehand.
func List(cur realm, collectionID, tokenID string, priceUgnot int64) string {
caller := cur.Previous().Address()
if priceUgnot <= 0 {
panic("price must be positive")
}
owner, err := nft2.OwnerOf(collectionID, tokenID)
if err != nil {
panic(err)
}
if owner != caller {
panic("only the token owner can list it")
}
assertApproved(cur, collectionID, tokenID, owner)
key := collectionID + ":" + tokenID
if listingID, ok := byToken.Get(key).(string); ok {
if l, ok2 := listings.Get(listingID).(*Listing); ok2 && l.active {
panic("token is already listed (listing #" + listingID + ")")
}
}
nextID++
id := strconv.FormatInt(nextID, 10)
l := &Listing{
id: id,
collectionID: collectionID,
tokenID: tokenID,
seller: caller,
priceUgnot: priceUgnot,
active: true,
createdAt: runtime.ChainHeight(),
}
listings.Set(id, l)
byToken.Set(key, id)
indexActive(l)
chain.Emit(
"NFTListed",
"listingId", id,
"collection", collectionID,
"tokenId", tokenID,
"seller", caller.String(),
"price", strconv.FormatInt(priceUgnot, 10),
)
return id
}
// UpdatePrice re-prices an active listing in place. Seller only.
func UpdatePrice(cur realm, listingID string, priceUgnot int64) {
caller := cur.Previous().Address()
l := mustGetListing(listingID)
if !l.active {
panic("listing is not active")
}
if l.seller != caller {
panic("only the seller can change this listing's price")
}
if priceUgnot <= 0 {
panic("price must be positive")
}
old := l.priceUgnot
l.priceUgnot = priceUgnot
chain.Emit(
"NFTListingRepriced",
"listingId", listingID,
"oldPrice", strconv.FormatInt(old, 10),
"price", strconv.FormatInt(priceUgnot, 10),
)
}
// Cancel withdraws an active listing. Seller only.
func Cancel(cur realm, listingID string) {
caller := cur.Previous().Address()
l := mustGetListing(listingID)
if !l.active {
panic("listing is not active")
}
if l.seller != caller {
panic("only the seller can cancel this listing")
}
deactivate(l)
chain.Emit("NFTListingCancelled", "listingId", listingID)
}
// Buy purchases an active listing. The transaction must send at least the
// listing's priceUgnot; any excess is refunded. Royalties declared on the
// token are paid to the collection creator before the seller is paid.
func Buy(cur realm, listingID string) {
assertDirectPayment(cur)
caller := cur.Previous().Address()
l := mustGetListing(listingID)
if !l.active {
panic("listing is not active")
}
if l.seller == caller {
panic("cannot buy your own listing")
}
sentAmt := unsafe.OriginSend().AmountOf("ugnot")
if sentAmt < l.priceUgnot {
panic("insufficient payment: sent " + strconv.FormatInt(sentAmt, 10) +
"ugnot, need " + strconv.FormatInt(l.priceUgnot, 10) + "ugnot")
}
// Deactivate first, then move money (see package doc, change #3).
deactivate(l)
royaltyAddr, rawRoyalty, err := nft2.RoyaltyInfo(l.collectionID, l.tokenID, l.priceUgnot)
if err != nil {
rawRoyalty = 0
}
royaltyAmt, sellerAmt, refund := splitPayment(l.priceUgnot, sentAmt, rawRoyalty, royaltyAddr == l.seller)
marketAddr := cur.Address()
bk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
if royaltyAmt > 0 {
bk.SendCoins(marketAddr, royaltyAddr, chain.NewCoins(chain.NewCoin("ugnot", royaltyAmt)))
}
bk.SendCoins(marketAddr, l.seller, chain.NewCoins(chain.NewCoin("ugnot", sellerAmt)))
if refund > 0 {
bk.SendCoins(marketAddr, caller, chain.NewCoins(chain.NewCoin("ugnot", refund)))
}
// Cross into nft2 to move ownership. This reverts the whole tx,
// including the transfers above, if the seller's approval no longer
// stands or they no longer own the token.
nft2.TransferFrom(cross(cur), l.collectionID, l.tokenID, l.seller, caller)
chain.Emit(
"NFTSold",
"listingId", listingID,
"collection", l.collectionID,
"tokenId", l.tokenID,
"seller", l.seller.String(),
"buyer", caller.String(),
"price", strconv.FormatInt(l.priceUgnot, 10),
"royalty", strconv.FormatInt(royaltyAmt, 10),
)
}
// splitPayment is the pure payout arithmetic, kept separate so it can be
// unit-tested without a funded chain: a royalty is only honoured when it
// is positive, strictly smaller than the price, and not payable to the
// seller themselves (in which case paying it would just be a needless
// extra transfer of the seller's own money).
func splitPayment(price, sent, royalty int64, royaltyGoesToSeller bool) (royaltyAmt, sellerAmt, refund int64) {
if royalty <= 0 || royalty >= price || royaltyGoesToSeller {
royalty = 0
}
refund = sent - price
if refund < 0 {
refund = 0
}
return royalty, price - royalty, refund
}
// Read-only views.
// GetListing returns one listing by ID.
func GetListing(listingID string) ListingInfo {
return toInfo(mustGetListing(listingID))
}
// ListActive returns exactly one page of ACTIVE listings, oldest first.
// Unlike v1, offset/limit apply to the active set only.
func ListActive(offset, limit int64) []ListingInfo {
return page(activeIndex, "", "", offset, limit)
}
// ListBySeller returns a page of one seller's active listings.
func ListBySeller(seller address, offset, limit int64) []ListingInfo {
return page(bySeller, seller.String()+":", seller.String()+";", offset, limit)
}
// ListByCollection returns a page of one collection's active listings.
func ListByCollection(collectionID string, offset, limit int64) []ListingInfo {
return page(byCollection, collectionID+":", collectionID+";", offset, limit)
}
// ListingForToken returns the active listing on a token, or an empty
// ListingInfo if there is none — one lookup instead of scanning pages.
func ListingForToken(collectionID, tokenID string) ListingInfo {
if id, ok := byToken.Get(collectionID + ":" + tokenID).(string); ok {
if l, ok2 := listings.Get(id).(*Listing); ok2 && l.active {
return toInfo(l)
}
}
return ListingInfo{}
}
// ListingCount returns the total number of listings ever created.
func ListingCount() int64 { return nextID }
// ActiveListingCount returns how many listings are currently active, so a
// UI can page without over-fetching.
func ActiveListingCount() int64 { return activeCount }
// FloorPrice returns the lowest active asking price across a collection
// (0 when it has no active listings). Computed on-chain so the frontend
// stops deriving a "floor" from whatever subset it happened to load.
func FloorPrice(collectionID string) int64 {
var floor int64
byCollection.Iterate(collectionID+":", collectionID+";", func(key string, value any) bool {
if l, ok := listings.Get(value.(string)).(*Listing); ok && l.active {
if floor == 0 || l.priceUgnot < floor {
floor = l.priceUgnot
}
}
return false
})
return floor
}
// Render implements the gno.land realm home-page convention.
func Render(path string) string {
if path != "" {
l, ok := listings.Get(path).(*Listing)
if !ok {
return "# 404\n\nlisting not found: " + path
}
return renderListing(l)
}
out := "# gnoNFT Marketplace v2\n\n"
if activeCount == 0 {
return out + "No active listings.\n"
}
out += strconv.FormatInt(activeCount, 10) + " active listing(s)\n\n"
activeIndex.Iterate("", "", func(key string, value any) bool {
if l, ok := listings.Get(value.(string)).(*Listing); ok {
out += renderListing(l) + "\n"
}
return false
})
return out
}
func renderListing(l *Listing) string {
status := "active"
if !l.active {
status = "sold/cancelled"
}
return "- #" + l.id + " " + l.collectionID + ":" + l.tokenID +
" - " + strconv.FormatInt(l.priceUgnot, 10) + "ugnot - seller " + l.seller.String() +
" (" + status + ")"
}
// Internal helpers.
func assertApproved(cur realm, collectionID, tokenID string, owner address) {
marketAddr := cur.Address()
approved, _ := nft2.GetApproved(collectionID, tokenID)
if approved != marketAddr && !nft2.IsApprovedForAll(collectionID, owner, marketAddr) {
panic("marketplace is not approved to transfer this token yet; call nft2.Approve or nft2.SetApprovalForAll first")
}
}
func indexActive(l *Listing) {
p := padID(l.id)
activeIndex.Set(p, l.id)
bySeller.Set(l.seller.String()+":"+p, l.id)
byCollection.Set(l.collectionID+":"+p, l.id)
activeCount++
}
func deactivate(l *Listing) {
l.active = false
p := padID(l.id)
activeIndex.Remove(p)
bySeller.Remove(l.seller.String() + ":" + p)
byCollection.Remove(l.collectionID + ":" + p)
byToken.Remove(l.collectionID + ":" + l.tokenID)
if activeCount > 0 {
activeCount--
}
}
func page(tree *avl.Tree, start, end string, offset, limit int64) []ListingInfo {
out := make([]ListingInfo, 0, limit)
var seen int64
tree.Iterate(start, end, func(key string, value any) bool {
if seen < offset {
seen++
return false
}
if limit > 0 && int64(len(out)) >= limit {
return true
}
if l, ok := listings.Get(value.(string)).(*Listing); ok && l.active {
out = append(out, toInfo(l))
}
seen++
return false
})
return out
}
// padID zero-pads a numeric ID so index trees sort numerically rather
// than lexicographically ("10" must come after "2").
func padID(id string) string {
const width = 12
if len(id) >= width {
return id
}
return "000000000000"[:width-len(id)] + id
}
func toInfo(l *Listing) ListingInfo {
return ListingInfo{
ID: l.id,
CollectionID: l.collectionID,
TokenID: l.tokenID,
Seller: l.seller,
PriceUgnot: l.priceUgnot,
Active: l.active,
CreatedAt: l.createdAt,
}
}
func mustGetListing(id string) *Listing {
v := listings.Get(id)
if v == nil {
panic("listing not found: " + id)
}
return v.(*Listing)
}
Latest RPC state
Exported functions
- List(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, collectionID string, tokenID string, priceUgnot int64) string
- UpdatePrice(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, listingID string, priceUgnot int64)
- Cancel(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, listingID string)
- Buy(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, listingID string)
- GetListing(listingID string) struct{ID string; CollectionID string; TokenID string; Seller .uverse.address; PriceUgnot int64; Active bool; CreatedAt int64}
- ListActive(offset int64, limit int64) []gno.land/r/g1hx4z2kwrnzd9up3g0gd4hspc6v78e4r90jkke3/nftmarket2.ListingInfo
- ListBySeller(seller string, offset int64, limit int64) []gno.land/r/g1hx4z2kwrnzd9up3g0gd4hspc6v78e4r90jkke3/nftmarket2.ListingInfo
- ListByCollection(collectionID string, offset int64, limit int64) []gno.land/r/g1hx4z2kwrnzd9up3g0gd4hspc6v78e4r90jkke3/nftmarket2.ListingInfo
- ListingForToken(collectionID string, tokenID string) struct{ID string; CollectionID string; TokenID string; Seller .uverse.address; PriceUgnot int64; Active bool; CreatedAt int64}
- ListingCount() int64
- ActiveListingCount() int64
- FloorPrice(collectionID string) int64
Latest RPC state · Realm Render
gnoNFT Marketplace v2
1 active listing(s)
- #1 1:1 - 5000000ugnot - seller g1hx4z2kwrnzd9up3g0gd4hspc6v78e4r90jkke3 (active)