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

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}