stable.gno
40.20 Kb · 1178 lines
1// Package stablev4 is the permanent half of the gems6v2 split-realm
2// design (stablev3 plus two fixes from the 2026-09-15 Pearl test run:
3// a burned token's own page, and no per-token copy of the sealed image) -- a candidate successor to the real gems6/stable, built to
4// prove two things together before either goes anywhere near mainnet:
5// a delayed-reveal mint (fixing the OutOfGasError problem the real
6// gems6/v1's instant-reveal MintAssembled has against Adena's own gas
7// estimation -- confirmed on a throwaway single-realm spike,
8// gno.land/r/g1gn6t0q9wenwhdda47rkrpfd63kcxjvyp7eqwku/revealpoc,
9// before this real split-realm version was attempted), and the
10// approved gemsartfab art package (crystal-look repaint + a real Rose
11// Cut cascade fix + a facet-merge seam fix on Round Brilliant/Oval).
12//
13// Deployed under a test address (not the vesting wallet) so this whole
14// prototype -- stable, campaign, v2, and gemsartfab itself -- is
15// self-contained and doesn't need the real collection's own keys to
16// validate. Folding a confirmed-good design into the real mainnet
17// deploy is a separate step once this proves out.
18//
19// Reveal mechanism: MintAssembled records a trait selection and a
20// SEALED placeholder image immediately (cheap, see RecordMint's own
21// doc comment for why this is the whole point). TokenURI/TokenMetadata
22// compute the real image via gemsartfab -- but ONLY once
23// RevealDelayBlocks has passed -- through currentMetadata, which is
24// the one addition to the real stable's own read path. This DOES mean
25// this realm, unlike the real gems6/stable, imports an art package
26// directly: unavoidable given gno's static imports (the realm serving
27// TokenURI has to know how to render, and rendering can't be deferred
28// to "whichever realm is currently approved" the way minting can,
29// since a future version's own art package isn't importable before it
30// exists). Matches the tradeoff the older g20 collection already
31// accepted for the exact same reason -- not a new problem introduced
32// here.
33//
34// Everything else (ownership, whitelist, royalty, claim log, campaign
35// wiring) is unchanged from the real gems6/stable -- see that
36// package's own doc comments for the full design history.
37//
38// Ledger: the standard gno.land/p/nt/grc721/v0 (2026-09-26), not a
39// vendored grc721. Adena lists only collections whose NewToken/Transfer
40// events come from that exact package (see ~/gno-land-dev-notes.md), and
41// chain.Emit stamps the emitting package, so this cannot be retrofitted
42// later. The package stores ownership only; every token's metadata is
43// computed here from its traits (sealedData), never stored per token.
44// TokenURI returns the image itself (Adena puts it straight into an
45// <img>); TokenMetadata returns the metadata as a JSON string, and
46// TokenJSON the classic base64 JSON data URI for marketplaces that want it.
47package stable
48
49import (
50 runtime "chain/runtime"
51 unsaferealm "chain/runtime/unsafe"
52 "encoding/base64"
53 "strconv"
54 "strings"
55
56 "gno.land/p/nt/avl/v0"
57 "gno.land/p/nt/grc721/v0"
58 "gno.land/p/nt/seqid/v0"
59 campaign "gno.land/r/g17cjym5e9hhws46lt6329pv2gtx2ay0503hgems/gems/campaign"
60 gemsart "gno.land/p/g17cjym5e9hhws46lt6329pv2gtx2ay0503hgems/gemsart3d2"
61 "gno.land/p/nt/ufmt/v0"
62)
63
64const (
65 CollectionName = "GNO Gems"
66 CollectionSymbol = "GEMS"
67
68 // realmRelPath: this realm's own gnoweb-relative path, baked in as
69 // a compile-time constant -- see the real gems6/stable's own
70 // comment on why (unsaferealm.CurrentRealm() resolves differently
71 // nested inside Render than called directly). Bumped for this
72 // deployment, per the gemsartfab handoff's own reminder that this
73 // gets missed.
74 realmRelPath = "/r/g17cjym5e9hhws46lt6329pv2gtx2ay0503hgems/gems/stable"
75
76 // 6, not 10: every read has a 3,000,000,000 gas ceiling (vm
77 // maxGasQuery) and a 3D token renders for up to ~265M (Cushion); 8
78 // renders plus page overhead ran out of gas on a local chain, 6 leave
79 // about half the budget spare
80 // summaryPageSize: tokens per CollectionSummary / WalletSummary call
81 // (the mint site's grids). Thumbnail renders, like the gnoweb table;
82 // TokenSummary of one token keeps the full art.
83 summaryPageSize = 10
84 // gnowebListLimit: rows per page of the gnoweb collection table. Rows
85 // use the art's thumbnail render (~145-165M gas, vs ~245-265M full),
86 // so 10 fit well under the 3B query ceiling; the token page and the
87 // summaries the mint site reads keep the full art.
88 gnowebListLimit = 10
89 pageWindow = 2
90 tokenPageImageSize = "420"
91
92 // RevealDelayBlocks: kept short so this candidate design can be
93 // tested end-to-end in one sitting -- a real production value
94 // would be tuned separately before mainnet.
95 RevealDelayBlocks = 5
96)
97
98var (
99 token *grc721.Token
100 ledger *grc721.PrivateLedger // private: only this realm mints, burns and moves tokens
101
102 collectionOwner address = "g17cjym5e9hhws46lt6329pv2gtx2ay0503hgems"
103
104 approvedVersion string
105
106 mintPriceUgnot int64 = 100000000 // 100 GNOT, per request
107
108 whitelistBps avl.Tree
109
110 royaltyBps int64 = 500
111 royaltyRecipient address = "g17cjym5e9hhws46lt6329pv2gtx2ay0503hgems"
112
113 mintedIDs []grc721.TokenID
114
115 mintedTraitCombos avl.Tree // combo key -> true
116 tokenComboKeys avl.Tree // tid.String() -> comboKey string
117 mintCountByWallet avl.Tree // address.String() -> int64
118
119 mintingOpenMirror = true
120 maxMintsPerWalletMirror int64 = 0
121
122 burnedRecords avl.Tree // tid.String() -> burnRecord
123 burnedIDs []grc721.TokenID
124
125 claimRecords []claimRecord
126
127 // sealedData/mintHeight: the reveal mechanism's own state. tid ->
128 // its locked-in trait selection / the block it was minted at.
129 // Deleted from both on Burn (releases their storage deposit, same
130 // as tokenComboKeys already does) and never touched again once a
131 // token is revealed -- IsRevealed keeps working after that point
132 // purely from Age, no separate "already revealed" flag needed.
133 sealedData avl.Tree // tid.String() -> traitPreset
134 mintHeight avl.Tree // tid.String() -> int64
135
136 sealedImage string
137)
138
139// traitPreset is one token's locked-in trait selection -- everything
140// gemsartfab needs to render the real image once revealed. Owned here
141// (not imported from whichever realm minted it) since stable is the
142// one place this needs to survive any future mint-mechanics version
143// swap.
144type traitPreset struct {
145 Chain string
146 ShapeIdx int
147 ColorIdx int
148 Metal string
149 Active bool
150 Rarity string // tier fixed at mint by the version realm (v5.rarityTier)
151}
152
153type claimRecord struct {
154 Wallet address
155 Bps int64
156 MintHeight int64
157 Tid grc721.TokenID
158}
159
160// burnRecord keeps a burned token's compact trait selection, not its
161// expanded name/description/attribute strings: the text is rebuilt on read
162// (traitOnlyMetadata), so a burn frees more storage deposit.
163type burnRecord struct {
164 BurnedBy address
165 BurnHeight int64
166 Traits traitPreset
167}
168
169func (r burnRecord) metadata() Metadata {
170 if r.Traits.Chain == "" {
171 return Metadata{Name: "Untitled"}
172 }
173 return traitOnlyMetadata(r.Traits)
174}
175
176func init(cur realm) {
177 // emits NewToken; the token id becomes "<this realm>.<symbol>.0000000"
178 token, ledger = grc721.NewToken(CollectionName, CollectionSymbol, seqid.ID(0), cur)
179 svg := `<svg xmlns="http://www.w3.org/2000/svg" width="170" height="170" viewBox="0 0 170 170">` +
180 `<rect width="170" height="170" fill="#0a0b10"/>` +
181 `<circle cx="85" cy="78" r="34" fill="none" stroke="#555" stroke-width="4"/>` +
182 `<text x="85" y="83" font-size="12" text-anchor="middle" fill="#999" font-family="monospace">SEALED</text>` +
183 `</svg>`
184 sealedImage = "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(svg))
185}
186
187// Metadata/Trait: the shape every reader here builds (grc721/v0 keeps no
188// metadata of its own). Never stored per token -- see currentMetadata.
189type Trait struct {
190 TraitType string
191 Value string
192}
193
194type Metadata struct {
195 Name string
196 Description string
197 Image string
198 BackgroundColor string
199 Attributes []Trait
200}
201
202/* ------------------------------ standard reads ------------------------------ */
203
204func Name() string { return CollectionName }
205func Symbol() string { return CollectionSymbol }
206func TokenCount() int64 { return token.TotalSupply() }
207func TokenID() string { return token.ID() }
208
209// GetName/GetSymbol: the Teller-style names some tools look for.
210func GetName() string { return CollectionName }
211func GetSymbol() string { return CollectionSymbol }
212
213func OwnerOf(tid grc721.TokenID) (address, error) { return token.OwnerOf(tid) }
214func BalanceOf(owner address) (int64, error) { return token.BalanceOf(owner) }
215func GetApproved(tid grc721.TokenID) (address, error) { return token.GetApproved(tid) }
216func IsApprovedForAll(owner, operator address) bool { return token.IsApprovedForAll(owner, operator) }
217
218func ApprovedVersion() string { return approvedVersion }
219func Owner() address { return collectionOwner }
220func MintPrice() int64 { return mintPriceUgnot }
221
222func MintedIDs() []grc721.TokenID { return mintedIDs }
223func TotalBurned() int64 { return int64(len(burnedIDs)) }
224
225func MintingOpen() bool { return mintingOpenMirror }
226func MaxMintsPerWallet() int64 { return maxMintsPerWalletMirror }
227
228func MintsByWallet(owner address) int64 {
229 n, _ := mintCountByWallet.Get(owner.String()).(int64)
230 return n
231}
232
233func IsTraitComboAvailable(comboKey string) bool {
234 return !mintedTraitCombos.Has(comboKey)
235}
236
237func WhitelistBps(wallet address) (bps int64, ok bool) {
238 if !whitelistBps.Has(wallet.String()) {
239 return 0, false
240 }
241 return whitelistBps.Get(wallet.String()).(int64), true
242}
243
244func WhitelistCount() int64 { return int64(whitelistBps.Size()) }
245
246func WhitelistEntry(index int) (wallet string, bps int64) {
247 w, v := whitelistBps.GetByIndex(index)
248 return w, v.(int64)
249}
250
251func EffectivePrice(caller address) int64 {
252 if v, ok := whitelistBps.Get(caller.String()).(int64); ok {
253 return mintPriceUgnot * v / 10000
254 }
255 if bps, _, ok := campaign.Peek(caller); ok {
256 return mintPriceUgnot * bps / 10000
257 }
258 return mintPriceUgnot
259}
260
261func RoyaltyInfo(tid grc721.TokenID, salePrice int64) (address, int64, error) {
262 if _, err := token.OwnerOf(tid); err != nil {
263 return "", 0, err
264 }
265 return royaltyRecipient, salePrice * royaltyBps / 10000, nil
266}
267
268func RoyaltyBps() int64 { return royaltyBps }
269func RoyaltyRecipient() address { return royaltyRecipient }
270
271/* -------------------------------- owner-gated -------------------------------- */
272
273func callerAddress() address {
274 return unsaferealm.PreviousRealm().Address()
275}
276
277func assertOwner() {
278 if callerAddress() != collectionOwner {
279 panic("unauthorized: caller is not the collection owner")
280 }
281}
282
283func SetApprovedVersion(cur realm, pkgpath string) {
284 assertOwner()
285 approvedVersion = pkgpath
286}
287
288func SetMintPrice(cur realm, ugnot int64) {
289 assertOwner()
290 if ugnot < 0 {
291 panic("price cannot be negative")
292 }
293 mintPriceUgnot = ugnot
294}
295
296func AddToWhitelist(cur realm, wallet address, bps int64) {
297 assertOwner()
298 if bps < 0 || bps > 10000 {
299 panic("bps must be between 0 and 10000")
300 }
301 whitelistBps.Set(wallet.String(), bps)
302}
303
304func AddToWhitelistBatch(cur realm, walletsCSV string, bps int64) {
305 assertOwner()
306 if bps < 0 || bps > 10000 {
307 panic("bps must be between 0 and 10000")
308 }
309 for _, w := range strings.Split(walletsCSV, ",") {
310 w = strings.TrimSpace(w)
311 if w == "" {
312 continue
313 }
314 whitelistBps.Set(w, bps)
315 }
316}
317
318func RemoveFromWhitelist(cur realm, wallet address) {
319 assertOwner()
320 whitelistBps.Remove(wallet.String())
321}
322
323func SetRoyaltyBps(cur realm, bps int64) {
324 assertOwner()
325 if bps < 0 || bps > 10000 {
326 panic("bps must be between 0 and 10000")
327 }
328 royaltyBps = bps
329}
330
331func SetRoyaltyRecipient(cur realm, recipient address) {
332 assertOwner()
333 royaltyRecipient = recipient
334}
335
336/* ------------------------------- version-gated -------------------------------- */
337
338func assertCallerIsApprovedVersion(cur realm) {
339 caller := cur.Previous().PkgPath()
340 if approvedVersion == "" {
341 panic("no version realm has been approved yet")
342 }
343 if caller != approvedVersion {
344 panic("unauthorized: caller is not the approved version realm")
345 }
346}
347
348// RecordMint: the ENTIRE point of this v2 candidate. Unlike the real
349// gems6/stable's own RecordMint, this does NOT take a full, rendered
350// grc721.Metadata -- it takes the raw trait fields instead, and writes
351// a small fixed-size SEALED placeholder to nft's own storage. The real
352// image is never computed here, or anywhere, during a mint transaction
353// -- see currentMetadata for where it actually happens (a plain read,
354// unbound by any tx-level gas-wanted). Only the approved version realm
355// may call this. Reserves comboKey atomically with the mint, same
356// authoritative-check role the real stable's RecordMint plays.
357func RecordMint(cur realm, to address, payer address, tid grc721.TokenID, chainName string, shapeIdx, colorIdx int, metal string, active bool, comboKey string, rarity string) grc721.TokenID {
358 assertCallerIsApprovedVersion(cur)
359 if mintedTraitCombos.Has(comboKey) {
360 panic("trait combination already minted")
361 }
362 // Ownership only: no metadata is stored per token, not even a
363 // "Sealed" record -- currentMetadata derives it from sealedData.
364 checkErr(ledger.Mint(to, tid))
365 mintedTraitCombos.Set(comboKey, true)
366 tokenComboKeys.Set(tid.String(), comboKey)
367 mintCountByWallet.Set(to.String(), MintsByWallet(to)+1)
368 mintedIDs = append(mintedIDs, tid)
369
370 height := runtime.ChainHeight()
371 mintHeight.Set(tid.String(), height)
372 sealedData.Set(tid.String(), traitPreset{
373 Chain: chainName, ShapeIdx: shapeIdx, ColorIdx: colorIdx, Metal: metal, Active: active, Rarity: rarity,
374 })
375
376 if _, hasOverride := whitelistBps.Get(payer.String()).(int64); !hasOverride {
377 if bps, ok := campaign.ConsumeDiscount(cross(cur), payer); ok {
378 claimRecords = append(claimRecords, claimRecord{
379 Wallet: payer,
380 Bps: bps,
381 MintHeight: height,
382 Tid: tid,
383 })
384 }
385 }
386 return tid
387}
388
389func ClaimCount() int64 { return int64(len(claimRecords)) }
390
391func ClaimAt(index int) (wallet address, bps int64, mintHeight_ int64, tid string) {
392 r := claimRecords[index]
393 return r.Wallet, r.Bps, r.MintHeight, r.Tid.String()
394}
395
396func ReportMintingOpen(cur realm, open bool) {
397 assertCallerIsApprovedVersion(cur)
398 mintingOpenMirror = open
399}
400
401func ReportMaxMintsPerWallet(cur realm, max int64) {
402 assertCallerIsApprovedVersion(cur)
403 maxMintsPerWalletMirror = max
404}
405
406/* -------------------------------- reveal logic ------------------------------- */
407
408// Age returns real blocks elapsed since tid was minted, or -1 if it
409// doesn't exist / was never sealed.
410func Age(tid grc721.TokenID) int64 {
411 h := mintHeight.Get(tid.String())
412 if h == nil {
413 return -1
414 }
415 return runtime.ChainHeight() - h.(int64)
416}
417
418// IsRevealed reports whether tid's real metadata is visible yet.
419func IsRevealed(tid grc721.TokenID) bool {
420 if _, isTraitPreset := sealedData.Get(tid.String()).(traitPreset); !isTraitPreset {
421 return true
422 }
423 age := Age(tid)
424 return age >= 0 && age >= RevealDelayBlocks
425}
426
427// currentMetadata is what every reader-facing function below actually
428// returns: nft's own stored (sealed) metadata before reveal, or the
429// real image assembled fresh from gemsartfab after -- computed on this
430// plain read, never inside a mint or any other gas-metered write. This
431// is the one addition to the real gems6/stable's own read path.
432func currentMetadata(tid grc721.TokenID) (Metadata, error) {
433 if _, err := token.OwnerOf(tid); err != nil {
434 return Metadata{}, err
435 }
436 tp, ok := sealedData.Get(tid.String()).(traitPreset)
437 if !ok {
438 return Metadata{}, grc721.ErrInvalidTokenId
439 }
440 if !IsRevealed(tid) {
441 return sealedMetadata(), nil
442 }
443 return assembledMetadata(tid, tp), nil
444}
445
446// sealedMetadata: what every token shows until its reveal block, the one
447// shared placeholder image (not per-token storage).
448func sealedMetadata() Metadata {
449 return Metadata{Name: "Sealed", Image: sealedImage, BackgroundColor: "0a0b10", Attributes: []Trait{{TraitType: "Status", Value: "Sealed"}}}
450}
451
452// listRow is one gnoweb table row's name and image: the name from the
453// traits (no render), the image the art's thumbnail render once revealed,
454// the shared sealed placeholder before.
455func listRow(tid grc721.TokenID) (name, image string) {
456 tp, ok := sealedData.Get(tid.String()).(traitPreset)
457 if !ok {
458 return "Untitled", ""
459 }
460 if !IsRevealed(tid) {
461 return "Sealed", sealedImage
462 }
463 name = traitOnlyMetadata(tp).Name
464 svg := gemsart.RenderTokenThumbSVG(tp.Chain, tp.Metal, tp.ShapeIdx, tp.ColorIdx, tp.Active, tid.String())
465 return name, "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(svg))
466}
467
468func assembledMetadata(tid grc721.TokenID, tp traitPreset) Metadata {
469 svg, stone, _, colorName := gemsart.RenderTokenSVG(tp.Chain, tp.Metal, tp.ShapeIdx, tp.ColorIdx, tp.Active, tid.String())
470 shapeName := gemsart.ShapeNames[tp.ShapeIdx]
471 if stone == "Pearl" {
472 shapeName = gemsart.PearlShapeNames[tp.ShapeIdx]
473 }
474 image := "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(svg))
475 status := "Sunset"
476 if tp.Active {
477 status = "Active"
478 }
479 return Metadata{
480 Name: gemDisplayName(colorName, stone, shapeName, tp.Metal, tp.Chain),
481 Description: ufmt.Sprintf("%s %s %s set in %s %s ring, minted on %s.", article(colorName), colorName, shapeName, strings.ToLower(article(tp.Metal)), tp.Metal, tp.Chain),
482 Image: image,
483 BackgroundColor: "0a0b10",
484 Attributes: []Trait{
485 {TraitType: "Chain", Value: tp.Chain},
486 {TraitType: "Stone", Value: stone},
487 {TraitType: "Shape", Value: shapeName},
488 {TraitType: "Color", Value: colorName},
489 {TraitType: "Ring", Value: tp.Metal},
490 {TraitType: "Status", Value: status},
491 {TraitType: "Rarity", Value: tp.Rarity},
492 },
493 }
494}
495
496// article is "An" before a vowel sound (Amethyst, Imperial, Orange), else "A".
497func article(word string) string {
498 if word != "" && strings.ContainsRune("AEIOUaeiou", rune(word[0])) {
499 return "An"
500 }
501 return "A"
502}
503
504func gemDisplayName(colorName, stone, shape, metal, chain string) string {
505 // "Amethyst Oval · Gold Ring · TEST-7": the piece, its setting, its
506 // chain, split by a middle dot (the old "X -- Y Ring - Z Chain" was a
507 // parsing delimiter that leaked into the name). Quartz colour names are
508 // the variety names and already say what the stone is (Amethyst,
509 // Citrine, Rose Quartz, Rock Crystal...): "Amethyst Oval", not
510 // "Amethyst Quartz Oval".
511 piece := colorName + " " + stone + " " + shape
512 if stone == "Quartz" || stone == "" {
513 piece = colorName + " " + shape
514 }
515 return piece + " · " + metal + " Ring · " + chain
516}
517
518// colorNameFor looks up a stone's colorIdx-th color name from
519// gemsart.ColorPaletteCSV ("hex:name;hex:name;...") -- a cheap parse,
520// not a render. Kept in sync with gemsart.RenderTokenSVG's own internal
521// colorForStone by construction (same palette source), without ever
522// calling RenderTokenSVG itself.
523func colorNameFor(stone string, colorIdx int) string {
524 pairs := strings.Split(gemsart.ColorPaletteCSV(stone), ";")
525 if colorIdx < 0 || colorIdx >= len(pairs) {
526 return ""
527 }
528 parts := strings.SplitN(pairs[colorIdx], ":", 2)
529 if len(parts) != 2 {
530 return ""
531 }
532 return parts[1]
533}
534
535// traitOnlyMetadata builds the archived record straight from tp's raw
536// fields -- no gemsart.RenderTokenSVG call, so no image, ever. This is
537// what Burn archives: the burn record was never meant to keep the
538// picture (see Burn's own comment), so there's no reason to pay the
539// render cost to build one just to immediately discard it.
540func traitOnlyMetadata(tp traitPreset) Metadata {
541 stone := gemsart.StoneForChain(tp.Chain)
542 shapeName := gemsart.ShapeNames[tp.ShapeIdx]
543 if stone == "Pearl" {
544 shapeName = gemsart.PearlShapeNames[tp.ShapeIdx]
545 }
546 colorName := colorNameFor(stone, tp.ColorIdx)
547 status := "Sunset"
548 if tp.Active {
549 status = "Active"
550 }
551 return Metadata{
552 Name: gemDisplayName(colorName, stone, shapeName, tp.Metal, tp.Chain),
553 Description: ufmt.Sprintf("%s %s %s set in %s %s ring, minted on %s.", article(colorName), colorName, shapeName, strings.ToLower(article(tp.Metal)), tp.Metal, tp.Chain),
554 BackgroundColor: "0a0b10",
555 Attributes: []Trait{
556 {TraitType: "Chain", Value: tp.Chain},
557 {TraitType: "Stone", Value: stone},
558 {TraitType: "Shape", Value: shapeName},
559 {TraitType: "Color", Value: colorName},
560 {TraitType: "Ring", Value: tp.Metal},
561 {TraitType: "Status", Value: status},
562 {TraitType: "Rarity", Value: tp.Rarity},
563 },
564 }
565}
566
567// TokenURI is the token's image itself, as a data URI: the sealed
568// placeholder until the reveal block, then the full art. Adena puts it
569// straight into an <img>, which also runs the art's CSS animation.
570func TokenURI(tid grc721.TokenID) (string, error) {
571 metadata, err := currentMetadata(tid)
572 if err != nil {
573 return "", err
574 }
575 return metadata.Image, nil
576}
577
578// TokenMetadata is the metadata as a JSON string in the camelCase shape
579// Adena reads (name, description, backgroundColor, attributes of
580// {traitType, value}). No image in it, so it costs no render: the image
581// is TokenURI.
582func TokenMetadata(tid grc721.TokenID) (string, error) {
583 if _, err := token.OwnerOf(tid); err != nil {
584 return "", err
585 }
586 m := sealedMetadata()
587 if tp, ok := sealedData.Get(tid.String()).(traitPreset); ok && IsRevealed(tid) {
588 m = traitOnlyMetadata(tp)
589 }
590 m.Image = ""
591 return metadataJSON(m, "traitType", "backgroundColor"), nil
592}
593
594// TokenJSON is the classic OpenSea-style metadata (snake_case keys, the
595// image inline) as a base64 JSON data URI, for marketplaces that expect a
596// tokenURI of that shape.
597func TokenJSON(tid grc721.TokenID) (string, error) {
598 metadata, err := currentMetadata(tid)
599 if err != nil {
600 return "", err
601 }
602 return "data:application/json;base64," + base64.StdEncoding.EncodeToString([]byte(metadataJSON(metadata, "trait_type", "background_color"))), nil
603}
604
605func jsonEscape(s string) string {
606 var b strings.Builder
607 for i := 0; i < len(s); i++ {
608 c := s[i]
609 switch c {
610 case '"':
611 b.WriteString(`\"`)
612 case '\\':
613 b.WriteString(`\\`)
614 default:
615 b.WriteByte(c)
616 }
617 }
618 return b.String()
619}
620
621func metadataJSON(m Metadata, traitKey, bgKey string) string {
622 var b strings.Builder
623 b.WriteString(`{"name":"`)
624 b.WriteString(jsonEscape(m.Name))
625 b.WriteString(`","description":"`)
626 b.WriteString(jsonEscape(m.Description))
627 b.WriteString(`"`)
628 if m.Image != "" {
629 b.WriteString(`,"image":"`)
630 b.WriteString(jsonEscape(m.Image))
631 b.WriteString(`"`)
632 }
633 if m.BackgroundColor != "" {
634 b.WriteString(`,"` + bgKey + `":"`)
635 b.WriteString(jsonEscape(m.BackgroundColor))
636 b.WriteString(`"`)
637 }
638 b.WriteString(`,"attributes":[`)
639 for i, t := range m.Attributes {
640 if i > 0 {
641 b.WriteString(",")
642 }
643 b.WriteString(`{"` + traitKey + `":"`)
644 b.WriteString(jsonEscape(t.TraitType))
645 b.WriteString(`","value":"`)
646 b.WriteString(jsonEscape(t.Value))
647 b.WriteString(`"}`)
648 }
649 b.WriteString("]}")
650 return b.String()
651}
652
653/* -------------------------- Token-owner actions --------------------------- */
654
655func Approve(cur realm, to address, tid grc721.TokenID) {
656 checkErr(ledger.Approve(callerAddress(), to, tid))
657}
658
659func SetApprovalForAll(cur realm, operator address, approved bool) {
660 checkErr(ledger.SetApprovalForAll(callerAddress(), operator, approved))
661}
662
663func TransferFrom(cur realm, from, to address, tid grc721.TokenID) {
664 checkErr(ledger.TransferFrom(callerAddress(), from, to, tid))
665}
666
667// SafeTransferFrom: kept for the standard signature. grc721/v0 has no
668// receiver-contract hook, so it is TransferFrom.
669func SafeTransferFrom(cur realm, from, to address, tid grc721.TokenID) {
670 checkErr(ledger.TransferFrom(callerAddress(), from, to, tid))
671}
672
673// Burn destroys a token. Caller must be its current owner. Archives the
674// real trait combination as text -- always the real traits, even if
675// burned before the reveal delay passed, since sealedData already has
676// them regardless of whether TokenURI has started showing them
677// publicly yet. Deliberately never renders or stores an image: the
678// archive was only ever a text record (see BurnedTokenSummary/
679// renderMintedListPage's own "Burned" row), so calling
680// gemsart.RenderTokenSVG here just to immediately discard its output
681// was pure waste -- confirmed live, a burn on an already-revealed token
682// cost ~165M gas, almost entirely that one re-render, roughly the same
683// cost the whole reveal-delay design exists to avoid paying on mint.
684// Releases the trait combo AND the reveal bookkeeping (sealedData/
685// mintHeight) -- maximizes the real storage-deposit refund, same
686// reasoning the real gems6/stable's own Burn already documents.
687func Burn(cur realm, tid grc721.TokenID) {
688 caller := callerAddress()
689 owner, err := token.OwnerOf(tid)
690 checkErr(err)
691 if caller != owner {
692 panic(grc721.ErrCallerIsNotOwner)
693 }
694
695 key := tid.String()
696 archived, _ := sealedData.Get(key).(traitPreset)
697
698 if comboKey, ok := tokenComboKeys.Get(key).(string); ok {
699 mintedTraitCombos.Remove(comboKey)
700 tokenComboKeys.Remove(key)
701 }
702 sealedData.Remove(key)
703 mintHeight.Remove(key)
704
705 checkErr(ledger.Burn(tid))
706
707 burnedRecords.Set(key, burnRecord{
708 BurnedBy: caller,
709 BurnHeight: runtime.ChainHeight(),
710 Traits: archived,
711 })
712 burnedIDs = append(burnedIDs, tid)
713}
714
715/* ------------------------------ gnoweb display ------------------------------ */
716
717func traitsToCSV(attrs []Trait) string {
718 pairs := make([]string, len(attrs))
719 for i, a := range attrs {
720 pairs[i] = a.TraitType + "=" + a.Value
721 }
722 return strings.Join(pairs, ";")
723}
724
725func TokenSummary(tid grc721.TokenID) (string, error) { return tokenSummary(tid, false) }
726
727// thumbSummary is TokenSummary with the art's thumbnail render and the
728// metadata built from the traits, so a page of them stays well under the
729// query gas ceiling.
730func thumbSummary(tid grc721.TokenID) (string, error) { return tokenSummary(tid, true) }
731
732func tokenSummary(tid grc721.TokenID, thumb bool) (string, error) {
733 owner, err := token.OwnerOf(tid)
734 if err != nil {
735 return "", err
736 }
737 var metadata Metadata
738 if tp, ok := sealedData.Get(tid.String()).(traitPreset); thumb && ok && IsRevealed(tid) {
739 metadata = traitOnlyMetadata(tp)
740 svg := gemsart.RenderTokenThumbSVG(tp.Chain, tp.Metal, tp.ShapeIdx, tp.ColorIdx, tp.Active, tid.String())
741 metadata.Image = "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(svg))
742 } else {
743 metadata, err = currentMetadata(tid)
744 if err != nil {
745 return "", err
746 }
747 }
748 enc := base64.StdEncoding
749 fields := []string{
750 tid.String(),
751 owner.String(),
752 enc.EncodeToString([]byte(metadata.Name)),
753 enc.EncodeToString([]byte(metadata.Description)),
754 enc.EncodeToString([]byte(metadata.Image)),
755 "", // external URL: none
756 enc.EncodeToString([]byte(traitsToCSV(metadata.Attributes))),
757 enc.EncodeToString([]byte(metadata.BackgroundColor)),
758 }
759 return strings.Join(fields, "|"), nil
760}
761
762func CollectionSummary(page int) string {
763 total := len(mintedIDs)
764 if page < 1 {
765 page = 1
766 }
767 startK := (page - 1) * summaryPageSize
768 endK := startK + summaryPageSize
769 if endK > total {
770 endK = total
771 }
772 var lines []string
773 for k := startK; k < endK; k++ {
774 tid := mintedIDs[total-1-k]
775 line, err := thumbSummary(tid)
776 if err != nil {
777 continue
778 }
779 lines = append(lines, line)
780 }
781 return strings.Join(lines, "\n")
782}
783
784// walletScanChunk bounds how many ids one WalletSummary call scans;
785// walletRenderCap bounds how many of the caller's OWN tokens it renders.
786// The second bound is the one that matters: every matched token is a
787// full SVG render (TokenSummary -> currentMetadata -> gemsart), and one
788// vm/qeval has a fixed gas budget -- a wallet holding 30 pieces made
789// the unbounded version fail outright with "out of gas in location:
790// CPUCycles" on Pearl (2026-09-16), taking the mint page's "Your
791// minted Gems" panel down for exactly the collectors who care most.
792// A client pages by calling again with the returned nextIdx until it
793// reaches the collection size, same protocol as before; it just gets
794// more, smaller pages for a big holder.
795const (
796 walletScanChunk = 100
797 walletRenderCap = summaryPageSize
798)
799
800func WalletSummary(owner address, startIdx int64) (summary string, nextIdx int64) {
801 total := len(mintedIDs)
802 start := int(startIdx)
803 if start < 0 {
804 start = 0
805 }
806 end := start + walletScanChunk
807 if end > total {
808 end = total
809 }
810 var lines []string
811 k := start
812 for ; k < end; k++ {
813 if len(lines) >= walletRenderCap {
814 break
815 }
816 tid := mintedIDs[total-1-k]
817 tokenOwner, err := token.OwnerOf(tid)
818 if err != nil || tokenOwner != owner {
819 continue
820 }
821 line, err := thumbSummary(tid)
822 if err != nil {
823 continue
824 }
825 lines = append(lines, line)
826 }
827 return strings.Join(lines, "\n"), int64(k)
828}
829
830func BurnedTokenSummary(tid grc721.TokenID) (string, error) {
831 raw := burnedRecords.Get(tid.String())
832 rec, ok := raw.(burnRecord)
833 if !ok {
834 return "", grc721.ErrInvalidTokenId
835 }
836 m := rec.metadata()
837 enc := base64.StdEncoding
838 fields := []string{
839 tid.String(),
840 rec.BurnedBy.String(),
841 ufmt.Sprintf("%d", rec.BurnHeight),
842 enc.EncodeToString([]byte(m.Name)),
843 enc.EncodeToString([]byte(m.Description)),
844 "", // image: a burn keeps none
845 "", // external URL: none
846 enc.EncodeToString([]byte(traitsToCSV(m.Attributes))),
847 enc.EncodeToString([]byte(m.BackgroundColor)),
848 }
849 return strings.Join(fields, "|"), nil
850}
851
852func bigTokenImage(image string) string {
853 const prefix = "data:image/svg+xml;base64,"
854 if !strings.HasPrefix(image, prefix) {
855 return image
856 }
857 raw, err := base64.StdEncoding.DecodeString(image[len(prefix):])
858 if err != nil {
859 return image
860 }
861 svg := string(raw)
862 resized := strings.Replace(svg, `width="170.00" height="170.00"`, `width="`+tokenPageImageSize+`" height="`+tokenPageImageSize+`"`, 1)
863 if resized == svg {
864 resized = strings.Replace(svg, `width="170" height="170"`, `width="`+tokenPageImageSize+`" height="`+tokenPageImageSize+`"`, 1)
865 }
866 return prefix + base64.StdEncoding.EncodeToString([]byte(resized))
867}
868
869// shortAddr is how lists show an address: g1 + 4 characters … last 4.
870func shortAddr(a string) string {
871 if len(a) <= 14 {
872 return a
873 }
874 return a[:6] + "…" + a[len(a)-4:]
875}
876
877func renderMintedListPage(b *strings.Builder, page int) {
878 total := len(mintedIDs)
879 if total == 0 {
880 return
881 }
882 totalPages := (total + gnowebListLimit - 1) / gnowebListLimit
883 if page < 1 {
884 page = 1
885 }
886 if page > totalPages {
887 page = totalPages
888 }
889 startK := (page - 1) * gnowebListLimit
890 endK := startK + gnowebListLimit
891 if endK > total {
892 endK = total
893 }
894
895 b.WriteString("\n## Minted so far\n\n")
896 if totalPages > 1 {
897 b.WriteString(ufmt.Sprintf("_page %d of %d -- %d minted total_\n\n", page, totalPages, total))
898 }
899 // Image column centred (so is a burned row's "Burned"). Owners are
900 // shortened to g1xxxx…xxxx: the full 40-character address made the
901 // Owner column so wide that gnoweb broke "Index" over two lines. The
902 // token's own page still shows the full address.
903 b.WriteString("| Image | Index | Name | Owner |\n|:---:|:---:|---|---|\n")
904 for k := startK; k < endK; k++ {
905 tid := mintedIDs[total-1-k]
906 link := realmRelPath + ":token/" + tid.String()
907 owner, err := token.OwnerOf(tid)
908 if err != nil {
909 // Burned -- stays in its original chronological position
910 // (never skipped: a listing that silently drops rows reads
911 // as broken, not as "nothing to see here" -- confirmed live
912 // against the real gems6/stable, where an all-burned page
913 // rendered as a blank table with no explanation). Pulled
914 // from the permanent burnedRecords archive, image always
915 // empty by design (see Burn's own doc comment) so "Burned"
916 // stands in for it. No wall-clock date is available inside
917 // a gno.land realm (only block height) -- shown as that
918 // instead of a fabricated date.
919 raw := burnedRecords.Get(tid.String())
920 rec, ok := raw.(burnRecord)
921 if !ok {
922 continue // shouldn't happen: burned but no archive record
923 }
924 name := rec.metadata().Name
925 b.WriteString(ufmt.Sprintf("| Burned | [#%s](%s) | %s (burned at block %d) | %s |\n", tid.String(), link, name, rec.BurnHeight, shortAddr(rec.BurnedBy.String())))
926 continue
927 }
928 name, image := listRow(tid)
929 imgCell := ""
930 if image != "" {
931 imgCell = ufmt.Sprintf("[](%s)", name, image, link)
932 }
933 b.WriteString(ufmt.Sprintf("| %s | [#%s](%s) | %s | %s |\n", imgCell, tid.String(), link, name, shortAddr(owner.String())))
934 }
935 b.WriteString("\n")
936
937 renderPageLinks(b, page, totalPages, "page")
938}
939
940func renderPageLinks(b *strings.Builder, page, totalPages int, linkBase string) {
941 if totalPages <= 1 {
942 return
943 }
944 b.WriteString("\n")
945 parts := []string{}
946 if page > 1 {
947 parts = append(parts, ufmt.Sprintf("[<< First](%s:%s/1)", realmRelPath, linkBase))
948 parts = append(parts, ufmt.Sprintf("[<- newer](%s:%s/%d)", realmRelPath, linkBase, page-1))
949 }
950 lastShown := 0
951 for p := 1; p <= totalPages; p++ {
952 if p != 1 && p != totalPages && (p < page-pageWindow || p > page+pageWindow) {
953 continue
954 }
955 if lastShown != 0 && p != lastShown+1 {
956 parts = append(parts, "...")
957 }
958 if p == page {
959 parts = append(parts, ufmt.Sprintf("**%d**", p))
960 } else {
961 parts = append(parts, ufmt.Sprintf("[%d](%s:%s/%d)", p, realmRelPath, linkBase, p))
962 }
963 lastShown = p
964 }
965 if page < totalPages {
966 parts = append(parts, ufmt.Sprintf("[older ->](%s:%s/%d)", realmRelPath, linkBase, page+1))
967 parts = append(parts, ufmt.Sprintf("[Last >>](%s:%s/%d)", realmRelPath, linkBase, totalPages))
968 }
969 b.WriteString(strings.Join(parts, " - "))
970 b.WriteString("\n")
971}
972
973func renderBurnedListPage(b *strings.Builder, page int) {
974 total := len(burnedIDs)
975 if total == 0 {
976 return
977 }
978 totalPages := (total + gnowebListLimit - 1) / gnowebListLimit
979 if page < 1 {
980 page = 1
981 }
982 if page > totalPages {
983 page = totalPages
984 }
985 startK := (page - 1) * gnowebListLimit
986 endK := startK + gnowebListLimit
987 if endK > total {
988 endK = total
989 }
990
991 b.WriteString("\n## Burned tokens\n\n")
992 if totalPages > 1 {
993 b.WriteString(ufmt.Sprintf("_page %d of %d -- %d burned total_\n\n", page, totalPages, total))
994 }
995 b.WriteString("| Index | Name | Burned By | Block |\n|:---:|---|---|---|\n")
996 for k := startK; k < endK; k++ {
997 tid := burnedIDs[total-1-k]
998 raw := burnedRecords.Get(tid.String())
999 rec, ok := raw.(burnRecord)
1000 if !ok {
1001 continue
1002 }
1003 name := rec.metadata().Name
1004 b.WriteString(ufmt.Sprintf("| #%s | %s | %s | %d |\n", tid.String(), name, shortAddr(rec.BurnedBy.String()), rec.BurnHeight))
1005 }
1006 b.WriteString("\n")
1007
1008 renderPageLinks(b, page, totalPages, "burned/page")
1009}
1010
1011// renderBurnedTokenPage is the token route for an id that no longer
1012// exists. If it was burned, its archived record -- name, traits, who
1013// burned it, at what block -- is shown: the minted list links every
1014// burned row here, and stablev3 answered that link with a 404, which
1015// read as a broken site (found in the 2026-09-15 Pearl test run).
1016// Anything else really is a 404.
1017func renderBurnedTokenPage(tid grc721.TokenID) string {
1018 raw := burnedRecords.Get(tid.String())
1019 rec, ok := raw.(burnRecord)
1020 if !ok {
1021 return render404()
1022 }
1023 m := rec.metadata()
1024 name := m.Name
1025 var b strings.Builder
1026 b.WriteString(ufmt.Sprintf("# %s #%s (burned)\n\n[<- back to collection](%s)\n\n", CollectionName, tid.String(), realmRelPath))
1027 b.WriteString("## Details\n\n")
1028 b.WriteString(ufmt.Sprintf("* **Collection**: %s\n", CollectionName))
1029 b.WriteString(ufmt.Sprintf("* **Name**: %s\n", name))
1030 b.WriteString(ufmt.Sprintf("* **Index**: #%s\n", tid.String()))
1031 if m.Description != "" {
1032 b.WriteString(ufmt.Sprintf("* **Description**: %s\n", m.Description))
1033 }
1034 b.WriteString(ufmt.Sprintf("* **Status**: burned by %s at block %d\n", rec.BurnedBy, rec.BurnHeight))
1035 b.WriteString(ufmt.Sprintf("* **Archive**: [all burned tokens](%s:burned)\n", realmRelPath))
1036 if len(m.Attributes) > 0 {
1037 b.WriteString("\n## Traits\n\n")
1038 for _, attr := range m.Attributes {
1039 b.WriteString(ufmt.Sprintf("* **%s**: %s\n", attr.TraitType, attr.Value))
1040 }
1041 }
1042 return b.String()
1043}
1044
1045// gnotString: ugnot as GNOT, trailing zeros trimmed (100000000 -> "100",
1046// 1500000 -> "1.5").
1047func gnotString(ugnot int64) string {
1048 whole, frac := ugnot/1_000_000, ugnot%1_000_000
1049 if frac == 0 {
1050 return strconv.FormatInt(whole, 10)
1051 }
1052 f := strconv.FormatInt(frac+1_000_000, 10)[1:]
1053 return strconv.FormatInt(whole, 10) + "." + strings.TrimRight(f, "0")
1054}
1055
1056// bpsPercent: basis points as a percentage (500 -> "5%", 250 -> "2.5%").
1057// ufmt ignores zero-padding, so "%d.%02d" printed 500 as "5.0%".
1058func bpsPercent(bps int64) string {
1059 if bps%100 == 0 {
1060 return strconv.FormatInt(bps/100, 10) + "%"
1061 }
1062 f := strconv.FormatInt(bps%100+100, 10)[1:]
1063 return strconv.FormatInt(bps/100, 10) + "." + strings.TrimRight(f, "0") + "%"
1064}
1065
1066func render404() string {
1067 return ufmt.Sprintf("# 404\n\n[<- back to collection](%s)\n", realmRelPath)
1068}
1069
1070func Render(path string) string {
1071 if path == "" {
1072 var b strings.Builder
1073 b.WriteString(token.RenderHome())
1074 b.WriteString("\n## About\n\n")
1075 b.WriteString(CollectionName + " are gemstones captured in an anti-gravity field. Each stone floats, suspended, at the heart of the ring around it -- gold, platinum, silver, bronze or obsidian -- which holds it in place and turns it into a display piece for the collector.\n\n")
1076 b.WriteString("The collection is inspired by the names of gno.land's chains. Every piece's Chain trait names a gno.land chain generation, and its stone is that chain's own: Topaz, Sapphire and Pearl for the testnets that carry those names, Diamond for mainnet, and Quartz for the testnets without one. Only real cuts and colours; every image is drawn on-chain. Delayed-reveal mint: each piece's traits stay sealed for a few blocks after it is minted.\n\n")
1077 b.WriteString(ufmt.Sprintf("* **Owner**: %s\n", Owner()))
1078 b.WriteString(ufmt.Sprintf("* **Assembled trait combinations minted**: %d\n", int64(mintedTraitCombos.Size())))
1079 b.WriteString("* **Max supply**: unlimited\n")
1080 if mintingOpenMirror {
1081 b.WriteString("* **Minting**: open\n")
1082 } else {
1083 b.WriteString("* **Minting**: closed\n")
1084 }
1085 if mintPriceUgnot > 0 {
1086 b.WriteString("* **Mint price**: " + gnotString(mintPriceUgnot) + " GNOT\n")
1087 } else {
1088 b.WriteString("* **Mint price**: free\n")
1089 }
1090 b.WriteString(ufmt.Sprintf("* **Reveal delay**: %d block(s)\n", RevealDelayBlocks))
1091 if maxMintsPerWalletMirror > 0 {
1092 b.WriteString(ufmt.Sprintf("* **Max mints per wallet**: %d\n", maxMintsPerWalletMirror))
1093 } else {
1094 b.WriteString("* **Max mints per wallet**: unlimited\n")
1095 }
1096 if royaltyBps > 0 {
1097 b.WriteString("* **Secondary sale royalty**: " + bpsPercent(royaltyBps) + " (EIP-2981 RoyaltyInfo)\n")
1098 }
1099 if len(burnedIDs) > 0 {
1100 b.WriteString(ufmt.Sprintf("* **Burned**: %d -- [view burned tokens ->](%s:burned)\n", len(burnedIDs), realmRelPath))
1101 }
1102
1103 renderMintedListPage(&b, 1)
1104 return b.String()
1105 }
1106
1107 parts := strings.Split(path, "/")
1108 if len(parts) == 2 && parts[0] == "page" {
1109 page, err := strconv.Atoi(parts[1])
1110 if err != nil || page < 1 {
1111 return render404()
1112 }
1113 var b strings.Builder
1114 b.WriteString(ufmt.Sprintf("# %s -- minted tokens\n\n[<- back to collection](%s)\n", CollectionName, realmRelPath))
1115 renderMintedListPage(&b, page)
1116 return b.String()
1117 }
1118 if parts[0] == "burned" {
1119 page := 1
1120 if len(parts) == 3 && parts[1] == "page" {
1121 p, err := strconv.Atoi(parts[2])
1122 if err != nil || p < 1 {
1123 return render404()
1124 }
1125 page = p
1126 } else if len(parts) != 1 {
1127 return render404()
1128 }
1129 var b strings.Builder
1130 b.WriteString(ufmt.Sprintf("# %s -- burned tokens\n\n[<- back to collection](%s)\n", CollectionName, realmRelPath))
1131 renderBurnedListPage(&b, page)
1132 return b.String()
1133 }
1134 if len(parts) == 2 && parts[0] == "token" {
1135 tid := grc721.TokenID(parts[1])
1136 owner, err := token.OwnerOf(tid)
1137 if err != nil {
1138 return renderBurnedTokenPage(tid)
1139 }
1140 metadata, _ := currentMetadata(tid)
1141 var b strings.Builder
1142 b.WriteString(ufmt.Sprintf("# %s #%s\n\n[<- back to collection](%s)\n\n", CollectionName, tid.String(), realmRelPath))
1143 image := metadata.Image
1144 name := metadata.Name
1145 if name == "" {
1146 name = "Untitled"
1147 }
1148 if image != "" {
1149 b.WriteString(ufmt.Sprintf("\n\n", name, bigTokenImage(image)))
1150 }
1151
1152 b.WriteString("## Details\n\n")
1153 b.WriteString(ufmt.Sprintf("* **Collection**: %s\n", CollectionName))
1154 b.WriteString(ufmt.Sprintf("* **Name**: %s\n", name))
1155 b.WriteString(ufmt.Sprintf("* **Index**: #%s\n", tid.String()))
1156 if metadata.Description != "" {
1157 b.WriteString(ufmt.Sprintf("* **Description**: %s\n", metadata.Description))
1158 }
1159 b.WriteString(ufmt.Sprintf("* **Owner**: %s\n", owner))
1160 b.WriteString(ufmt.Sprintf("* **Revealed**: %t (age %d block(s))\n", IsRevealed(tid), Age(tid)))
1161
1162 if len(metadata.Attributes) > 0 {
1163 b.WriteString("\n## Traits\n\n")
1164 for _, attr := range metadata.Attributes {
1165 b.WriteString(ufmt.Sprintf("* **%s**: %s\n", attr.TraitType, attr.Value))
1166 }
1167 }
1168 return b.String()
1169 }
1170
1171 return render404()
1172}
1173
1174func checkErr(err error) {
1175 if err != nil {
1176 panic(err)
1177 }
1178}