Search Apps Documentation Source Content File Folder Download Copy Actions Download

nftminter.gno

17.44 Kb · 500 lines
  1// Package tardigrades is a single-collection GRC721 NFT minter realm.
  2//
  3// Minting is public and free — anyone can call Mint, no allowlist, no
  4// payment. Metadata is predetermined: the owner curates an ordered queue
  5// of preset tokens (AddPresetToken), and Mint just hands out the next
  6// unclaimed one — the caller supplies no metadata and cannot influence
  7// or later change what they receive (metadata-editing functions are
  8// collection-owner-only, not token-owner). Metadata is stored fully
  9// on-chain per token, following the OpenSea metadata standard.
 10//
 11// The realm's public functions deliberately mirror the standard GRC721
 12// shape (Mint/OwnerOf/TokenURI/SafeTransferFrom/BalanceOf/...) so that
 13// tooling built against any GRC721 collection — a marketplace, an
 14// explorer/observer — can interact with this one without special-casing
 15// it. See gno.land/p/g1w93f099t4pp9jamyghp88p60fvkg39dxz2qzrc/grc721 for
 16// the underlying token implementation.
 17package tardigrades
 18
 19import (
 20	unsaferealm "chain/runtime"
 21	"encoding/base64"
 22	"strings"
 23
 24	"gno.land/p/g1w93f099t4pp9jamyghp88p60fvkg39dxz2qzrc/grc721"
 25	"gno.land/p/g1w93f099t4pp9jamyghp88p60fvkg39dxz2qzrc/avl"
 26	"gno.land/p/nt/seqid/v0"
 27	"gno.land/p/nt/ufmt/v0"
 28)
 29
 30const (
 31	CollectionName   = "Tardigrades in Gnoland"
 32	CollectionSymbol = "TARDIGRADE"
 33
 34	// MaxSupply caps total mints; 0 means unlimited.
 35	MaxSupply = 69
 36)
 37
 38var (
 39	// nft holds the collection's token state. Concrete type on purpose
 40	// (see grc721.IGRC721Reader's doc comment): there is no writer
 41	// interface, so every mutation must go through one of this realm's
 42	// own cur-validating wrappers below.
 43	nft *grc721.MetadataNFT
 44
 45	// collectionOwner curates the preset queue and can edit token
 46	// metadata — minting itself is public and needs no authorization.
 47	// Deploy-time constant: the address that published this realm.
 48	//
 49	// Self-owned rather than gno.land/p/nt/ownable/v0 — that package's
 50	// own API isn't consistent across gno.land networks either (a third
 51	// instance of the same class of problem as avl.Tree.Get and
 52	// chain/runtime's PreviousRealm/CurrentRealm split: Beta Mainnet's
 53	// deployed ownable has a completely different shape — AssertOwned()
 54	// takes no address argument at all, auth mode baked in at
 55	// construction time — confirmed from its actual deployed source, not
 56	// assumed). We only ever used two methods (AssertOwnedBy, Owner) on
 57	// top of an address we already derive ourselves via callerAddress(),
 58	// so owning this directly removes a dependency that has already
 59	// drifted once, rather than adding a third per-network dialect to
 60	// render-for-address.sh for something this small.
 61	collectionOwner address = "g1w93f099t4pp9jamyghp88p60fvkg39dxz2qzrc"
 62
 63	nextID seqid.ID
 64
 65	// presets is the owner-curated queue of not-yet-minted token
 66	// metadata, in mint order. presetsIn is the index the next
 67	// AddPresetToken call writes to; presetsOut is the index Mint
 68	// consumes next — the gap between them is the queue's contents.
 69	presets    avl.Tree // seqid string -> presetEntry
 70	presetsIn  seqid.ID
 71	presetsOut seqid.ID
 72
 73	// mintedIDs is every token ID ever minted, in mint order, including
 74	// burned ones (OwnerOf simply errors for those — callers iterating
 75	// this list should skip whatever it rejects). Exists purely to make
 76	// "list the whole collection" / "list what address X owns" possible
 77	// without needing to reach into grc721's internal owners tree, which
 78	// isn't exposed for iteration.
 79	mintedIDs []grc721.TokenID
 80)
 81
 82// presetEntry is one not-yet-minted token's predetermined metadata,
 83// queued by the owner ahead of time via AddPresetToken.
 84type presetEntry struct {
 85	Name            string
 86	Description     string
 87	Image           string
 88	ExternalURL     string
 89	BackgroundColor string
 90	Attributes      []grc721.Trait
 91}
 92
 93// parseAttributesCSV decodes AddPresetToken's attributesCSV argument:
 94// "TraitType=Value" pairs separated by ";", e.g.
 95// "Hat=Red Beanie;Eyes=Laser;Clothing=Denim Jacket". Blank entries and
 96// entries missing "=" are skipped rather than erroring — keeps this
 97// forgiving for hand-typed input from the web console.
 98func parseAttributesCSV(csv string) []grc721.Trait {
 99	if csv == "" {
100		return nil
101	}
102	var attrs []grc721.Trait
103	for _, pair := range strings.Split(csv, ";") {
104		if pair == "" {
105			continue
106		}
107		idx := strings.Index(pair, "=")
108		if idx < 0 {
109			continue
110		}
111		attrs = append(attrs, grc721.Trait{
112			TraitType: pair[:idx],
113			Value:     pair[idx+1:],
114		})
115	}
116	return attrs
117}
118
119func init(cur realm) {
120	nft = grc721.NewNFTWithMetadata(0, cur, CollectionName, CollectionSymbol)
121}
122
123/* -------------------- Reader (standard GRC721 shape) -------------------- */
124
125func Name() string      { return nft.Name() }
126func Symbol() string    { return nft.Symbol() }
127func TokenCount() int64 { return nft.TokenCount() }
128
129func BalanceOf(owner address) (int64, error) {
130	return nft.BalanceOf(owner)
131}
132
133func OwnerOf(tid grc721.TokenID) (address, error) {
134	return nft.OwnerOf(tid)
135}
136
137func TokenURI(tid grc721.TokenID) (string, error) {
138	return nft.TokenURI(tid)
139}
140
141func TokenMetadata(tid grc721.TokenID) (grc721.Metadata, error) {
142	return nft.TokenMetadata(tid)
143}
144
145func GetApproved(tid grc721.TokenID) (address, error) {
146	return nft.GetApproved(tid)
147}
148
149func IsApprovedForAll(owner, operator address) bool {
150	return nft.IsApprovedForAll(owner, operator)
151}
152
153// PresetQueueLength reports how many not-yet-minted preset tokens are
154// queued up — i.e. how many more times Mint can succeed before it needs
155// AddPresetToken to be called again.
156func PresetQueueLength() int64 {
157	return int64(presets.Size())
158}
159
160// Getter returns a reader-only view of the collection, safe to register
161// with cross-realm aggregators (marketplace, observer) without risking
162// a captured cur.
163func Getter() grc721.NFTGetter {
164	return nft.Getter()
165}
166
167// traitsToCSV is the inverse of parseAttributesCSV — used by TokenSummary
168// to round-trip a token's traits through the same "TraitType=Value;..."
169// format AddPresetToken accepts, keeping the encoding symmetric.
170func traitsToCSV(attrs []grc721.Trait) string {
171	pairs := make([]string, len(attrs))
172	for i, a := range attrs {
173		pairs[i] = a.TraitType + "=" + a.Value
174	}
175	return strings.Join(pairs, ";")
176}
177
178// TokenSummary returns tid, its current owner, and its metadata's
179// name/description/image/externalURL/attributes as one line: fields
180// separated by "|", each value (other than the plain tokenID/owner)
181// base64-encoded. Base64 rather than plain text specifically so a
182// description or name containing "|", a newline, or anything else can
183// never be mistaken for a field boundary — a plain-text delimited format
184// would be one user-supplied "|" away from corrupting whatever parses
185// it. Meant for simple off-chain UIs (this project's own web/ console)
186// to build a gallery view without decoding Gno's own struct value syntax.
187func TokenSummary(tid grc721.TokenID) (string, error) {
188	owner, err := nft.OwnerOf(tid)
189	if err != nil {
190		return "", err
191	}
192	metadata, err := nft.TokenMetadata(tid)
193	if err != nil {
194		return "", err
195	}
196
197	enc := base64.StdEncoding
198	fields := []string{
199		tid.String(),
200		owner.String(),
201		enc.EncodeToString([]byte(metadata.Name)),
202		enc.EncodeToString([]byte(metadata.Description)),
203		enc.EncodeToString([]byte(metadata.Image)),
204		enc.EncodeToString([]byte(metadata.ExternalURL)),
205		enc.EncodeToString([]byte(traitsToCSV(metadata.Attributes))),
206		enc.EncodeToString([]byte(metadata.BackgroundColor)),
207	}
208	return strings.Join(fields, "|"), nil
209}
210
211// CollectionSummary returns one TokenSummary line per currently-existing
212// token (burned tokens are silently skipped), newline-separated, in mint
213// order.
214func CollectionSummary() string {
215	var lines []string
216	for _, tid := range mintedIDs {
217		line, err := TokenSummary(tid)
218		if err != nil {
219			continue // burned since minting
220		}
221		lines = append(lines, line)
222	}
223	return strings.Join(lines, "\n")
224}
225
226// WalletSummary is CollectionSummary filtered to tokens currently owned
227// by owner.
228func WalletSummary(owner address) string {
229	var lines []string
230	for _, tid := range mintedIDs {
231		tokenOwner, err := nft.OwnerOf(tid)
232		if err != nil || tokenOwner != owner {
233			continue
234		}
235		line, err := TokenSummary(tid)
236		if err != nil {
237			continue
238		}
239		lines = append(lines, line)
240	}
241	return strings.Join(lines, "\n")
242}
243
244// callerAddress derives the address that crossed into whichever
245// exported function called this, for authorization/ownership checks
246// (assertOwner, token-transfer caller derivation, etc.).
247//
248// Uses chain/runtime/unsafe.PreviousRealm() instead of the more
249// idiomatic cur.Previous() because cur.Previous() isn't available on
250// every gno.land network — confirmed missing on Beta Mainnet's GnoVM
251// as of 2026-08 (compile error: "type realm has no field or method
252// IsCurrent", which cur.Previous() also depends on). unsafe.PreviousRealm()
253// is documented as capable of misidentifying the caller in a "non-
254// crossing helper" reachable via multiple different realms' crossing
255// paths — that's NOT this function's shape: it's private, called only
256// from this realm's own exported entrypoints, each itself the sole
257// crossing frame in the chain, so there is exactly one possible
258// "immediate caller" per call — no cross-realm ambiguity for the
259// stack-walk to get wrong. Verified empirically (not just by this
260// argument) against an adversarial "malicious realm caller" scenario
261// and a two-hop EOA-through-an-intermediate-realm scenario, both
262// through this exact one-hop-of-indirection shape, before relying on
263// it here — see conversation history for the test cases if revisiting.
264func callerAddress() address {
265	return unsaferealm.PreviousRealm().Address()
266}
267
268// Owner returns the collection owner's address.
269func Owner() address {
270	return collectionOwner
271}
272
273// assertOwner panics unless the caller is the collection owner — this
274// realm's own equivalent of ownable.Ownable.AssertOwnedBy, see
275// collectionOwner's doc comment for why it's not that package.
276func assertOwner() {
277	if callerAddress() != collectionOwner {
278		panic("unauthorized: caller is not the collection owner")
279	}
280}
281
282/* ------------------------------- Minting ---------------------------------- */
283
284// Mint creates a new token owned by `to`, assigns it the next
285// not-yet-claimed preset in the queue (see AddPresetToken), and returns
286// its token ID. Public — anyone can call this, no authorization check,
287// no payment. Panics if the preset queue is empty — queue more with
288// AddPresetToken first.
289//
290// Deliberately takes no metadata of any kind from the caller: the point
291// of the preset queue is that whoever ends up minting a token can't
292// choose or influence what it looks like.
293func Mint(cur realm, to address) grc721.TokenID {
294	if MaxSupply > 0 && nft.TokenCount() >= MaxSupply {
295		panic("max supply reached")
296	}
297
298	outKey := presetsOut.String()
299	preset, ok := presets.Get(outKey).(presetEntry)
300	if !ok {
301		panic("no preset tokens queued — call AddPresetToken first")
302	}
303	presets.Remove(outKey)
304	presetsOut.Next()
305
306	tid := grc721.TokenID(nextID.String())
307	checkErr(nft.Mint(to, tid))
308	checkErr(nft.SetTokenMetadata(to, tid, grc721.Metadata{
309		Name:            preset.Name,
310		Description:     preset.Description,
311		Image:           preset.Image,
312		ExternalURL:     preset.ExternalURL,
313		BackgroundColor: preset.BackgroundColor,
314		Attributes:      preset.Attributes,
315	}))
316	nextID.Next()
317	mintedIDs = append(mintedIDs, tid)
318
319	return tid
320}
321
322// AddPresetToken queues one predetermined token's metadata for a future
323// Mint call to hand out, in the order added. Owner-only — this is the
324// only way preset metadata enters the collection; nothing else can
325// influence what an eventual Mint call produces.
326//
327// attributesCSV packs an arbitrary number of traits into one primitive
328// string argument — see parseAttributesCSV for the format. Required for
329// this to stay callable via a real gnokey/MsgCall transaction: a []Trait
330// parameter isn't an option (see Mint's doc comment on why struct/slice
331// arguments make a function uncallable from a real transaction).
332func AddPresetToken(cur realm, name, description, image, externalURL, backgroundColor, attributesCSV string) {
333	assertOwner()
334	presets.Set(presetsIn.String(), presetEntry{
335		Name:            name,
336		Description:     description,
337		Image:           image,
338		ExternalURL:     externalURL,
339		BackgroundColor: backgroundColor,
340		Attributes:      parseAttributesCSV(attributesCSV),
341	})
342	presetsIn.Next()
343}
344
345// ClearPresetQueue discards every not-yet-minted preset, resetting the
346// queue to empty. Owner-only. Already-minted tokens are unaffected —
347// this only touches what Mint would hand out next.
348func ClearPresetQueue(cur realm) {
349	assertOwner()
350	presets = avl.Tree{}
351	presetsIn = 0
352	presetsOut = 0
353}
354
355/* -------------------------- Token-owner actions --------------------------- */
356
357func Approve(cur realm, to address, tid grc721.TokenID) {
358	caller := callerAddress()
359	checkErr(nft.Approve(caller, to, tid))
360}
361
362func SetApprovalForAll(cur realm, operator address, approved bool) {
363	caller := callerAddress()
364	checkErr(nft.SetApprovalForAll(caller, operator, approved))
365}
366
367func TransferFrom(cur realm, from, to address, tid grc721.TokenID) {
368	caller := callerAddress()
369	checkErr(nft.TransferFrom(caller, from, to, tid))
370}
371
372func SafeTransferFrom(cur realm, from, to address, tid grc721.TokenID) {
373	caller := callerAddress()
374	checkErr(nft.SafeTransferFrom(caller, from, to, tid))
375}
376
377// tokenOwnerAsCaller looks up tid's current owner so it can be passed as
378// the "caller" grc721's metadata methods expect — they only check
379// caller == token-owner, with no separate notion of "collection owner".
380// Every metadata-editing function below asserts collection ownership
381// itself first, then uses this to satisfy that inner check: the result
382// is that only the collection owner can edit metadata, never whoever
383// happens to hold the token, which is the whole point of a preset,
384// non-customizable collection.
385func tokenOwnerAsCaller(tid grc721.TokenID) address {
386	owner, err := nft.OwnerOf(tid)
387	checkErr(err)
388	return owner
389}
390
391// SetTokenMetadata overwrites a token's on-chain metadata wholesale.
392// Owner-only (see tokenOwnerAsCaller) — not callable by whoever holds
393// the token. Takes the full grc721.Metadata struct, so — unlike
394// UpdateTokenMetadata below — it can only be invoked programmatically
395// (e.g. from another realm), not via a plain gnokey/MsgCall transaction;
396// see Mint's doc comment.
397func SetTokenMetadata(cur realm, tid grc721.TokenID, metadata grc721.Metadata) {
398	assertOwner()
399	checkErr(nft.SetTokenMetadata(tokenOwnerAsCaller(tid), tid, metadata))
400}
401
402// UpdateTokenMetadata rewrites the name/description/image/externalURL
403// fields of a token's on-chain metadata, preserving its Attributes and
404// other fields. Owner-only (see tokenOwnerAsCaller).
405func UpdateTokenMetadata(cur realm, tid grc721.TokenID, name, description, image, externalURL string) {
406	assertOwner()
407	metadata, err := nft.TokenMetadata(tid)
408	checkErr(err)
409	metadata.Name = name
410	metadata.Description = description
411	metadata.Image = image
412	metadata.ExternalURL = externalURL
413	checkErr(nft.SetTokenMetadata(tokenOwnerAsCaller(tid), tid, metadata))
414}
415
416// AddTokenAttribute appends one trait to a token's on-chain metadata.
417// Owner-only (see tokenOwnerAsCaller).
418func AddTokenAttribute(cur realm, tid grc721.TokenID, traitType, value, displayType string) {
419	assertOwner()
420	metadata, err := nft.TokenMetadata(tid)
421	checkErr(err)
422	metadata.Attributes = append(metadata.Attributes, grc721.Trait{
423		TraitType:   traitType,
424		Value:       value,
425		DisplayType: displayType,
426	})
427	checkErr(nft.SetTokenMetadata(tokenOwnerAsCaller(tid), tid, metadata))
428}
429
430// ClearTokenAttributes removes all traits from a token's on-chain
431// metadata. Owner-only (see tokenOwnerAsCaller).
432func ClearTokenAttributes(cur realm, tid grc721.TokenID) {
433	assertOwner()
434	metadata, err := nft.TokenMetadata(tid)
435	checkErr(err)
436	metadata.Attributes = nil
437	checkErr(nft.SetTokenMetadata(tokenOwnerAsCaller(tid), tid, metadata))
438}
439
440// SetTokenURI sets an (optional) off-chain pointer alongside the
441// on-chain metadata. Owner-only (see tokenOwnerAsCaller).
442func SetTokenURI(cur realm, tid grc721.TokenID, uri string) {
443	assertOwner()
444	_, err := nft.SetTokenURI(tokenOwnerAsCaller(tid), tid, grc721.TokenURI(uri))
445	checkErr(err)
446}
447
448// Burn destroys a token. Caller must be its current owner.
449func Burn(cur realm, tid grc721.TokenID) {
450	caller := callerAddress()
451	owner, err := nft.OwnerOf(tid)
452	checkErr(err)
453	if caller != owner {
454		panic(grc721.ErrCallerIsNotOwner)
455	}
456	checkErr(nft.Burn(tid))
457}
458
459/* --------------------------------- Render --------------------------------- */
460
461func Render(path string) string {
462	if path == "" {
463		var b strings.Builder
464		b.WriteString(nft.RenderHome())
465		b.WriteString(ufmt.Sprintf("* **Owner**: %s\n", Owner()))
466		b.WriteString(ufmt.Sprintf("* **Presets queued**: %d\n", PresetQueueLength()))
467		if MaxSupply > 0 {
468			b.WriteString(ufmt.Sprintf("* **Max supply**: %d\n", MaxSupply))
469		}
470		return b.String()
471	}
472
473	parts := strings.Split(path, "/")
474	if len(parts) == 2 && parts[0] == "token" {
475		tid := grc721.TokenID(parts[1])
476		owner, err := nft.OwnerOf(tid)
477		if err != nil {
478			return "404\n"
479		}
480		metadata, _ := nft.TokenMetadata(tid)
481		var b strings.Builder
482		b.WriteString(ufmt.Sprintf("# %s #%s\n\n", nft.Name(), tid.String()))
483		b.WriteString(ufmt.Sprintf("* **Owner**: %s\n", owner))
484		if metadata.Name != "" {
485			b.WriteString(ufmt.Sprintf("* **Name**: %s\n", metadata.Name))
486		}
487		if metadata.Description != "" {
488			b.WriteString(ufmt.Sprintf("* **Description**: %s\n", metadata.Description))
489		}
490		return b.String()
491	}
492
493	return "404\n"
494}
495
496func checkErr(err error) {
497	if err != nil {
498		panic(err)
499	}
500}