nft.gno
5.62 Kb · 207 lines
1// Package gnogle_nft is the NFT layer of the GNOGLE marketplace: a stable,
2// upgrade-independent realm that stores every collection and token (GRC721).
3//
4// The marketplace LOGIC lives in a SEPARATE realm (gnogle_market). It never
5// holds tokens — instead an owner grants it operator approval (SetApprovalForAll)
6// and the market moves tokens by calling TransferFrom here. Because the tokens
7// live in THIS realm, the market can be redeployed/upgraded any number of times
8// without affecting a single NFT.
9package gnogle_nft2
10
11import (
12 "chain"
13 "chain/banker"
14 "chain/runtime"
15 "chain/runtime/unsafe"
16 "strconv"
17
18 "gno.land/p/g18wk4a80cr7dqa25vfka2yug5n3pd50udled6y3/grc721"
19 "gno.land/p/nt/avl/v0"
20 "gno.land/p/nt/ufmt/v0"
21)
22
23const (
24 ugnot = "ugnot"
25 bpsDenominator = 10000
26 maxRoyaltyBps = 5000
27 maxSlugLen = 40
28)
29
30var zeroAddr address
31
32// Collection is one NFT collection. The concrete *grc721.BasicNFT is held in an
33// unexported field, so only this realm can mutate token ownership.
34type Collection struct {
35 id string
36 name string
37 symbol string
38 creator address
39 baseURI string
40 mintPrice int64
41 maxSupply int64
42 royaltyBps int64
43 minted int64
44 burned int64
45 sealed bool
46 verified bool
47 createdAt int64
48 nft *grc721.BasicNFT
49}
50
51var (
52 collections avl.Tree // id -> *Collection
53 collOrder []string
54 admin address
55)
56
57// CreateCollection registers a new collection owned by the caller.
58func CreateCollection(cur realm, id, name, symbol, baseURI string, mintPrice, maxSupply, royaltyBps int64) string {
59 assertUserCall(cur)
60 creator := cur.Previous().Address()
61 if !validSlug(id) {
62 panic("collection id must be 1-40 chars of [a-z0-9-]")
63 }
64 if _, ok := collections.Get(id); ok {
65 panic("collection id already taken: " + id)
66 }
67 if mintPrice < 0 || maxSupply < 0 {
68 panic("mintPrice/maxSupply cannot be negative")
69 }
70 if royaltyBps < 0 || royaltyBps > maxRoyaltyBps {
71 panic(ufmt.Sprintf("royaltyBps must be 0..%d", maxRoyaltyBps))
72 }
73 nft := grc721.NewBasicNFT(0, cur, name, symbol)
74 collections.Set(id, &Collection{
75 id: id, name: name, symbol: symbol, creator: creator, baseURI: baseURI,
76 mintPrice: mintPrice, maxSupply: maxSupply, royaltyBps: royaltyBps,
77 createdAt: runtime.ChainHeight(), nft: nft,
78 })
79 collOrder = append(collOrder, id)
80 chain.Emit("CreateCollection", "id", id, "creator", creator.String())
81 return id
82}
83
84// Mint mints the next token of a collection to the caller, who must attach the
85// mint price in ugnot (paid to the creator).
86func Mint(cur realm, collID string) string {
87 assertUserCall(cur)
88 coll := mustGetCollection(collID)
89 minter := cur.Previous().Address()
90 paid := receivedUgnot()
91 if paid != coll.mintPrice {
92 panic(ufmt.Sprintf("must send exactly %d ugnot to mint (sent %d)", coll.mintPrice, paid))
93 }
94 tokenID := mintOne(coll, minter)
95 payCreator(cur, coll.creator, paid)
96 chain.Emit("Mint", "collection", collID, "tokenId", tokenID, "minter", minter.String())
97 return tokenID
98}
99
100// MintBatch mints count (1..20) tokens to the caller, charging count × mint price.
101func MintBatch(cur realm, collID string, count int64) string {
102 assertUserCall(cur)
103 if count <= 0 || count > 20 {
104 panic("count must be between 1 and 20")
105 }
106 coll := mustGetCollection(collID)
107 minter := cur.Previous().Address()
108 paid := receivedUgnot()
109 if paid != coll.mintPrice*count {
110 panic(ufmt.Sprintf("must send exactly %d ugnot for %d mints", coll.mintPrice*count, count))
111 }
112 first := ""
113 for i := int64(0); i < count; i++ {
114 if tid := mintOne(coll, minter); first == "" {
115 first = tid
116 }
117 }
118 payCreator(cur, coll.creator, paid)
119 chain.Emit("MintBatch", "collection", collID, "count", strconv.FormatInt(count, 10), "minter", minter.String())
120 return first
121}
122
123func mintOne(coll *Collection, minter address) string {
124 if coll.maxSupply > 0 && coll.minted >= coll.maxSupply {
125 panic("collection is sold out")
126 }
127 tokenID := strconv.FormatInt(coll.minted+1, 10)
128 if err := coll.nft.Mint(minter, grc721.TokenID(tokenID)); err != nil {
129 panic(err)
130 }
131 coll.minted++
132 return tokenID
133}
134
135// --- admin (verified badge only) -------------------------------------------
136
137// ClaimAdmin assigns the admin (one-time). Admin can only flag collections verified.
138func ClaimAdmin(cur realm) {
139 if admin != zeroAddr {
140 panic("admin already claimed")
141 }
142 admin = cur.Previous().Address()
143 chain.Emit("ClaimAdmin", "admin", admin.String())
144}
145
146func assertAdmin(cur realm) {
147 if admin == zeroAddr {
148 panic("admin not set; call ClaimAdmin first")
149 }
150 if cur.Previous().Address() != admin {
151 panic("unauthorized: admin only")
152 }
153}
154
155// --- helpers ---------------------------------------------------------------
156
157func assertUserCall(cur realm) {
158 if !cur.Previous().IsUserCall() {
159 panic("only direct user calls are accepted")
160 }
161}
162
163func receivedUgnot() int64 {
164 sent := unsafe.OriginSend()
165 for _, c := range sent {
166 if c.Denom != ugnot {
167 panic("only ugnot is accepted")
168 }
169 }
170 return sent.AmountOf(ugnot)
171}
172
173func payCreator(cur realm, to address, amount int64) {
174 if amount <= 0 {
175 return
176 }
177 b := banker.NewBanker(banker.BankerTypeRealmSend, cur)
178 b.SendCoins(cur.Address(), to, chain.Coins{chain.NewCoin(ugnot, amount)})
179}
180
181func mustGetCollection(id string) *Collection {
182 v, ok := collections.Get(id)
183 if !ok {
184 panic("collection not found: " + id)
185 }
186 return v.(*Collection)
187}
188
189func getCollection(id string) (*Collection, bool) {
190 v, ok := collections.Get(id)
191 if !ok {
192 return nil, false
193 }
194 return v.(*Collection), true
195}
196
197func validSlug(s string) bool {
198 if len(s) == 0 || len(s) > maxSlugLen {
199 return false
200 }
201 for _, c := range s {
202 if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') {
203 return false
204 }
205 }
206 return true
207}