test.gno
17.94 Kb · 918 lines
1package test
2
3import (
4 "chain"
5 "chain/banker"
6 "chain/runtime/unsafe"
7 "strconv"
8 "strings"
9
10 "gno.land/p/g1n4pl5uc4yt5r96m9w6fmdznx3x0jyg8l6arhmt/bazaar/grc721/v0"
11 "gno.land/p/g1n4pl5uc4yt5r96m9w6fmdznx3x0jyg8l6arhmt/bazaar/grc721/metadata/v0"
12 "gno.land/p/nt/avl/v0"
13 "gno.land/p/nt/seqid/v0"
14 "gno.land/r/g1n4pl5uc4yt5r96m9w6fmdznx3x0jyg8l6arhmt/bazaar/factory"
15)
16
17const (
18 maxNameLen = 64
19 maxImageLen = 200
20 maxSupplyBound = 1_000_000
21 maxRoyaltyBps = 1000
22 nativeDenom = "ugnot"
23 publicOrigin = "https://bazaar-gno.vercel.app"
24 dropAddCap = 20
25 dropSlotHardCap = 10000
26 minSlugLen = 2
27)
28
29type item struct {
30 id int
31 name string
32 image string
33 owner address
34 seller address
35 price int64
36 listed bool
37 rarity string
38 traits string
39 revealed bool
40 slot int
41}
42
43type dropSlot struct {
44 name string
45 image string
46 rarity string
47 traits string
48}
49
50var (
51 items avl.Tree
52 dropSlots avl.Tree
53 token *grc721.Token
54 ledger *grc721.PrivateLedger
55 metaView *metadata.Metadata
56 metaLed *metadata.Ledger
57 tokenSeq seqid.ID
58 nextID int
59 minted int
60 loaded int
61 maxSupply int
62 mintPrice int64
63 royaltyBps int
64 paused bool
65 inited bool
66 creator address
67 self address
68 colName string
69 colSymbol string
70 colCover string
71 slug string
72)
73
74func Init(cur realm, name, symbol, cover string, maxSupplyN int, mintPriceUgnot int64, royalty int64) {
75 if !cur.Previous().IsUserCall() {
76 panic("col: EOA only")
77 }
78 if !cur.IsCurrent() {
79 panic("col: spoofed realm")
80 }
81 if len(symbol) < 1 || len(symbol) > grc721.MaxSymbolLen {
82 panic("col: symbol")
83 }
84 pathSlug := lastElem(cur.PkgPath())
85 if !validPkgName(pathSlug) {
86 panic("col: slug")
87 }
88 if maxSupplyN < 0 || maxSupplyN > maxSupplyBound {
89 panic("col: maxSupply")
90 }
91 if mintPriceUgnot < 0 {
92 panic("col: mintPrice")
93 }
94 if royalty < 0 || royalty > maxRoyaltyBps {
95 panic("col: royalty")
96 }
97 if !validName(name) {
98 panic("col: name")
99 }
100 if !validImage(cover) {
101 panic("col: cover")
102 }
103 if inited {
104 panic("col: already initialized")
105 }
106 slug = pathSlug
107 reservedWho := factory.ReservedCreator(slug)
108 takeLaunchFee(cur)
109 tok, led := grc721.NewToken(name, symbol, tokenSeq.Next(), cur)
110 token = tok
111 ledger = led
112 metaView, metaLed = metadata.NewMetadata(led)
113 creator = cur.Previous().Address()
114 self = cur.Address()
115 colName = name
116 colSymbol = symbol
117 colCover = cover
118 maxSupply = maxSupplyN
119 mintPrice = mintPriceUgnot
120 royaltyBps = int(royalty)
121 items = avl.Tree{}
122 dropSlots = avl.Tree{}
123 loaded = 0
124 inited = true
125 if reservedWho == "" {
126 factory.CreditLaunchFee(cross(cur))
127 }
128 factory.Register(cross(cur), name, symbol, cover, maxSupplyN, mintPriceUgnot, royalty)
129 chain.Emit("Init", "creator", creator.String(), "slug", slug)
130}
131
132func Mint(cur realm, name, imageURL string) int {
133 caller := requireUser(cur)
134 if caller != creator {
135 panic("col: not creator")
136 }
137 id := mintTo(cur, caller, name, imageURL)
138 writeTokenMeta(id)
139 chain.Emit("Mint", "id", strconv.Itoa(id), "owner", caller.String())
140 return id
141}
142
143func PublicMint(cur realm) int {
144 caller := requireUser(cur)
145 takeExact(mintPrice)
146 name := colName + " #" + strconv.Itoa(minted+1)
147 image := publicImage()
148 var slot *dropSlot
149 slotIndex := -1
150 if loaded > 0 {
151 if minted >= loaded {
152 panic("col: no slot")
153 }
154 v := dropSlots.Get(slotKey(minted))
155 if v == nil {
156 panic("col: no slot")
157 }
158 slot = v.(*dropSlot)
159 name = slot.name
160 image = slot.image
161 slotIndex = minted
162 }
163 id := mintTo(cur, caller, name, image)
164 if slot != nil {
165 applySlot(id, slot, slotIndex)
166 }
167 writeTokenMeta(id)
168 if mintPrice > 0 {
169 bk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
170 bk.SendCoins(self, creator, chain.Coins{{Denom: nativeDenom, Amount: mintPrice}})
171 }
172 chain.Emit("PublicMint", "id", strconv.Itoa(id), "n", strconv.Itoa(minted))
173 return id
174}
175
176func AddDropItems(cur realm, blob string) {
177 caller := requireUser(cur)
178 if caller != creator {
179 panic("col: not creator")
180 }
181 lines := splitDropLines(blob)
182 if len(lines) < 1 || len(lines) > dropAddCap {
183 panic("col: slots")
184 }
185 if loaded+len(lines) > slotCap() {
186 panic("col: slots")
187 }
188 for _, line := range lines {
189 name, image, rarity, traits := parseDropLine(line)
190 if !validName(name) {
191 panic("col: name")
192 }
193 if !validImage(image) {
194 panic("col: image")
195 }
196 if !validRarity(rarity) {
197 panic("col: rarity")
198 }
199 if !validTraits(traits) {
200 panic("col: traits")
201 }
202 dropSlots.Set(slotKey(loaded), &dropSlot{name: name, image: image, rarity: rarity, traits: traits})
203 loaded++
204 }
205 chain.Emit("AddDropItems", "slug", slug, "loaded", strconv.Itoa(loaded))
206}
207
208func ListDropSlots() string {
209 requireInit()
210 out := ""
211 for i := 0; i < loaded; i++ {
212 v := dropSlots.Get(slotKey(i))
213 if v == nil {
214 continue
215 }
216 s := v.(*dropSlot)
217 if out != "" {
218 out += "\n"
219 }
220 out += strconv.Itoa(i) + "|" + s.name + "|" + s.image + "|" + s.rarity + "|" + s.traits
221 }
222 return out
223}
224
225func LoadedOf() int {
226 requireInit()
227 return loaded
228}
229
230func PauseMint(cur realm) {
231 caller := requireUser(cur)
232 if caller != creator {
233 panic("col: not creator")
234 }
235 paused = true
236 chain.Emit("PauseMint", "slug", slug)
237}
238
239func ResumeMint(cur realm) {
240 caller := requireUser(cur)
241 if caller != creator {
242 panic("col: not creator")
243 }
244 paused = false
245 chain.Emit("ResumeMint", "slug", slug)
246}
247
248func OwnerOf(id int) address {
249 return mustItem(id).owner
250}
251
252func GrcName() string {
253 requireInit()
254 return token.GetName()
255}
256
257func GrcSymbol() string {
258 requireInit()
259 return token.GetSymbol()
260}
261
262func GrcBalanceOf(addr string) int64 {
263 requireInit()
264 who := address(addr)
265 if !who.IsValid() {
266 return 0
267 }
268 n, err := token.BalanceOf(who)
269 if err != nil {
270 return 0
271 }
272 return n
273}
274
275func GrcOwnerOf(id int) string {
276 requireInit()
277 owner, err := token.OwnerOf(itemTid(id))
278 if err != nil {
279 panic("col: owner")
280 }
281 return owner.String()
282}
283
284func Name() string {
285 return GrcName()
286}
287
288func Symbol() string {
289 return GrcSymbol()
290}
291
292func BalanceOf(addr string) int64 {
293 return GrcBalanceOf(addr)
294}
295
296func TokenURI(id int) string {
297 mustItem(id)
298 if metaView != nil {
299 u, err := metaView.TokenURI(itemTid(id))
300 if err == nil && u != "" {
301 return u
302 }
303 }
304 return tokenJSON(mustItem(id))
305}
306
307func guardUnlisted(id int) {
308 if mustItem(id).listed {
309 panic("col: listed")
310 }
311}
312
313func Approve(cur realm, to string, id int) {
314 caller := requireUser(cur)
315 guardUnlisted(id)
316 who := address(to)
317 if to != "" && !who.IsValid() {
318 panic("col: addr")
319 }
320 if err := ledger.Approve(caller, who, itemTid(id)); err != nil {
321 panic("col: approve")
322 }
323}
324
325func SetApprovalForAll(cur realm, operator string, approved bool) {
326 caller := requireUser(cur)
327 op := address(operator)
328 if !op.IsValid() {
329 panic("col: addr")
330 }
331 if err := ledger.SetApprovalForAll(caller, op, approved); err != nil {
332 panic("col: approve")
333 }
334}
335
336func TransferFrom(cur realm, from, to string, id int) {
337 caller := requireUser(cur)
338 guardUnlisted(id)
339 src := address(from)
340 dst := address(to)
341 if !src.IsValid() || !dst.IsValid() {
342 panic("col: addr")
343 }
344 if err := ledger.TransferFrom(caller, src, dst, itemTid(id)); err != nil {
345 panic("col: transfer")
346 }
347 it := mustItem(id)
348 it.owner = dst
349 items.Set(itemKey(id), it)
350}
351
352func GetApproved(id int) string {
353 requireInit()
354 who, err := token.GetApproved(itemTid(id))
355 if err != nil {
356 return ""
357 }
358 return who.String()
359}
360
361func IsApprovedForAll(owner, operator string) bool {
362 requireInit()
363 a := address(owner)
364 b := address(operator)
365 if !a.IsValid() || !b.IsValid() {
366 return false
367 }
368 return token.IsApprovedForAll(a, b)
369}
370
371func TotalSupply() int64 {
372 requireInit()
373 return token.TotalSupply()
374}
375
376func SellerOf(id int) address {
377 return mustItem(id).seller
378}
379
380func PriceOf(id int) int64 {
381 return mustItem(id).price
382}
383
384func NameOf(id int) string {
385 return mustItem(id).name
386}
387
388func ImageOf(id int) string {
389 return mustItem(id).image
390}
391
392func Listed(id int) bool {
393 return mustItem(id).listed
394}
395
396func NextID() int {
397 return nextID
398}
399
400func CollectionOf(_ int) string {
401 requireInit()
402 return slug
403}
404
405func ItemLine(id int) string {
406 it := mustItem(id)
407 return strconv.Itoa(it.id) + "|" + it.name + "|" + it.owner.String() + "|" + it.seller.String() + "|" + strconv.FormatInt(it.price, 10) + "|" + strconv.FormatBool(it.listed) + "|" + it.image + "|" + slug + "|" + it.rarity + "|" + it.traits + "|" + strconv.FormatBool(it.revealed) + "|" + strconv.Itoa(it.slot)
408}
409
410func ListOpen() string {
411 requireInit()
412 out := ""
413 n := 0
414 items.Iterate("", "", func(_ string, value any) bool {
415 it := value.(*item)
416 if !it.listed {
417 return false
418 }
419 if n >= 50 {
420 return true
421 }
422 if out != "" {
423 out += "\n"
424 }
425 out += ItemLine(it.id)
426 n++
427 return false
428 })
429 return out
430}
431
432func TokensOf(owner address) string {
433 requireInit()
434 out := ""
435 items.Iterate("", "", func(_ string, value any) bool {
436 it := value.(*item)
437 if it.owner != owner {
438 return false
439 }
440 if out != "" {
441 out += ","
442 }
443 out += strconv.Itoa(it.id)
444 return false
445 })
446 return out
447}
448
449func Creator() string {
450 requireInit()
451 return creator.String()
452}
453
454func MintPrice() int64 {
455 requireInit()
456 return mintPrice
457}
458
459func MaxSupply() int {
460 requireInit()
461 return maxSupply
462}
463
464func Minted() int {
465 requireInit()
466 return minted
467}
468
469func RoyaltyBps() int {
470 requireInit()
471 return royaltyBps
472}
473
474func SetRoyalty(cur realm, bps int) {
475 caller := requireUser(cur)
476 if caller != creator {
477 panic("col: not creator")
478 }
479 if minted != 0 {
480 panic("col: minted")
481 }
482 if bps < 0 || bps > maxRoyaltyBps {
483 panic("col: royalty")
484 }
485 royaltyBps = bps
486 factory.NoteRoyalty(cross(cur), bps)
487 chain.Emit("SetRoyalty", "slug", slug, "bps", strconv.Itoa(bps))
488}
489
490func RarityOf(id int) string {
491 return mustItem(id).rarity
492}
493
494func TraitsOf(id int) string {
495 return mustItem(id).traits
496}
497
498func Render(_ string) string {
499 if !inited {
500 return "# Collection\n\nCall Init first.\n"
501 }
502 out := "# " + colName + " ($" + colSymbol + ")\n\n"
503 out += "- minted: " + strconv.Itoa(minted) + "\n"
504 out += "- mint price: " + strconv.FormatInt(mintPrice, 10) + " ugnot\n\n"
505 open := ListOpen()
506 if open == "" {
507 out += "_No open listings._\n"
508 } else {
509 out += "```\n" + open + "\n```\n"
510 }
511 return out
512}
513
514func mintTo(cur realm, caller address, name, imageURL string) int {
515 requireInit()
516 if !cur.IsCurrent() {
517 panic("col: spoofed realm")
518 }
519 if paused {
520 panic("col: paused")
521 }
522 if maxSupply > 0 && minted >= maxSupply {
523 panic("col: sold out")
524 }
525 if !validName(name) {
526 panic("col: name")
527 }
528 if !validImage(imageURL) {
529 panic("col: image")
530 }
531 nextID++
532 id := nextID
533 it := &item{
534 id: id,
535 name: name,
536 image: imageURL,
537 owner: caller,
538 rarity: rarityOf(id),
539 revealed: true,
540 slot: -1,
541 }
542 items.Set(itemKey(id), it)
543 err := ledger.Mint(caller, itemTid(id))
544 if err != nil {
545 panic("col: mint")
546 }
547 minted++
548 factory.NoteMinted(cross(cur), minted)
549 return id
550}
551
552func takeLaunchFee(cur realm) {
553 fee := factory.LaunchFee()
554 who := factory.ReservedCreator(lastElem(cur.PkgPath()))
555 if who != "" && who == cur.Previous().Address().String() {
556 takeExact(0)
557 return
558 }
559 takeExact(fee)
560 if fee > 0 {
561 bk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
562 bk.SendCoins(cur.Address(), factory.Address(), chain.Coins{{Denom: nativeDenom, Amount: fee}})
563 }
564}
565
566func takeExact(want int64) {
567 sent := unsafe.OriginSend()
568 if want == 0 {
569 if len(sent) != 0 {
570 panic("col: extra denoms")
571 }
572 return
573 }
574 if len(sent) != 1 || sent[0].Denom != nativeDenom || sent[0].Amount != want {
575 panic("col: wrong payment")
576 }
577}
578
579func publicImage() string {
580 img := "/samples/" + slug + "-0" + strconv.Itoa(minted+1) + ".png"
581 if validImage(img) {
582 return img
583 }
584 return colCover
585}
586
587func requireInit() {
588 if !inited {
589 panic("col: call Init first")
590 }
591}
592
593func requireUser(cur realm) address {
594 requireInit()
595 if !cur.IsCurrent() {
596 panic("col: spoofed realm")
597 }
598 if !cur.Previous().IsUserCall() {
599 panic("col: EOA only")
600 }
601 return cur.Previous().Address()
602}
603
604func itemTid(id int) grc721.TokenID {
605 return grc721.TokenID(strconv.Itoa(id))
606}
607
608func mustItem(id int) *item {
609 v := items.Get(itemKey(id))
610 if v == nil {
611 panic("col: unknown item")
612 }
613 return v.(*item)
614}
615
616func itemKey(id int) string {
617 return strconv.Itoa(id)
618}
619
620func lastElem(pkg string) string {
621 i := strings.LastIndex(pkg, "/")
622 if i < 0 || i+1 >= len(pkg) {
623 panic("col: pkg")
624 }
625 return pkg[i+1:]
626}
627
628func slotKey(i int) string {
629 return strconv.Itoa(i)
630}
631
632func slotCap() int {
633 if maxSupply > 0 {
634 return maxSupply
635 }
636 return dropSlotHardCap
637}
638
639func applySlot(id int, s *dropSlot, slotIndex int) {
640 it := mustItem(id)
641 it.name = s.name
642 it.image = s.image
643 if s.rarity != "" {
644 it.rarity = s.rarity
645 }
646 it.traits = s.traits
647 it.revealed = true
648 it.slot = slotIndex
649 items.Set(itemKey(id), it)
650}
651
652func writeTokenMeta(id int) {
653 it := mustItem(id)
654 uri := tokenJSON(it)
655 if metaLed == nil {
656 return
657 }
658 if err := metaLed.SetTokenURI(itemTid(id), uri); err != nil {
659 panic("col: tokenURI")
660 }
661 if err := metaLed.SetTokenMetadata(itemTid(id), metadata.Data{
662 Name: it.name,
663 Description: colName + " #" + strconv.Itoa(id),
664 Image: absImage(it.image),
665 Attributes: traitAttrs(it),
666 }); err != nil {
667 panic("col: metadata")
668 }
669}
670
671func tokenJSON(it *item) string {
672 attrs := ""
673 for _, tr := range traitAttrs(it) {
674 if attrs != "" {
675 attrs += ","
676 }
677 attrs += `{"trait_type":` + jsonStr(tr.TraitType) + `,"value":` + jsonStr(tr.Value) + `}`
678 }
679 body := `{"name":` + jsonStr(it.name) + `,"description":` + jsonStr(colName+" #"+strconv.Itoa(it.id)) + `,"image":` + jsonStr(absImage(it.image)) + `,"attributes":[` + attrs + `]}`
680 return "data:application/json;charset=utf-8," + pctURI(body)
681}
682
683func traitAttrs(it *item) []metadata.Trait {
684 out := []metadata.Trait{}
685 if it.rarity != "" {
686 out = append(out, metadata.Trait{TraitType: "Rarity", Value: it.rarity})
687 }
688 for _, part := range strings.Split(it.traits, ";") {
689 part = strings.TrimSpace(part)
690 colon := strings.Index(part, ":")
691 if colon <= 0 || colon >= len(part)-1 {
692 continue
693 }
694 key := strings.TrimSpace(part[:colon])
695 val := strings.TrimSpace(part[colon+1:])
696 if key == "" || val == "" {
697 continue
698 }
699 out = append(out, metadata.Trait{TraitType: key, Value: val})
700 }
701 return out
702}
703
704func absImage(u string) string {
705 if u == "" {
706 return u
707 }
708 if strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "ipfs://") {
709 return u
710 }
711 if strings.HasPrefix(u, "/") {
712 return publicOrigin + u
713 }
714 return u
715}
716
717func jsonStr(s string) string {
718 out := `"`
719 for _, r := range s {
720 switch r {
721 case '"':
722 out += `\"`
723 case '\\':
724 out += `\\`
725 case '\n':
726 out += `\n`
727 case '\r':
728 out += `\r`
729 case '\t':
730 out += `\t`
731 default:
732 if r < 0x20 {
733 out += `\u00`
734 hex := "0123456789abcdef"
735 out += string([]byte{hex[r>>4], hex[r&15]})
736 } else {
737 out += string(r)
738 }
739 }
740 }
741 return out + `"`
742}
743
744func pctURI(s string) string {
745 out := ""
746 for i := 0; i < len(s); i++ {
747 c := s[i]
748 if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~' {
749 out += string(c)
750 continue
751 }
752 hex := "0123456789ABCDEF"
753 out += "%"
754 out += string([]byte{hex[c>>4], hex[c&15]})
755 }
756 return out
757}
758
759func splitDropLines(blob string) []string {
760 raw := strings.Split(blob, "\n")
761 out := make([]string, 0, len(raw))
762 for _, line := range raw {
763 line = strings.TrimSpace(line)
764 if line == "" {
765 continue
766 }
767 out = append(out, line)
768 }
769 return out
770}
771
772func parseDropLine(line string) (string, string, string, string) {
773 parts := strings.Split(line, "|")
774 name := ""
775 image := ""
776 rarity := ""
777 traits := ""
778 if len(parts) > 0 {
779 name = strings.TrimSpace(parts[0])
780 }
781 if len(parts) > 1 {
782 image = strings.TrimSpace(parts[1])
783 }
784 if len(parts) > 2 {
785 rarity = strings.TrimSpace(parts[2])
786 }
787 if len(parts) > 3 {
788 traits = strings.TrimSpace(parts[3])
789 }
790 return name, image, rarity, traits
791}
792
793func validRarity(s string) bool {
794 if s == "" {
795 return true
796 }
797 return s == "Common" || s == "Uncommon" || s == "Rare" || s == "Epic" || s == "Legendary"
798}
799
800func validTraits(s string) bool {
801 if s == "" {
802 return true
803 }
804 if len(s) > 200 {
805 return false
806 }
807 if strings.ContainsAny(s, "\n\r|") {
808 return false
809 }
810 n := 0
811 for _, part := range strings.Split(s, ";") {
812 part = strings.TrimSpace(part)
813 if part == "" {
814 continue
815 }
816 colon := strings.Index(part, ":")
817 if colon <= 0 || colon >= len(part)-1 {
818 return false
819 }
820 key := strings.TrimSpace(part[:colon])
821 val := strings.TrimSpace(part[colon+1:])
822 if key == "" || val == "" {
823 return false
824 }
825 if !asciiSet(key, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 _-") {
826 return false
827 }
828 if !asciiSet(val, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 _-") {
829 return false
830 }
831 n++
832 if n > 8 {
833 return false
834 }
835 }
836 return n >= 1
837}
838
839func validPkgName(s string) bool {
840 if len(s) < minSlugLen || len(s) > grc721.MaxSymbolLen {
841 return false
842 }
843 if s[0] < 'a' || s[0] > 'z' {
844 return false
845 }
846 return asciiSet(s, "abcdefghijklmnopqrstuvwxyz0123456789")
847}
848
849func rarityOf(id int) string {
850 r := id % 10
851 if r == 0 {
852 return "Legendary"
853 }
854 if r == 1 {
855 return "Common"
856 }
857 if r == 2 {
858 return "Uncommon"
859 }
860 if r == 3 {
861 return "Rare"
862 }
863 if r == 4 {
864 return "Epic"
865 }
866 if r <= 7 {
867 return "Uncommon"
868 }
869 return "Common"
870}
871
872func validName(name string) bool {
873 if len(name) < 1 || len(name) > maxNameLen {
874 return false
875 }
876 if strings.ContainsAny(name, "\n\r|\t\"\\") {
877 return false
878 }
879 for _, r := range name {
880 if r < 0x20 {
881 return false
882 }
883 }
884 return true
885}
886
887func validImage(u string) bool {
888 if u == "" {
889 return true
890 }
891 if len(u) > maxImageLen {
892 return false
893 }
894 if strings.ContainsAny(u, " \t\n\r|<>\"'`") {
895 return false
896 }
897 if strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "http://") {
898 return true
899 }
900 const prefix = "/samples/"
901 if !strings.HasPrefix(u, prefix) {
902 return false
903 }
904 rest := u[len(prefix):]
905 if rest == "" {
906 return false
907 }
908 return asciiSet(rest, "abcdefghijklmnopqrstuvwxyz0123456789._-")
909}
910
911func asciiSet(s, allowed string) bool {
912 for i := 0; i < len(s); i++ {
913 if !strings.ContainsRune(allowed, rune(s[i])) {
914 return false
915 }
916 }
917 return true
918}