Search Apps Documentation Source Content File Folder Download Copy Actions Download

nftmarket.gno

7.65 Kb · 236 lines
  1// Package nftmarket is a multi-collection NFT marketplace realm for gno.land,
  2// in the spirit of OpenSea but built for GnoVM.
  3//
  4// It bundles three capabilities in a single realm:
  5//
  6//  1. Collection factory — any user can create their own NFT collection with
  7//     CreateCollection and becomes its creator (royalty + mint-proceeds
  8//     recipient).
  9//  2. Open minting — any user can mint a token from any collection with Mint,
 10//     paying the collection's mint price (which goes to the creator).
 11//  3. Marketplace — a token owner lists an NFT for a fixed price with List,
 12//     and anyone buys it with Buy. The sale price is split between the seller,
 13//     the collection creator (royalty) and the platform (fee).
 14//
 15// Why one realm instead of separate "nft" and "market" realms?
 16// In gno.land's GRC721 design, only the realm that holds the concrete
 17// *grc721.BasicNFT may move its tokens — across a realm boundary it exposes a
 18// read-only view (see p/demo/tokens/grc721/igrc721.gno). Keeping minting and
 19// the market in the same realm lets the market escrow and transfer sold NFTs
 20// internally, without fragile cross-realm approvals.
 21//
 22// Payments use the native coin (ugnot). Buyers and minters attach coins to the
 23// call (gnokey ... -send "<amount>ugnot"); the realm escrows them and pays the
 24// recipients out via the banker. All amounts are in ugnot (1 GNOT = 1_000_000
 25// ugnot).
 26package nftmarket
 27
 28import (
 29	"chain"
 30	"chain/banker"
 31	"chain/runtime"
 32	"chain/runtime/unsafe"
 33	"strconv"
 34
 35	"gno.land/p/g18wk4a80cr7dqa25vfka2yug5n3pd50udled6y3/grc721"
 36	"gno.land/p/nt/avl/v0"
 37	"gno.land/p/nt/ufmt/v0"
 38)
 39
 40const (
 41	ugnot = "ugnot"
 42
 43	// bpsDenominator is the basis-points denominator (10000 bps = 100%).
 44	bpsDenominator = 10000
 45
 46	// maxRoyaltyBps caps a collection's secondary-sale royalty at 50%.
 47	maxRoyaltyBps = 5000
 48
 49	// maxFeeBps caps the platform fee at 10%.
 50	maxFeeBps = 1000
 51
 52	// maxSlugLen bounds a collection id.
 53	maxSlugLen = 40
 54)
 55
 56var zeroAddr address // the empty/invalid address
 57
 58// Collection is one NFT collection created by a user.
 59type Collection struct {
 60	id         string  // unique slug, e.g. "cryptopunks"
 61	name       string  // human-readable name
 62	symbol     string  // ticker, <= 11 chars of [A-Za-z0-9_-]
 63	creator    address // receives mint proceeds and royalties
 64	baseURI    string  // metadata base; tokenURI = baseURI + tokenID
 65	mintPrice  int64   // ugnot charged per mint (0 = free), paid to creator
 66	maxSupply  int64   // hard cap (0 = unlimited)
 67	royaltyBps int64   // creator royalty on secondary sales, in bps
 68	minted     int64   // number minted so far; the next token id is minted+1
 69	createdAt  int64   // block height at creation
 70	nft        *grc721.BasicNFT
 71}
 72
 73// Listing is an active fixed-price sale. While listed, the NFT is escrowed in
 74// the realm's own account (its on-chain owner is the realm address).
 75type Listing struct {
 76	collID    string
 77	tokenID   string
 78	seller    address
 79	price     int64 // ugnot
 80	createdAt int64 // block height
 81}
 82
 83var (
 84	collections avl.Tree // collID -> *Collection
 85	listings    avl.Tree // "collID/tokenID" -> *Listing
 86	collOrder   []string // collection ids in creation order, for rendering
 87
 88	admin  address       // platform admin; empty until ClaimAdmin is called
 89	feeBps int64   = 250 // platform fee on secondary sales (2.5%)
 90	feePot int64         // ugnot collected as fees, withdrawable by admin
 91)
 92
 93// --- collection factory -----------------------------------------------------
 94
 95// CreateCollection registers a new NFT collection owned by the caller and
 96// returns its id. mintPrice and maxSupply may be 0 (free / unlimited).
 97// royaltyBps is the creator's cut of every future secondary sale (0..5000).
 98func CreateCollection(cur realm, id, name, symbol, baseURI string, mintPrice, maxSupply, royaltyBps int64) string {
 99	assertUserCall(cur)
100	creator := cur.Previous().Address()
101
102	if !validSlug(id) {
103		panic("collection id must be 1-40 chars of [a-z0-9-]")
104	}
105	if _, exists := collections.Get(id); exists {
106		panic("collection id already taken: " + id)
107	}
108	if mintPrice < 0 {
109		panic("mintPrice cannot be negative")
110	}
111	if maxSupply < 0 {
112		panic("maxSupply cannot be negative")
113	}
114	if royaltyBps < 0 || royaltyBps > maxRoyaltyBps {
115		panic(ufmt.Sprintf("royaltyBps must be between 0 and %d", maxRoyaltyBps))
116	}
117
118	// NewBasicNFT validates name/symbol and panics on invalid input.
119	nft := grc721.NewBasicNFT(0, cur, name, symbol)
120
121	collections.Set(id, &Collection{
122		id:         id,
123		name:       name,
124		symbol:     symbol,
125		creator:    creator,
126		baseURI:    baseURI,
127		mintPrice:  mintPrice,
128		maxSupply:  maxSupply,
129		royaltyBps: royaltyBps,
130		minted:     0,
131		createdAt:  runtime.ChainHeight(),
132		nft:        nft,
133	})
134	collOrder = append(collOrder, id)
135
136	chain.Emit("CreateCollection", "id", id, "creator", creator.String(), "symbol", symbol)
137	return id
138}
139
140// Mint mints the next token of a collection to the caller, who must attach
141// exactly the collection's mint price in ugnot. The proceeds go to the
142// collection creator. Returns the newly minted token id.
143func Mint(cur realm, collID string) string {
144	assertUserCall(cur)
145	coll := mustGetCollection(collID)
146	minter := cur.Previous().Address()
147
148	if coll.maxSupply > 0 && coll.minted >= coll.maxSupply {
149		panic("collection is sold out")
150	}
151
152	paid := receivedUgnot()
153	if paid != coll.mintPrice {
154		panic(ufmt.Sprintf("must send exactly %d ugnot to mint (sent %d)", coll.mintPrice, paid))
155	}
156
157	tokenID := strconv.FormatInt(coll.minted+1, 10)
158	if err := coll.nft.Mint(minter, grc721.TokenID(tokenID)); err != nil {
159		panic(err)
160	}
161	coll.minted++
162
163	// Primary sale: the creator receives the full mint price.
164	payout(cur, coll.creator, paid)
165
166	chain.Emit("Mint", "collection", collID, "tokenId", tokenID, "minter", minter.String())
167	return tokenID
168}
169
170// --- shared helpers ---------------------------------------------------------
171
172// assertUserCall rejects non-EOA callers. unsafe.OriginSend() only describes a
173// real receipt at this realm when the caller is a pure user (maketx call);
174// an intermediate code realm or `maketx run` envelope could otherwise make the
175// realm pay out its own pre-existing balance. See r/demo/disperse.
176func assertUserCall(cur realm) {
177	if !cur.Previous().IsUserCall() {
178		panic("only direct user calls (gnokey maketx call) are accepted")
179	}
180}
181
182// receivedUgnot returns the amount of ugnot attached to the current call and
183// rejects any other denomination.
184func receivedUgnot() int64 {
185	sent := unsafe.OriginSend()
186	for _, c := range sent {
187		if c.Denom != ugnot {
188			panic("only ugnot is accepted as payment")
189		}
190	}
191	return sent.AmountOf(ugnot)
192}
193
194// payout sends amount ugnot from the realm's escrow to addr. No-op for amount<=0.
195func payout(cur realm, addr address, amount int64) {
196	if amount <= 0 {
197		return
198	}
199	b := banker.NewBanker(banker.BankerTypeRealmSend, cur)
200	b.SendCoins(cur.Address(), addr, chain.Coins{chain.NewCoin(ugnot, amount)})
201}
202
203func mustGetCollection(id string) *Collection {
204	v, ok := collections.Get(id)
205	if !ok {
206		panic("collection not found: " + id)
207	}
208	return v.(*Collection)
209}
210
211func listingKey(collID, tokenID string) string { return collID + "/" + tokenID }
212
213func getListing(collID, tokenID string) (*Listing, bool) {
214	v, ok := listings.Get(listingKey(collID, tokenID))
215	if !ok {
216		return nil, false
217	}
218	return v.(*Listing), true
219}
220
221// now returns the current block height, used to timestamp records.
222func now() int64 { return runtime.ChainHeight() }
223
224// validSlug reports whether s is a valid collection id: 1..maxSlugLen chars of
225// lowercase letters, digits and hyphens.
226func validSlug(s string) bool {
227	if len(s) == 0 || len(s) > maxSlugLen {
228		return false
229	}
230	for _, c := range s {
231		if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') {
232			return false
233		}
234	}
235	return true
236}