nft.gno
17.70 Kb · 529 lines
1// Package nft is the Launchpad collection ledger. Each collection is a GRC721
2// token whose transfer mode, royalties and metadata rules are fixed at
3// creation. Collections are created, and their tokens minted, only by issuer
4// realms: the Launchpad drops realm and any realm the config admin adds.
5// Holders move, approve and burn their own tokens. The ledger has no admin of
6// its own: nothing here can move a holder's token or change a collection's
7// terms, and a paused lane stops only new collections and new mints.
8package nft
9
10import (
11 "chain"
12 "strconv"
13
14 "gno.land/p/nt/avl/v0"
15 "gno.land/p/nt/grc721/v0"
16 "gno.land/p/nt/seqid/v0"
17 "gno.land/p/samcrew/launchpad/currency/v1"
18 "gno.land/p/samcrew/launchpad/meta/v1"
19 sm "gno.land/p/samcrew/launchpad/safemath/v1"
20 "gno.land/r/samcrew/launchpad/config/v1"
21)
22
23const (
24 dropsPath = "gno.land/r/samcrew/launchpad/drops/v1"
25 marketPath = "gno.land/r/samcrew/launchpad/market/v1"
26 ModeOpen = "open"
27 ModeRoyaltyProtected = "royalty_protected"
28 ModeSoulbound = "soulbound"
29 StatusActive = "active"
30 StatusBurned = "burned"
31 StatusRevoked = "revoked"
32)
33
34// Info is a collection's public record. The creator may edit the profile
35// fields until FreezeProfile, hand the creator role over and seal the supply;
36// everything else is immutable.
37type Info struct {
38 ID string // "C<n>"
39 GRC721ID string
40 Issuer address // the only realm that mints into the collection
41 Creator address
42 Originator address // the creator at creation, whoever holds the role now
43 PendingCreator address
44 Name string
45 Symbol string
46 Description string
47 Image string
48 Banner string
49 Website string
50 Mode string
51 Revocable bool // soulbound only: the creator may revoke a token
52 MaxSupply int64 // zero means an uncapped open edition
53 Sealed bool // no token can ever be minted again
54 Minted int64 // includes retired tokens; numbers are never reused
55 ProfileFrozen bool
56}
57
58type collection struct {
59 key string // tree key: fixed width, in creation order
60 info Info
61 token *grc721.Token
62 ledger *grc721.PrivateLedger
63 metadata metadata
64 traitsRoot string // Merkle root of the token traits, set once
65 royalties []royalty // at most maxRoyaltyReceivers
66 royaltyBPS int64
67 markets []address // royalty-protected only, at most maxMarkets
68 retired avl.Tree // token number -> StatusBurned | StatusRevoked
69}
70
71var (
72 lastID seqid.ID
73 collections avl.Tree // collection key -> *collection
74 holdings avl.Tree // owner -> *avl.Tree of "<collection key>/<number key>" -> custody ID
75 lastCustody seqid.ID // the last custody ID given
76)
77
78// isIssuer reports whether a realm may create collections and mint into its
79// own: the drops realm always, and every realm on config's add-only list.
80func isIssuer(who address) bool {
81 return who == chain.PackageAddress(dropsPath) || config.IsIssuer(config.LedgerNFT, who)
82}
83
84// gate stops a new collection or a new mint while its lane is paused, so the
85// pauser is a brake on every issuer config may add, whatever that issuer
86// checks. The drops realm is not asked twice: it applies the same gate itself,
87// with the currency it charges, before it calls here.
88func gate(issuer address, lane string) {
89 if issuer != chain.PackageAddress(dropsPath) && config.IsPaused(lane) {
90 panic("launchpad/nft: lane paused")
91 }
92}
93
94// callingRealm returns the realm that called; a wallet is refused.
95func callingRealm(cur realm) address {
96 if !cur.IsCurrent() || cur.Previous().IsUserCall() {
97 panic("launchpad/nft: issuer realm only")
98 }
99 return cur.Previous().Address()
100}
101
102// actor returns the immediate caller. Coins a wallet attaches to a direct call
103// would stay in this realm, so they are refused; a calling realm keeps whatever
104// its own caller sent.
105func actor(cur realm) address {
106 if !cur.IsCurrent() {
107 panic("launchpad/nft: stale realm context")
108 }
109 if cur.Previous().IsUserCall() {
110 currency.AssertNoCoins(0, cur)
111 }
112 return cur.Previous().Address()
113}
114
115func check(err error) {
116 if err != nil {
117 panic(err.Error())
118 }
119}
120
121func checkProfile(description, image, banner, website string) {
122 check(meta.Description(description))
123 check(meta.Image(image))
124 check(meta.Image(banner))
125 check(meta.Website(website))
126}
127
128func mustCollection(id string) *collection {
129 if len(id) >= 2 && len(id) <= 21 && id[0] == 'C' && id[1] != '0' {
130 if n, err := strconv.ParseUint(id[1:], 10, 64); err == nil {
131 if c := collections.Get(seqid.ID(n).String()); c != nil {
132 return c.(*collection)
133 }
134 }
135 }
136 panic("launchpad/nft: unknown collection")
137}
138
139func tokenID(number int64) grc721.TokenID {
140 if number <= 0 {
141 panic("launchpad/nft: invalid token number")
142 }
143 return grc721.TokenID(strconv.FormatInt(number, 10))
144}
145
146func holdingKey(c *collection, number int64) string {
147 return c.key + "/" + seqid.ID(number).String()
148}
149
150// hold and release keep the holdings index equal to ownership: one tree per
151// owner, so a page of an account's tokens costs the same wherever it starts.
152func hold(owner address, c *collection, number int64) {
153 mine, _ := holdings.Get(owner.String()).(*avl.Tree)
154 if mine == nil {
155 mine = avl.NewTree()
156 holdings.Set(owner.String(), mine)
157 }
158 mine.Set(holdingKey(c, number), int64(lastCustody.Next()))
159}
160
161// renew gives a held token a new custody ID, as a move does: a change of its
162// single-token approval starts a new custody too.
163func renew(owner address, c *collection, number int64) {
164 holdings.Get(owner.String()).(*avl.Tree).Set(holdingKey(c, number), int64(lastCustody.Next()))
165}
166
167func release(owner address, c *collection, number int64) {
168 mine := holdings.Get(owner.String()).(*avl.Tree)
169 mine.Remove(holdingKey(c, number))
170 if mine.Size() == 0 {
171 holdings.Remove(owner.String())
172 }
173}
174
175// Create registers a collection for a creator the calling issuer realm has
176// authenticated and charged. The issuer is recorded on the collection and no
177// other realm can mint into it. royalties is "address:bps;address:bps" with
178// addresses in strictly ascending order, or empty; the metadata arguments are
179// those of newMetadata.
180func Create(cur realm, creator address, name, symbol, description, image,
181 banner, website, mode string, revocable bool, maxSupply int64,
182 metadataMode, baseURI, placeholderURI, commitment, provenance,
183 royalties string) string {
184 issuer := callingRealm(cur)
185 if !isIssuer(issuer) {
186 panic("launchpad/nft: issuer realm only")
187 }
188 gate(issuer, config.LaneCollection)
189 if !meta.ValidAddress(creator) || maxSupply < 0 ||
190 (mode != ModeOpen && mode != ModeRoyaltyProtected && mode != ModeSoulbound) ||
191 (revocable && mode != ModeSoulbound) {
192 panic("launchpad/nft: invalid collection terms")
193 }
194 check(meta.Name(name))
195 check(meta.Ticker(symbol))
196 if config.IsReserved(symbol) {
197 panic("launchpad/nft: reserved symbol")
198 }
199 checkProfile(description, image, banner, website)
200 c := &collection{metadata: newMetadata(metadataMode, baseURI, placeholderURI,
201 commitment, provenance, creator)}
202 c.royalties, c.royaltyBPS = parseRoyalties(royalties, mode)
203 if mode == ModeRoyaltyProtected {
204 c.markets = []address{chain.PackageAddress(marketPath)}
205 }
206 n := lastID.Next()
207 id := "C" + strconv.FormatUint(uint64(n), 10)
208 c.key = n.String()
209 c.token, c.ledger = grc721.NewToken(name, symbol, n, cur)
210 c.info = Info{
211 ID: id, GRC721ID: c.token.ID(), Issuer: issuer, Creator: creator,
212 Originator: creator, Name: name, Symbol: symbol, Description: description, Image: image,
213 Banner: banner, Website: website, Mode: mode, Revocable: revocable,
214 MaxSupply: maxSupply,
215 }
216 collections.Set(c.key, c)
217 chain.Emit("CollectionCreated", "collection", id, "token", c.token.ID(),
218 "issuer", issuer.String(), "creator", creator.String(),
219 "name", name, "symbol", symbol, "mode", mode,
220 "revocable", strconv.FormatBool(revocable),
221 "max_supply", strconv.FormatInt(maxSupply, 10),
222 "metadata", metadataMode, "base_uri", baseURI,
223 "placeholder_uri", placeholderURI, "base_uri_commitment", commitment,
224 "provenance", provenance, "royalties", royalties,
225 "description", description, "image", image, "banner", banner,
226 "website", website)
227 return id
228}
229
230// MintTo issues the next token number. Only the collection's issuer calls it,
231// after its own accounting of stages and payment. The ledger mints to whoever
232// the issuer names: it is the issuer's duty to mint a soulbound token only to
233// the wallet that asked for it.
234func MintTo(cur realm, id string, recipient address) int64 {
235 issuer := callingRealm(cur)
236 c := mustCollection(id)
237 if issuer != c.info.Issuer {
238 panic("launchpad/nft: the collection's issuer only")
239 }
240 gate(issuer, config.LaneNFTDrops)
241 if !meta.ValidAddress(recipient) || c.info.Sealed ||
242 (c.info.MaxSupply > 0 && c.info.Minted >= c.info.MaxSupply) {
243 panic("launchpad/nft: mint denied")
244 }
245 number := sm.Add(c.info.Minted, 1)
246 check(c.ledger.Mint(recipient, tokenID(number)))
247 c.info.Minted = number
248 hold(recipient, c, number)
249 return number
250}
251
252// operator reports whether who may be approved for, or move, a token of c.
253// Soulbound tokens never move. A royalty-protected token moves only through a
254// realm on the collection's market list. The ledger cannot make a realm pay:
255// the creator who lists one vouches that it pays the collection's royalties.
256func operator(c *collection, who address) bool {
257 switch c.info.Mode {
258 case ModeOpen:
259 return true
260 case ModeRoyaltyProtected:
261 for _, market := range c.markets {
262 if who == market {
263 return true
264 }
265 }
266 }
267 return false
268}
269
270func denied(c *collection) {
271 if c.info.Mode == ModeSoulbound {
272 panic("launchpad/nft: soulbound tokens cannot move")
273 }
274 panic("launchpad/nft: royalty-protected tokens move only through an allowed market")
275}
276
277// TransferFrom moves a token when the collection's mode lets the caller move
278// one, and the caller owns it or is approved for it.
279func TransferFrom(cur realm, id string, from, to address, number int64) {
280 who := actor(cur)
281 c := mustCollection(id)
282 if !operator(c, who) {
283 denied(c)
284 }
285 if !meta.ValidAddress(to) {
286 panic("launchpad/nft: invalid recipient")
287 }
288 check(c.ledger.TransferFrom(who, from, to, tokenID(number)))
289 release(from, c, number)
290 hold(to, c, number)
291}
292
293// Approve lets one account move one token; an empty address clears it. A
294// soulbound token takes no approval at all, a royalty-protected one only a
295// listed market.
296func Approve(cur realm, id string, to address, number int64) {
297 who := actor(cur)
298 c := mustCollection(id)
299 if c.info.Mode == ModeSoulbound || (to != "" && !operator(c, to)) {
300 denied(c)
301 }
302 if to != "" && !meta.ValidAddress(to) {
303 panic("launchpad/nft: invalid operator")
304 }
305 before := GetApproved(id, number)
306 check(c.ledger.Approve(who, to, tokenID(number)))
307 if to != before {
308 renew(ownerOf(c, number), c, number)
309 }
310}
311
312// SetApprovalForAll lets, or stops letting, one account move every token the
313// caller holds in the collection.
314func SetApprovalForAll(cur realm, id string, to address, approved bool) {
315 who := actor(cur)
316 c := mustCollection(id)
317 if !operator(c, to) {
318 denied(c)
319 }
320 if !meta.ValidAddress(to) {
321 panic("launchpad/nft: invalid operator")
322 }
323 check(c.ledger.SetApprovalForAll(who, to, approved))
324}
325
326func ownerOf(c *collection, number int64) address {
327 owner, err := c.token.OwnerOf(tokenID(number))
328 check(err)
329 return owner
330}
331
332func retire(c *collection, owner address, number int64, status string) {
333 tid := tokenID(number)
334 check(c.ledger.Burn(tid))
335 c.retired.Set(tid.String(), status)
336 release(owner, c, number)
337}
338
339// Burn is the holder's alone, in every mode. No creator, operator or admin
340// can destroy a token somebody else holds, except through Revoke.
341func Burn(cur realm, id string, number int64) {
342 who := actor(cur)
343 c := mustCollection(id)
344 if who != ownerOf(c, number) {
345 panic("launchpad/nft: holder only")
346 }
347 retire(c, who, number, StatusBurned)
348}
349
350// Revoke retires a credential. It exists only for soulbound collections
351// created as revocable, a term every holder could read before claiming. A
352// holder who burns first leaves a burned token, not a revoked one.
353func Revoke(cur realm, id string, number int64, reason string) {
354 c := creatorOf(cur, id)
355 if c.info.Mode != ModeSoulbound || !c.info.Revocable {
356 panic("launchpad/nft: not a revocable collection")
357 }
358 switch reason {
359 case "expired", "fraud", "eligibility_lost", "issuer_error", "other":
360 default:
361 panic("launchpad/nft: invalid revocation reason")
362 }
363 holder := ownerOf(c, number)
364 retire(c, holder, number, StatusRevoked)
365 chain.Emit("Revoked", "collection", id,
366 "number", strconv.FormatInt(number, 10), "holder", holder.String(),
367 "reason", reason)
368}
369
370// creatorOf returns the collection when the caller is its creator.
371func creatorOf(cur realm, id string) *collection {
372 who := actor(cur)
373 c := mustCollection(id)
374 if who != c.info.Creator {
375 panic("launchpad/nft: creator only")
376 }
377 return c
378}
379
380// ProposeCreator starts handing the creator role to another account. Nothing
381// changes until that account accepts; an empty address withdraws the offer.
382func ProposeCreator(cur realm, id string, next address) {
383 c := creatorOf(cur, id)
384 if next != "" && (!meta.ValidAddress(next) || next == c.info.Creator) {
385 panic("launchpad/nft: invalid next creator")
386 }
387 c.info.PendingCreator = next
388 chain.Emit("CreatorProposed", "collection", id, "next", next.String())
389}
390
391// AcceptCreator makes the caller the creator it was proposed as.
392func AcceptCreator(cur realm, id string) {
393 who := actor(cur)
394 c := mustCollection(id)
395 if c.info.PendingCreator == "" || who != c.info.PendingCreator {
396 panic("launchpad/nft: proposed creator only")
397 }
398 c.info.Creator, c.info.PendingCreator = who, ""
399 chain.Emit("CreatorChanged", "collection", id, "creator", who.String())
400}
401
402// SetIssuer moves the collection's minting to another issuer realm, for
403// instance a later version of the one that created it. Only the creator can
404// choose it, and only among realms allowed to issue.
405func SetIssuer(cur realm, id, pkgPath string) {
406 c := creatorOf(cur, id)
407 if len(pkgPath) < 12 || len(pkgPath) > 200 || pkgPath[:11] != "gno.land/r/" {
408 panic("launchpad/nft: invalid issuer")
409 }
410 next := chain.PackageAddress(pkgPath)
411 if !isIssuer(next) || next == c.info.Issuer {
412 panic("launchpad/nft: invalid issuer")
413 }
414 c.info.Issuer = next
415 chain.Emit("IssuerChanged", "collection", id, "issuer", next.String(),
416 "path", pkgPath)
417}
418
419// SealSupply ends minting for good, whatever the cap was.
420func SealSupply(cur realm, id string) {
421 c := creatorOf(cur, id)
422 if c.info.Sealed {
423 panic("launchpad/nft: supply already sealed")
424 }
425 c.info.Sealed = true
426 chain.Emit("SupplySealed", "collection", id,
427 "minted", strconv.FormatInt(c.info.Minted, 10))
428}
429
430func profileOwner(cur realm, id string) *collection {
431 c := creatorOf(cur, id)
432 if c.info.ProfileFrozen {
433 panic("launchpad/nft: profile frozen")
434 }
435 return c
436}
437
438func SetProfile(cur realm, id, description, image, banner, website string) {
439 c := profileOwner(cur, id)
440 checkProfile(description, image, banner, website)
441 c.info.Description, c.info.Image = description, image
442 c.info.Banner, c.info.Website = banner, website
443 chain.Emit("ProfileChanged", "collection", id, "description", description,
444 "image", image, "banner", banner, "website", website)
445}
446
447func FreezeProfile(cur realm, id string) {
448 profileOwner(cur, id).info.ProfileFrozen = true
449 chain.Emit("ProfileFrozen", "collection", id)
450}
451
452func GetInfo(id string) Info { return mustCollection(id).info }
453func Count() int64 { return int64(collections.Size()) }
454
455func BalanceOf(id string, owner address) int64 {
456 if !meta.ValidAddress(owner) {
457 panic("launchpad/nft: invalid owner")
458 }
459 n, err := mustCollection(id).token.BalanceOf(owner)
460 check(err)
461 return n
462}
463
464func OwnerOf(id string, number int64) address { return ownerOf(mustCollection(id), number) }
465
466func TotalSupply(id string) int64 { return mustCollection(id).token.TotalSupply() }
467
468// GetApproved returns the empty address when nobody is approved.
469func GetApproved(id string, number int64) address {
470 a, err := mustCollection(id).token.GetApproved(tokenID(number))
471 if err == grc721.ErrTokenIdNotApproved {
472 return ""
473 }
474 check(err)
475 return a
476}
477
478func IsApprovedForAll(id string, owner, operator address) bool {
479 if !meta.ValidAddress(owner) {
480 panic("launchpad/nft: invalid owner")
481 }
482 if !meta.ValidAddress(operator) {
483 panic("launchpad/nft: invalid operator")
484 }
485 return mustCollection(id).token.IsApprovedForAll(owner, operator)
486}
487
488func statusOf(c *collection, number int64) string {
489 if number <= 0 || number > c.info.Minted {
490 panic("launchpad/nft: invalid token number")
491 }
492 if status := c.retired.Get(strconv.FormatInt(number, 10)); status != nil {
493 return status.(string)
494 }
495 return StatusActive
496}
497
498// Epoch returns the ID of a token's current custody: a new one at every move
499// and every change of its single-token approval (the same approval again
500// changes nothing), never one given before; 0 for a retired token. A market
501// binds an order to it, so the order dies with the custody it was made under.
502func Epoch(id string, number int64) int64 {
503 c := mustCollection(id)
504 if statusOf(c, number) != StatusActive {
505 return 0
506 }
507 return holdings.Get(ownerOf(c, number).String()).(*avl.Tree).Get(holdingKey(c, number)).(int64)
508}
509
510func StatusOf(id string, number int64) string { return statusOf(mustCollection(id), number) }
511
512// TokenURI answers for every minted number, retired or not: the metadata of a
513// burned token is still part of the collection's public record.
514func TokenURI(id string, number int64) string {
515 c := mustCollection(id)
516 statusOf(c, number)
517 return c.metadata.tokenURI(number)
518}
519
520func Render(path string) string {
521 if path == "" {
522 return "# Launchpad NFT collections\n\n" +
523 strconv.Itoa(collections.Size()) + " collections.\n"
524 }
525 c := mustCollection(path)
526 return "# " + meta.EscapeMarkdown(c.info.Name) + "\n\n" +
527 "Mode: " + c.info.Mode + "\n\n" +
528 "Minted: " + strconv.FormatInt(c.info.Minted, 10) + "\n"
529}