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}