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

26.41 Kb · 926 lines
  1package stable
  2
  3import (
  4	runtime "chain/runtime"
  5	unsaferealm "chain/runtime/unsafe"
  6	"encoding/base64"
  7	"strconv"
  8	"strings"
  9
 10	"gno.land/p/g1wx60nkcp5lfsxmedeg7sfs47tzxkath05u63t4/avl"
 11	grc721 "gno.land/p/g1wx60nkcp5lfsxmedeg7sfs47tzxkath05u63t4/grc721v2"
 12	campaign "gno.land/r/g1wx60nkcp5lfsxmedeg7sfs47tzxkath05u63t4/trialmint/campaign"
 13	artpkg "gno.land/p/g1wx60nkcp5lfsxmedeg7sfs47tzxkath05u63t4/art3d"
 14	"gno.land/p/nt/ufmt/v0"
 15)
 16
 17const (
 18	CollectionName   = "Trial Mint"
 19	CollectionSymbol = "TRIAL"
 20
 21	realmRelPath = "/r/g1wx60nkcp5lfsxmedeg7sfs47tzxkath05u63t4/trialmint/stable"
 22
 23	summaryPageSize = 10
 24	gnowebListLimit = 10
 25	pageWindow         = 2
 26	tokenPageImageSize = "420"
 27
 28	RevealDelayBlocks = 5
 29)
 30
 31var (
 32	nft *grc721.MetadataNFT
 33
 34	collectionOwner address = "g1wx60nkcp5lfsxmedeg7sfs47tzxkath05u63t4"
 35
 36	approvedVersion string
 37
 38	mintPriceUgnot int64 = 1000000000000 // 100 GNOT, per request
 39
 40	whitelistBps avl.Tree
 41
 42	royaltyBps       int64   = 500
 43	royaltyRecipient address = "g1wx60nkcp5lfsxmedeg7sfs47tzxkath05u63t4"
 44
 45	mintedIDs []grc721.TokenID
 46
 47	mintedTraitCombos avl.Tree // combo key -> true
 48	tokenComboKeys    avl.Tree // tid.String() -> comboKey string
 49	mintCountByWallet avl.Tree // address.String() -> int64
 50
 51	mintingOpenMirror       = true
 52	maxMintsPerWalletMirror int64 = 0
 53
 54	burnedRecords avl.Tree // tid.String() -> burnRecord
 55	burnedIDs     []grc721.TokenID
 56
 57	claimRecords []claimRecord
 58
 59	sealedData avl.Tree // tid.String() -> traitPreset
 60	mintHeight avl.Tree // tid.String() -> int64
 61
 62	sealedImage string
 63)
 64
 65type traitPreset struct {
 66	Chain    string
 67	ShapeIdx int
 68	ColorIdx int
 69	Metal    string
 70	Active   bool
 71	Rarity   string // tier fixed at mint by the version realm (v5.rarityTier)
 72}
 73
 74type claimRecord struct {
 75	Wallet     address
 76	Bps        int64
 77	MintHeight int64
 78	Tid        grc721.TokenID
 79}
 80
 81type burnRecord struct {
 82	BurnedBy   address
 83	BurnHeight int64
 84	Metadata   grc721.Metadata
 85}
 86
 87func init(cur realm) {
 88	nft = grc721.NewNFTWithMetadata(0, cur, CollectionName, CollectionSymbol)
 89	svg := `<svg xmlns="http://www.w3.org/2000/svg" width="170" height="170" viewBox="0 0 170 170">` +
 90		`<rect width="170" height="170" fill="#0a0b10"/>` +
 91		`<circle cx="85" cy="78" r="34" fill="none" stroke="#555" stroke-width="4"/>` +
 92		`<text x="85" y="83" font-size="12" text-anchor="middle" fill="#999" font-family="monospace">SEALED</text>` +
 93		`</svg>`
 94	sealedImage = "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(svg))
 95}
 96
 97func Name() string      { return CollectionName }
 98func Symbol() string    { return CollectionSymbol }
 99func TokenCount() int64 { return nft.TokenCount() }
