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

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](%s)](%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("![%s](%s)\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}