nftmarket.gno
8.08 Kb · 245 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 gnogle_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 burned int64 // number of tokens burned
70 sealed bool // when true, no further minting (supply capped at minted)
71 verified bool // admin-set "verified collection" badge
72 createdAt int64 // block height at creation
73 nft *grc721.BasicNFT
74}
75
76// Listing is an active fixed-price sale. While listed, the NFT is escrowed in
77// the realm's own account (its on-chain owner is the realm address).
78type Listing struct {
79 collID string
80 tokenID string
81 seller address
82 price int64 // ugnot
83 createdAt int64 // block height
84}
85
86var (
87 collections avl.Tree // collID -> *Collection
88 listings avl.Tree // "collID/tokenID" -> *Listing
89 collOrder []string // collection ids in creation order, for rendering
90
91 admin address // platform admin; empty until ClaimAdmin is called
92 feeBps int64 = 250 // platform fee on secondary sales (2.5%)
93 feePot int64 // ugnot collected as fees, withdrawable by admin
94)
95
96// --- collection factory -----------------------------------------------------
97
98// CreateCollection registers a new NFT collection owned by the caller and
99// returns its id. mintPrice and maxSupply may be 0 (free / unlimited).
100// royaltyBps is the creator's cut of every future secondary sale (0..5000).
101func CreateCollection(cur realm, id, name, symbol, baseURI string, mintPrice, maxSupply, royaltyBps int64) string {
102 assertUserCall(cur)
103 creator := cur.Previous().Address()
104
105 if !validSlug(id) {
106 panic("collection id must be 1-40 chars of [a-z0-9-]")
107 }
108 if _, exists := collections.Get(id); exists {
109 panic("collection id already taken: " + id)
110 }
111 if mintPrice < 0 {
112 panic("mintPrice cannot be negative")
113 }
114 if maxSupply < 0 {
115 panic("maxSupply cannot be negative")
116 }
117 if royaltyBps < 0 || royaltyBps > maxRoyaltyBps {
118 panic(ufmt.Sprintf("royaltyBps must be between 0 and %d", maxRoyaltyBps))
119 }
120
121 // NewBasicNFT validates name/symbol and panics on invalid input.
122 nft := grc721.NewBasicNFT(0, cur, name, symbol)
123
124 collections.Set(id, &Collection{
125 id: id,
126 name: name,
127 symbol: symbol,
128 creator: creator,
129 baseURI: baseURI,
130 mintPrice: mintPrice,
131 maxSupply: maxSupply,
132 royaltyBps: royaltyBps,
133 minted: 0,
134 createdAt: runtime.ChainHeight(),
135 nft: nft,
136 })
137 collOrder = append(collOrder, id)
138
139 chain.Emit("CreateCollection", "id", id, "creator", creator.String(), "symbol", symbol)
140 return id
141}
142
143// Mint mints the next token of a collection to the caller, who must attach
144// exactly the collection's mint price in ugnot. The proceeds go to the
145// collection creator. Returns the newly minted token id.
146func Mint(cur realm, collID string) string {
147 assertUserCall(cur)
148 coll := mustGetCollection(collID)
149 minter := cur.Previous().Address()
150
151 paid := receivedUgnot()
152 if paid != coll.mintPrice {
153 panic(ufmt.Sprintf("must send exactly %d ugnot to mint (sent %d)", coll.mintPrice, paid))
154 }
155
156 tokenID := mintOne(coll, minter)
157
158 // Primary sale: the creator receives the full mint price.
159 payout(cur, coll.creator, paid)
160
161 chain.Emit("Mint", "collection", collID, "tokenId", tokenID, "minter", minter.String())
162 return tokenID
163}
164
165// mintOne mints the next sequential token of coll to minter and returns its id.
166// Enforces the supply cap. Shared by Mint and MintBatch.
167func mintOne(coll *Collection, minter address) string {
168 if coll.maxSupply > 0 && coll.minted >= coll.maxSupply {
169 panic("collection is sold out")
170 }
171 tokenID := strconv.FormatInt(coll.minted+1, 10)
172 if err := coll.nft.Mint(minter, grc721.TokenID(tokenID)); err != nil {
173 panic(err)
174 }
175 coll.minted++
176 return tokenID
177}
178
179// --- shared helpers ---------------------------------------------------------
180
181// assertUserCall rejects non-EOA callers. unsafe.OriginSend() only describes a
182// real receipt at this realm when the caller is a pure user (maketx call);
183// an intermediate code realm or `maketx run` envelope could otherwise make the
184// realm pay out its own pre-existing balance. See r/demo/disperse.
185func assertUserCall(cur realm) {
186 if !cur.Previous().IsUserCall() {
187 panic("only direct user calls (gnokey maketx call) are accepted")
188 }
189}
190
191// receivedUgnot returns the amount of ugnot attached to the current call and
192// rejects any other denomination.
193func receivedUgnot() int64 {
194 sent := unsafe.OriginSend()
195 for _, c := range sent {
196 if c.Denom != ugnot {
197 panic("only ugnot is accepted as payment")
198 }
199 }
200 return sent.AmountOf(ugnot)
201}
202
203// payout sends amount ugnot from the realm's escrow to addr. No-op for amount<=0.
204func payout(cur realm, addr address, amount int64) {
205 if amount <= 0 {
206 return
207 }
208 b := banker.NewBanker(banker.BankerTypeRealmSend, cur)
209 b.SendCoins(cur.Address(), addr, chain.Coins{chain.NewCoin(ugnot, amount)})
210}
211
212func mustGetCollection(id string) *Collection {
213 v, ok := collections.Get(id)
214 if !ok {
215 panic("collection not found: " + id)
216 }
217 return v.(*Collection)
218}
219
220func listingKey(collID, tokenID string) string { return collID + "/" + tokenID }
221
222func getListing(collID, tokenID string) (*Listing, bool) {
223 v, ok := listings.Get(listingKey(collID, tokenID))
224 if !ok {
225 return nil, false
226 }
227 return v.(*Listing), true
228}
229
230// now returns the current block height, used to timestamp records.
231func now() int64 { return runtime.ChainHeight() }
232
233// validSlug reports whether s is a valid collection id: 1..maxSlugLen chars of
234// lowercase letters, digits and hyphens.
235func validSlug(s string) bool {
236 if len(s) == 0 || len(s) > maxSlugLen {
237 return false
238 }
239 for _, c := range s {
240 if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') {
241 return false
242 }
243 }
244 return true
245}