100
101func OwnerOf(tid grc721.TokenID) (address, error)            { return nft.OwnerOf(tid) }
102func BalanceOf(owner address) (int64, error)                 { return nft.BalanceOf(owner) }
103func GetApproved(tid grc721.TokenID) (address, error)        { return nft.GetApproved(tid) }
104func IsApprovedForAll(owner, operator address) bool          { return nft.IsApprovedForAll(owner, operator) }
105
106func ApprovedVersion() string { return approvedVersion }
107func Owner() address          { return collectionOwner }
108func MintPrice() int64        { return mintPriceUgnot }
109
110func MintedIDs() []grc721.TokenID { return mintedIDs }
111func TotalBurned() int64          { return int64(len(burnedIDs)) }
112
113func MintingOpen() bool        { return mintingOpenMirror }
114func MaxMintsPerWallet() int64 { return maxMintsPerWalletMirror }
115
116func MintsByWallet(owner address) int64 {
117	n, _ := mintCountByWallet.Get(owner.String()).(int64)
118	return n
119}
120
121func IsTraitComboAvailable(comboKey string) bool {
122	return !mintedTraitCombos.Has(comboKey)
123}
124
125func WhitelistBps(wallet address) (bps int64, ok bool) {
126	if !whitelistBps.Has(wallet.String()) {
127		return 0, false
128	}
129	return whitelistBps.Get(wallet.String()).(int64), true
130}
131
132func WhitelistCount() int64 { return int64(whitelistBps.Size()) }
133
134func WhitelistEntry(index int) (wallet string, bps int64) {
135	w, v := whitelistBps.GetByIndex(index)
136	return w, v.(int64)
137}
138
139func EffectivePrice(caller address) int64 {
140	if v, ok := whitelistBps.Get(caller.String()).(int64); ok {
141		return mintPriceUgnot * v / 10000
142	}
143	if bps, _, ok := campaign.Peek(caller); ok {
144		return mintPriceUgnot * bps / 10000
145	}
146	return mintPriceUgnot
147}
148
149func RoyaltyInfo(tid grc721.TokenID, salePrice int64) (address, int64, error) {
150	if _, err := nft.OwnerOf(tid); err != nil {
151		return "", 0, err
152	}
153	return royaltyRecipient, salePrice * royaltyBps / 10000, nil
154}
155
156func RoyaltyBps() int64         { return royaltyBps }
157func RoyaltyRecipient() address { return royaltyRecipient }
158
159func callerAddress() address {
160	return unsaferealm.PreviousRealm().Address()
161}
162
163func assertOwner() {
164	if callerAddress() != collectionOwner {
165		panic("unauthorized: caller is not the collection owner")
166	}
167}
168
169func SetApprovedVersion(cur realm, pkgpath string) {
170	assertOwner()
171	approvedVersion = pkgpath
172}
173
174func SetMintPrice(cur realm, ugnot int64) {
175	assertOwner()
176	if ugnot < 0 {
177		panic("price cannot be negative")
178	}
179	mintPriceUgnot = ugnot
180}
181
182func AddToWhitelist(cur realm, wallet address, bps int64) {
183	assertOwner()
184	if bps < 0 || bps > 10000 {
185		panic("bps must be between 0 and 10000")
186	}
187	whitelistBps.Set(wallet.String(), bps)
188}
189
190func AddToWhitelistBatch(cur realm, walletsCSV string, bps int64) {
191	assertOwner()
192	if bps < 0 || bps > 10000 {
193		panic("bps must be between 0 and 10000")
194	}
195	for _, w := range strings.Split(walletsCSV, ",") {
196		w = strings.TrimSpace(w)
197		if w == "" {
198			continue
199		}
200		whitelistBps.Set(w, bps)
201	}
202}
203
204func RemoveFromWhitelist(cur realm, wallet address) {
205	assertOwner()
206	whitelistBps.Remove(wallet.String())
207}
208
209func SetRoyaltyBps(cur realm, bps int64) {
210	assertOwner()
211	if bps < 0 || bps > 10000 {
212		panic("bps must be between 0 and 10000")
213	}
214	royaltyBps = bps
215}
216
217func SetRoyaltyRecipient(cur realm, recipient address) {
218	assertOwner()
219	royaltyRecipient = recipient
220}
221
222func assertCallerIsApprovedVersion(cur realm) {
223	caller := cur.Previous().PkgPath()
224	if approvedVersion == "" {
225		panic("no version realm has been approved yet")
226	}
227	if caller != approvedVersion {
228		panic("unauthorized: caller is not the approved version realm")
229	}
230}
231
232func 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 {
233	assertCallerIsApprovedVersion(cur)
234	if mintedTraitCombos.Has(comboKey) {
235		panic("trait combination already minted")
236	}
237	checkErr(nft.Mint(to, tid))
238	checkErr(nft.SetTokenMetadata(to, tid, grc721.Metadata{
239		Name:       "Sealed",
240		Attributes: []grc721.Trait{{TraitType: "Status", Value: "Sealed"}},
241	}))
242	mintedTraitCombos.Set(comboKey, true)
243	tokenComboKeys.Set(tid.String(), comboKey)
244	mintCountByWallet.Set(to.String(), MintsByWallet(to)+1)
245	mintedIDs = append(mintedIDs, tid)
246
247	height := runtime.ChainHeight()
248	mintHeight.Set(tid.String(), height)
249	sealedData.Set(tid.String(), traitPreset{
250		Chain: chainName, ShapeIdx: shapeIdx, ColorIdx: colorIdx, Metal: metal, Active: active, Rarity: rarity,
251	})
252
253	if _, hasOverride := whitelistBps.Get(payer.String()).(int64); !hasOverride {
254		if bps, ok := campaign.ConsumeDiscount(cross(cur), payer); ok {
255			claimRecords = append(claimRecords, claimRecord{
256				Wallet:     payer,
257				Bps:        bps,
258				MintHeight: height,
259				Tid:        tid,
260			})
261		}
262	}
263	return tid
264}
265
266func ClaimCount() int64 { return int64(len(claimRecords)) }
267
268func ClaimAt(index int) (wallet address, bps int64, mintHeight_ int64, tid string) {
269	r := claimRecords[index]
270	return r.Wallet, r.Bps, r.MintHeight, r.Tid.String()
271}
272
273func ReportMintingOpen(cur realm, open bool) {
274	assertCallerIsApprovedVersion(cur)
275	mintingOpenMirror = open
276}
277
278func ReportMaxMintsPerWallet(cur realm, max int64) {
279	assertCallerIsApprovedVersion(cur)
280	maxMintsPerWalletMirror = max
281}
282
283func Age(tid grc721.TokenID) int64 {
284	h := mintHeight.Get(tid.String())
285	if h == nil {
286		return -1
287	}
288	return runtime.ChainHeight() - h.(int64)
289}
290
291func IsRevealed(tid grc721.TokenID) bool {
292	if _, isTraitPreset := sealedData.Get(tid.String()).(traitPreset); !isTraitPreset {
293		return true
294	}
295	age := Age(tid)
296	return age >= 0 && age >= RevealDelayBlocks
297}
298
299func currentMetadata(tid grc721.TokenID) (grc721.Metadata, error) {
300	stored, err := nft.TokenMetadata(tid)
301	if err != nil {
302		return stored, err
303	}
304	tp, hadReveal := sealedData.Get(tid.String()).(traitPreset)
305	if !hadReveal {
306		return stored, nil
307	}
308	if !IsRevealed(tid) {
309		stored.Image = sealedImage // still sealed: shared placeholder, not per-token storage
310		return stored, nil
311	}
312	return assembledMetadata(tid, tp), nil
313}
314
315func listRow(tid grc721.TokenID) (name, image string) {
316	tp, ok := sealedData.Get(tid.String()).(traitPreset)
317	if !ok {
318		m, _ := nft.TokenMetadata(tid)
319		return m.Name, m.Image
320	}
321	if !IsRevealed(tid) {
322		return "Sealed", sealedImage
323	}
324	name = traitOnlyMetadata(tp).Name
325	svg := artpkg.RenderTokenThumbSVG(tp.Chain, tp.Metal, tp.ShapeIdx, tp.ColorIdx, tp.Active, tid.String())
326	return name, "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(svg))
327}
328
329func assembledMetadata(tid grc721.TokenID, tp traitPreset) grc721.Metadata {
330	svg, stone, _, colorName := artpkg.RenderTokenSVG(tp.Chain, tp.Metal, tp.ShapeIdx, tp.ColorIdx, tp.Active, tid.String())
331	shapeName := artpkg.ShapeNames[tp.ShapeIdx]
332	if stone == "Pearl" {
333		shapeName = artpkg.PearlShapeNames[tp.ShapeIdx]
334	}
335	image := "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(svg))
336	status := "Sunset"
337	if tp.Active {
338		status = "Active"
339	}
340	return grc721.Metadata{
341		Name:            pieceDisplayName(colorName, stone, shapeName, tp.Metal, tp.Chain),
342		Description:     ufmt.Sprintf("%s %s %s set in a %s ring, minted on %s.", article(colorName), colorName, shapeName, tp.Metal, tp.Chain),
343		Image:           image,
344		BackgroundColor: "0a0b10",
345		Attributes: []grc721.Trait{
346			{TraitType: "Chain", Value: tp.Chain},
347			{TraitType: "Stone", Value: stone},
348			{TraitType: "Shape", Value: shapeName},
349			{TraitType: "Color", Value: colorName},
350			{TraitType: "Ring", Value: tp.Metal},
351			{TraitType: "Status", Value: status},
352			{TraitType: "Rarity", Value: tp.Rarity},
353		},
354	}
355}
356
357func article(word string) string {
358	if word != "" && strings.ContainsRune("AEIOUaeiou", rune(word[0])) {
359		return "An"
360	}
361	return "A"
362}
363
364func pieceDisplayName(colorName, stone, shape, metal, chain string) string {
365	piece := colorName + " " + stone + " " + shape
366	if stone == "Quartz" || stone == "" {
367		piece = colorName + " " + shape
368	}
369	return piece + " · " + metal + " Ring · " + chain
370}
371
372func colorNameFor(stone string, colorIdx int) string {
373	pairs := strings.Split(artpkg.ColorPaletteCSV(stone), ";")
374	if colorIdx < 0 || colorIdx >= len(pairs) {
375		return ""
376	}
377	parts := strings.SplitN(pairs[colorIdx], ":", 2)
378	if len(parts) != 2 {
379		return ""
380	}
381	return parts[1]
382}
383
384func traitOnlyMetadata(tp traitPreset) grc721.Metadata {
385	stone := artpkg.StoneForChain(tp.Chain)
386	shapeName := artpkg.ShapeNames[tp.ShapeIdx]
387	if stone == "Pearl" {
388		shapeName = artpkg.PearlShapeNames[tp.ShapeIdx]
389	}
390	colorName := colorNameFor(stone, tp.ColorIdx)
391	status := "Sunset"
392	if tp.Active {
393		status = "Active"
394	}
395	return grc721.Metadata{
396		Name:        pieceDisplayName(colorName, stone, shapeName, tp.Metal, tp.Chain),
397		Description: ufmt.Sprintf("%s %s %s set in a %s ring, minted on %s.", article(colorName), colorName, shapeName, tp.Metal, tp.Chain),
398		Attributes: []grc721.Trait{
399			{TraitType: "Chain", Value: tp.Chain},
400			{TraitType: "Stone", Value: stone},
401			{TraitType: "Shape", Value: shapeName},
402			{TraitType: "Color", Value: colorName},
403			{TraitType: "Ring", Value: tp.Metal},
404			{TraitType: "Status", Value: status},
405			{TraitType: "Rarity", Value: tp.Rarity},
406		},
407	}
408}
409
410func TokenMetadata(tid grc721.TokenID) (grc721.Metadata, error) { return currentMetadata(tid) }
411
412func TokenURI(tid grc721.TokenID) (string, error) {
413	metadata, err := currentMetadata(tid)
414	if err != nil {
415		return "", err
416	}
417	return metadataDataURI(metadata), nil
418}
419
420func jsonEscape(s string) string {
421	var b strings.Builder
422	for i := 0; i < len(s); i++ {
423		c := s[i]
424		switch c {
425		case '"':
426			b.WriteString(`\"`)
427		case '\\':
428			b.WriteString(`\\`)
429		default:
430			b.WriteByte(c)
431		}
432	}
433	return b.String()
434}
435
436func metadataDataURI(m grc721.Metadata) string {
437	image := m.Image
438	if image == "" {
439		image = m.ImageData
440	}
441	var b strings.Builder
442	b.WriteString(`{"name":"`)
443	b.WriteString(jsonEscape(m.Name))
444	b.WriteString(`","description":"`)
445	b.WriteString(jsonEscape(m.Description))
446	b.WriteString(`","image":"`)
447	b.WriteString(jsonEscape(image))
448	b.WriteString(`"`)
449	if m.BackgroundColor != "" {
450		b.WriteString(`,"background_color":"`)
451		b.WriteString(jsonEscape(m.BackgroundColor))
452		b.WriteString(`"`)
453	}
454	b.WriteString(`,"attributes":[`)
455	for i, t := range m.Attributes {
456		if i > 0 {
457			b.WriteString(",")
458		}
459		b.WriteString(`{"trait_type":"`)
460		b.WriteString(jsonEscape(t.TraitType))
461		b.WriteString(`","value":"`)
462		b.WriteString(jsonEscape(t.Value))
463		b.WriteString(`"}`)
464	}
465	b.WriteString("]}")
466	return "data:application/json;base64," + base64.StdEncoding.EncodeToString([]byte(b.String()))
467}
468
469func Approve(cur realm, to address, tid grc721.TokenID) {
470	checkErr(nft.Approve(callerAddress(), to, tid))
471}
472
473func SetApprovalForAll(cur realm, operator address, approved bool) {
474	checkErr(nft.SetApprovalForAll(callerAddress(), operator, approved))
475}
476
477func TransferFrom(cur realm, from, to address, tid grc721.TokenID) {
478	checkErr(nft.TransferFrom(callerAddress(), from, to, tid))
479}
480
481func SafeTransferFrom(cur realm, from, to address, tid grc721.TokenID) {
482	checkErr(nft.SafeTransferFrom(callerAddress(), from, to, tid))
483}
484
485func Burn(cur realm, tid grc721.TokenID) {
486	caller := callerAddress()
487	owner, err := nft.OwnerOf(tid)
488	checkErr(err)
489	if caller != owner {
490		panic(grc721.ErrCallerIsNotOwner)
491	}
492
493	key := tid.String()
494	var archived grc721.Metadata
495	if tp, ok := sealedData.Get(key).(traitPreset); ok {
496		archived = traitOnlyMetadata(tp)
497	}
498
499	if comboKey, ok := tokenComboKeys.Get(key).(string); ok {
500		mintedTraitCombos.Remove(comboKey)
501		tokenComboKeys.Remove(key)
502	}
503	sealedData.Remove(key)
504	mintHeight.Remove(key)
505
506	checkErr(nft.Burn(tid))
507
508	burnedRecords.Set(key, burnRecord{
509		BurnedBy:   caller,
510		BurnHeight: runtime.ChainHeight(),
511		Metadata:   archived,
512	})
513	burnedIDs = append(burnedIDs, tid)
514}
515
516func traitsToCSV(attrs []grc721.Trait) string {
517	pairs := make([]string, len(attrs))
518	for i, a := range attrs {
519		pairs[i] = a.TraitType + "=" + a.Value
520	}
521	return strings.Join(pairs, ";")
522}
523
524func TokenSummary(tid grc721.TokenID) (string, error) { return tokenSummary(tid, false) }
525
526func thumbSummary(tid grc721.TokenID) (string, error) { return tokenSummary(tid, true) }
527
528func tokenSummary(tid grc721.TokenID, thumb bool) (string, error) {
529	owner, err := nft.OwnerOf(tid)
530	if err != nil {
531		return "", err
532	}
533	var metadata grc721.Metadata
534	if tp, ok := sealedData.Get(tid.String()).(traitPreset); thumb && ok && IsRevealed(tid) {
535		stored, _ := nft.TokenMetadata(tid)
536		metadata = traitOnlyMetadata(tp)
537		metadata.ExternalURL = stored.ExternalURL
538		metadata.BackgroundColor = "0a0b10"
539		svg := artpkg.RenderTokenThumbSVG(tp.Chain, tp.Metal, tp.ShapeIdx, tp.ColorIdx, tp.Active, tid.String())
540		metadata.Image = "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(svg))
541	} else {
542		metadata, err = currentMetadata(tid)
543		if err != nil {
544			return "", err
545		}
546	}
547	enc := base64.StdEncoding
548	fields := []string{
549		tid.String(),
550		owner.String(),
551		enc.EncodeToString([]byte(metadata.Name)),
552		enc.EncodeToString([]byte(metadata.Description)),
553		enc.EncodeToString([]byte(metadata.Image)),
554		enc.EncodeToString([]byte(metadata.ExternalURL)),
555		enc.EncodeToString([]byte(traitsToCSV(metadata.Attributes))),
556		enc.EncodeToString([]byte(metadata.BackgroundColor)),
557	}
558	return strings.Join(fields, "|"), nil
559}
560
561func CollectionSummary(page int) string {
562	total := len(mintedIDs)
563	if page < 1 {
564		page = 1
565	}
566	startK := (page - 1) * summaryPageSize
567	endK := startK + summaryPageSize
568	if endK > total {
569		endK = total
570	}
571	var lines []string
572	for k := startK; k < endK; k++ {
573		tid := mintedIDs[total-1-k]
574		line, err := thumbSummary(tid)
575		if err != nil {
576			continue
577		}
578		lines = append(lines, line)
579	}
580	return strings.Join(lines, "\n")
581}
582
583const (
584	walletScanChunk = 100
585	walletRenderCap = summaryPageSize
586)
587
588func WalletSummary(owner address, startIdx int64) (summary string, nextIdx int64) {
589	total := len(mintedIDs)
590	start := int(startIdx)
591	if start < 0 {
592		start = 0
593	}
594	end := start + walletScanChunk
595	if end > total {
596		end = total
597	}
598	var lines []string
599	k := start
600	for ; k < end; k++ {
601		if len(lines) >= walletRenderCap {
602			break
603		}
604		tid := mintedIDs[total-1-k]
605		tokenOwner, err := nft.OwnerOf(tid)
606		if err != nil || tokenOwner != owner {
607			continue
608		}
609		line, err := thumbSummary(tid)
610		if err != nil {
611			continue
612		}
613		lines = append(lines, line)
614	}
615	return strings.Join(lines, "\n"), int64(k)
616}
617
618func BurnedTokenSummary(tid grc721.TokenID) (string, error) {
619	raw := burnedRecords.Get(tid.String())
620	rec, ok := raw.(burnRecord)
621	if !ok {
622		return "", grc721.ErrInvalidTokenId
623	}
624	enc := base64.StdEncoding
625	fields := []string{
626		tid.String(),
627		rec.BurnedBy.String(),
628		ufmt.Sprintf("%d", rec.BurnHeight),
629		enc.EncodeToString([]byte(rec.Metadata.Name)),
630		enc.EncodeToString([]byte(rec.Metadata.Description)),
631		enc.EncodeToString([]byte(rec.Metadata.Image)),
632		enc.EncodeToString([]byte(rec.Metadata.ExternalURL)),
633		enc.EncodeToString([]byte(traitsToCSV(rec.Metadata.Attributes))),
634		enc.EncodeToString([]byte(rec.Metadata.BackgroundColor)),
635	}
636	return strings.Join(fields, "|"), nil
637}
638
639func bigTokenImage(image string) string {
640	const prefix = "data:image/svg+xml;base64,"
641	if !strings.HasPrefix(image, prefix) {
642		return image
643	}
644	raw, err := base64.StdEncoding.DecodeString(image[len(prefix):])
645	if err != nil {
646		return image
647	}
648	svg := string(raw)
649	resized := strings.Replace(svg, `width="170.00" height="170.00"`, `width="`+tokenPageImageSize+`" height="`+tokenPageImageSize+`"`, 1)
650	if resized == svg {
651		resized = strings.Replace(svg, `width="170" height="170"`, `width="`+tokenPageImageSize+`" height="`+tokenPageImageSize+`"`, 1)
652	}
653	return prefix + base64.StdEncoding.EncodeToString([]byte(resized))
654}
655
656func renderMintedListPage(b *strings.Builder, page int) {
657	total := len(mintedIDs)
658	if total == 0 {
659		return
660	}
661	totalPages := (total + gnowebListLimit - 1) / gnowebListLimit
662	if page < 1 {
663		page = 1
664	}
665	if page > totalPages {
666		page = totalPages
667	}
668	startK := (page - 1) * gnowebListLimit
669	endK := startK + gnowebListLimit
670	if endK > total {
671		endK = total
672	}
673
674	b.WriteString("\n## Minted so far\n\n")
675	if totalPages > 1 {
676		b.WriteString(ufmt.Sprintf("_page %d of %d -- %d minted total_\n\n", page, totalPages, total))
677	}
678	b.WriteString("| Image | Index | Name | Owner |\n|---|---|---|---|\n")
679	for k := startK; k < endK; k++ {
680		tid := mintedIDs[total-1-k]
681		link := realmRelPath + ":token/" + tid.String()
682		owner, err := nft.OwnerOf(tid)
683		if err != nil {
684			raw := burnedRecords.Get(tid.String())
685			rec, ok := raw.(burnRecord)
686			if !ok {
687				continue // shouldn't happen: burned but no archive record
688			}
689			name := rec.Metadata.Name
690			if name == "" {
691				name = "Untitled"
692			}
693			b.WriteString(ufmt.Sprintf("| Burned | [#%s](%s) | %s (burned at block %d) | %s |\n", tid.String(), link, name, rec.BurnHeight, rec.BurnedBy))
694			continue
695		}
696		name, image := listRow(tid)
697		imgCell := ""
698		if image != "" {
699			imgCell = ufmt.Sprintf("[![%s](%s)](%s)", name, image, link)
700		}
701		b.WriteString(ufmt.Sprintf("| %s | [#%s](%s) | %s | %s |\n", imgCell, tid.String(), link, name, owner))
702	}
703	b.WriteString("\n")
704
705	renderPageLinks(b, page, totalPages, "page")
706}
707
708func renderPageLinks(b *strings.Builder, page, totalPages int, linkBase string) {
709	if totalPages <= 1 {
710		return
711	}
712	b.WriteString("\n")
713	parts := []string{}
714	if page > 1 {
715		parts = append(parts, ufmt.Sprintf("[<< First](%s:%s/1)", realmRelPath, linkBase))
716		parts = append(parts, ufmt.Sprintf("[<- newer](%s:%s/%d)", realmRelPath, linkBase, page-1))
717	}
718	lastShown := 0
719	for p := 1; p <= totalPages; p++ {
720		if p != 1 && p != totalPages && (p < page-pageWindow || p > page+pageWindow) {
721			continue
722		}
723		if lastShown != 0 && p != lastShown+1 {
724			parts = append(parts, "...")
725		}
726		if p == page {
727			parts = append(parts, ufmt.Sprintf("**%d**", p))
728		} else {
729			parts = append(parts, ufmt.Sprintf("[%d](%s:%s/%d)", p, realmRelPath, linkBase, p))
730		}
731		lastShown = p
732	}
733	if page < totalPages {
734		parts = append(parts, ufmt.Sprintf("[older ->](%s:%s/%d)", realmRelPath, linkBase, page+1))
735		parts = append(parts, ufmt.Sprintf("[Last >>](%s:%s/%d)", realmRelPath, linkBase, totalPages))
736	}
737	b.WriteString(strings.Join(parts, " - "))
738	b.WriteString("\n")
739}
740
741func renderBurnedListPage(b *strings.Builder, page int) {
742	total := len(burnedIDs)
743	if total == 0 {
744		return
745	}
746	totalPages := (total + gnowebListLimit - 1) / gnowebListLimit
747	if page < 1 {
748		page = 1
749	}
750	if page > totalPages {
751		page = totalPages
752	}
753	startK := (page - 1) * gnowebListLimit
754	endK := startK + gnowebListLimit
755	if endK > total {
756		endK = total
757	}
758
759	b.WriteString("\n## Burned tokens\n\n")
760	if totalPages > 1 {
761		b.WriteString(ufmt.Sprintf("_page %d of %d -- %d burned total_\n\n", page, totalPages, total))
762	}
763	b.WriteString("| Index | Name | Burned By | Block |\n|---|---|---|---|\n")
764	for k := startK; k < endK; k++ {
765		tid := burnedIDs[total-1-k]
766		raw := burnedRecords.Get(tid.String())
767		rec, ok := raw.(burnRecord)
768		if !ok {
769			continue
770		}
771		name := rec.Metadata.Name
772		if name == "" {
773			name = "Untitled"
774		}
775		b.WriteString(ufmt.Sprintf("| #%s | %s | %s | %d |\n", tid.String(), name, rec.BurnedBy, rec.BurnHeight))
776	}
777	b.WriteString("\n")
778
779	renderPageLinks(b, page, totalPages, "burned/page")
780}
781
782func renderBurnedTokenPage(tid grc721.TokenID) string {
783	raw := burnedRecords.Get(tid.String())
784	rec, ok := raw.(burnRecord)
785	if !ok {
786		return render404()
787	}
788	name := rec.Metadata.Name
789	if name == "" {
790		name = "Untitled"
791	}
792	var b strings.Builder
793	b.WriteString(ufmt.Sprintf("# %s #%s (burned)\n\n[<- back to collection](%s)\n\n", nft.Name(), tid.String(), realmRelPath))
794	b.WriteString("## Details\n\n")
795	b.WriteString(ufmt.Sprintf("* **Collection**: %s\n", nft.Name()))
796	b.WriteString(ufmt.Sprintf("* **Name**: %s\n", name))
797	b.WriteString(ufmt.Sprintf("* **Index**: #%s\n", tid.String()))
798	if rec.Metadata.Description != "" {
799		b.WriteString(ufmt.Sprintf("* **Description**: %s\n", rec.Metadata.Description))
800	}
801	b.WriteString(ufmt.Sprintf("* **Status**: burned by %s at block %d\n", rec.BurnedBy, rec.BurnHeight))
802	b.WriteString(ufmt.Sprintf("* **Archive**: [all burned tokens](%s:burned)\n", realmRelPath))
803	if len(rec.Metadata.Attributes) > 0 {
804		b.WriteString("\n## Traits\n\n")
805		for _, attr := range rec.Metadata.Attributes {
806			b.WriteString(ufmt.Sprintf("* **%s**: %s\n", attr.TraitType, attr.Value))
807		}
808	}
809	return b.String()
810}
811
812func render404() string {
813	return ufmt.Sprintf("# 404\n\n[<- back to collection](%s)\n", realmRelPath)
814}
815
816func Render(path string) string {
817	if path == "" {
818		var b strings.Builder
819		b.WriteString(nft.RenderHome())
820		b.WriteString("\n## About\n\n")
821		b.WriteString(CollectionName + ": each piece's Chain trait names a gno.land chain generation, and its stone is that chain's real stone -- Topaz, Sapphire, Pearl, Diamond for mainnet, Quartz for the testnets without one. Only real cuts and colours; every image is drawn on-chain. Delayed-reveal mint (real metadata sealed for a short delay after minting).\n\n")
822		b.WriteString(ufmt.Sprintf("* **Owner**: %s\n", Owner()))
823		b.WriteString(ufmt.Sprintf("* **Assembled trait combinations minted**: %d\n", int64(mintedTraitCombos.Size())))
824		b.WriteString("* **Max supply**: unlimited\n")
825		if mintingOpenMirror {
826			b.WriteString("* **Minting**: open\n")
827		} else {
828			b.WriteString("* **Minting**: closed\n")
829		}
830		if mintPriceUgnot > 0 {
831			b.WriteString(ufmt.Sprintf("* **Mint price**: %d ugnot\n", mintPriceUgnot))
832		} else {
833			b.WriteString("* **Mint price**: free\n")
834		}
835		b.WriteString(ufmt.Sprintf("* **Reveal delay**: %d block(s)\n", RevealDelayBlocks))
836		if maxMintsPerWalletMirror > 0 {
837			b.WriteString(ufmt.Sprintf("* **Max mints per wallet**: %d\n", maxMintsPerWalletMirror))
838		} else {
839			b.WriteString("* **Max mints per wallet**: unlimited\n")
840		}
841		if royaltyBps > 0 {
842			b.WriteString(ufmt.Sprintf("* **Secondary sale royalty**: %d.%02d%% (EIP-2981 RoyaltyInfo)\n", royaltyBps/100, royaltyBps%100))
843		}
844		if len(burnedIDs) > 0 {
845			b.WriteString(ufmt.Sprintf("* **Burned**: %d -- [view burned tokens ->](%s:burned)\n", len(burnedIDs), realmRelPath))
846		}
847
848		renderMintedListPage(&b, 1)
849		return b.String()
850	}
851
852	parts := strings.Split(path, "/")
853	if len(parts) == 2 && parts[0] == "page" {
854		page, err := strconv.Atoi(parts[1])
855		if err != nil || page < 1 {
856			return render404()
857		}
858		var b strings.Builder
859		b.WriteString(ufmt.Sprintf("# %s -- minted tokens\n\n[<- back to collection](%s)\n", nft.Name(), realmRelPath))
860		renderMintedListPage(&b, page)
861		return b.String()
862	}
863	if parts[0] == "burned" {
864		page := 1
865		if len(parts) == 3 && parts[1] == "page" {
866			p, err := strconv.Atoi(parts[2])
867			if err != nil || p < 1 {
868				return render404()
869			}
870			page = p
871		} else if len(parts) != 1 {
872			return render404()
873		}
874		var b strings.Builder
875		b.WriteString(ufmt.Sprintf("# %s -- burned tokens\n\n[<- back to collection](%s)\n", nft.Name(), realmRelPath))
876		renderBurnedListPage(&b, page)
877		return b.String()
878	}
879	if len(parts) == 2 && parts[0] == "token" {
880		tid := grc721.TokenID(parts[1])
881		owner, err := nft.OwnerOf(tid)
882		if err != nil {
883			return renderBurnedTokenPage(tid)
884		}
885		metadata, _ := currentMetadata(tid)
886		var b strings.Builder
887		b.WriteString(ufmt.Sprintf("# %s #%s\n\n[<- back to collection](%s)\n\n", nft.Name(), tid.String(), realmRelPath))
888		image := metadata.Image
889		if image == "" {
890			image = metadata.ImageData
891		}
892		name := metadata.Name
893		if name == "" {
894			name = "Untitled"
895		}
896		if image != "" {
897			b.WriteString(ufmt.Sprintf("![%s](%s)\n\n", name, bigTokenImage(image)))
898		}
899
900		b.WriteString("## Details\n\n")
901		b.WriteString(ufmt.Sprintf("* **Collection**: %s\n", nft.Name()))
902		b.WriteString(ufmt.Sprintf("* **Name**: %s\n", name))
903		b.WriteString(ufmt.Sprintf("* **Index**: #%s\n", tid.String()))
904		if metadata.Description != "" {
905			b.WriteString(ufmt.Sprintf("* **Description**: %s\n", metadata.Description))
906		}
907		b.WriteString(ufmt.Sprintf("* **Owner**: %s\n", owner))
908		b.WriteString(ufmt.Sprintf("* **Revealed**: %t (age %d block(s))\n", IsRevealed(tid), Age(tid)))
909
910		if len(metadata.Attributes) > 0 {
911			b.WriteString("\n## Traits\n\n")
912			for _, attr := range metadata.Attributes {
913				b.WriteString(ufmt.Sprintf("* **%s**: %s\n", attr.TraitType, attr.Value))
914			}
915		}
916		return b.String()
917	}
918
919	return render404()
920}
921
922func checkErr(err error) {
923	if err != nil {
924		panic(err)
925	}
926}