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}