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

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}