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)", 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("\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}