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

shown.gno

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