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

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}