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

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