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