Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

col3333.gno

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