Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

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}