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