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

bazaargens2.gno

30.33 Kb · 1521 lines
   1package bazaargens2
   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	"time"
  15
  16	"gno.land/p/nt/grc721/metadata/v0"
  17	"gno.land/p/nt/grc721/v0"
  18	"gno.land/p/nt/avl/v0"
  19	"gno.land/p/nt/seqid/v0"
  20	factory "gno.land/r/g1n4pl5uc4yt5r96m9w6fmdznx3x0jyg8l6arhmt/bazaar/bazaarv4"
  21)
  22
  23const (
  24	maxNameLen      = 64
  25	maxImageLen     = 200
  26	maxSupplyBound  = 1_000_000
  27	maxRoyaltyBps   = 1000
  28	nativeDenom     = "ugnot"
  29	publicOrigin    = "https://bazaar-gno.vercel.app"
  30	dropAddCap      = 20
  31	dropSlotHardCap = 10000
  32	allowAddCap     = 50
  33	minSlugLen      = 2
  34)
  35
  36type item struct {
  37	id       int
  38	name     string
  39	image    string
  40	owner    address
  41	seller   address
  42	price    int64
  43	listed   bool
  44	rarity   string
  45	traits   string
  46	revealed bool
  47	slot     int
  48}
  49
  50type dropSlot struct {
  51	name   string
  52	image  string
  53	rarity string
  54	traits string
  55}
  56
  57type phaseCount struct {
  58	wl  int
  59	pub int
  60}
  61
  62var (
  63	items      avl.Tree
  64	dropSlots  avl.Tree
  65	allowlist  avl.Tree
  66	mintedBy   avl.Tree
  67	token      *grc721.Token
  68	ledger     *grc721.PrivateLedger
  69	metaView   *metadata.Metadata
  70	metaLed    *metadata.Ledger
  71	tokenSeq   seqid.ID
  72	nextID     int
  73	minted     int
  74	loaded     int
  75	allowN     int
  76	mintCap    int
  77	wlMintCap  int
  78	pubMintCap int
  79	maxSupply  int
  80	mintPrice  int64
  81	wlPrice     int64
  82	wlSupply    int
  83	wlStart      int64
  84	wlEnd        int64
  85	publicStart  int64
  86	publicEnd    int64
  87	publicForced bool
  88	royaltyBps   int
  89	paused     bool
  90	hidden     bool
  91	inited     bool
  92	creator    address
  93	self       address
  94	colName    string
  95	colSymbol  string
  96	colCover   string
  97	prereveal  string
  98	slug       string
  99)
 100
 101func Init(cur realm, name, symbol, cover string, maxSupplyN int, mintPriceUgnot int64, royalty int64) {
 102	if !cur.Previous().IsUserCall() {
 103		panic("col: EOA only")
 104	}
 105	if !cur.IsCurrent() {
 106		panic("col: spoofed realm")
 107	}
 108	if len(symbol) < 1 || len(symbol) > grc721.MaxSymbolLen {
 109		panic("col: symbol")
 110	}
 111	pathSlug := lastElem(cur.PkgPath())
 112	if !validPkgName(pathSlug) {
 113		panic("col: slug")
 114	}
 115	if maxSupplyN < 0 || maxSupplyN > maxSupplyBound {
 116		panic("col: maxSupply")
 117	}
 118	if mintPriceUgnot < 0 {
 119		panic("col: mintPrice")
 120	}
 121	if royalty < 0 || royalty > maxRoyaltyBps {
 122		panic("col: royalty")
 123	}
 124	if !validName(name) {
 125		panic("col: name")
 126	}
 127	if !validImage(cover) {
 128		panic("col: cover")
 129	}
 130	if inited {
 131		panic("col: already initialized")
 132	}
 133	slug = pathSlug
 134	reservedWho := factory.ReservedCreator(slug)
 135	if reservedWho == "" || reservedWho != cur.Previous().Address().String() {
 136		panic("col: reserve first")
 137	}
 138	takeExact(0)
 139	tok, led := grc721.NewToken(name, symbol, tokenSeq.Next(), cur)
 140	token = tok
 141	ledger = led
 142	metaView, metaLed = metadata.NewMetadata(led)
 143	creator = cur.Previous().Address()
 144	self = cur.Address()
 145	colName = name
 146	colSymbol = symbol
 147	colCover = cover
 148	prereveal = ""
 149	maxSupply = maxSupplyN
 150	mintPrice = mintPriceUgnot
 151	royaltyBps = int(royalty)
 152	items = avl.Tree{}
 153	dropSlots = avl.Tree{}
 154	allowlist = avl.Tree{}
 155	mintedBy = avl.Tree{}
 156	loaded = 0
 157	allowN = 0
 158	mintCap = 0
 159	wlMintCap = 0
 160	pubMintCap = 0
 161	wlPrice = 0
 162	wlSupply = 0
 163	wlStart = 0
 164	wlEnd = 0
 165	publicStart = 0
 166	publicEnd = 0
 167	publicForced = false
 168	hidden = false
 169	paused = maxSupplyN > 0
 170	inited = true
 171	factory.Register(cross(cur), name, symbol, cover, maxSupplyN, mintPriceUgnot, royalty)
 172	chain.Emit("Init", "creator", creator.String(), "slug", slug)
 173}
 174
 175func Mint(cur realm, name, imageURL string) int {
 176	caller := requireUser(cur)
 177	if caller != creator {
 178		panic("col: not creator")
 179	}
 180	id := mintTo(cur, caller, name, imageURL)
 181	writeTokenMeta(id)
 182	chain.Emit("Mint", "id", strconv.Itoa(id), "owner", caller.String())
 183	return id
 184}
 185
 186func PublicMint(cur realm) int {
 187	caller := requireUser(cur)
 188	if paused {
 189		panic("col: paused")
 190	}
 191	if mintPhase(time.Now().Unix()) == "closed" {
 192		panic("col: not open")
 193	}
 194	requireAllow(caller)
 195	checkMintCap(caller)
 196	if maxSupply > 0 && loaded == 0 {
 197		panic("col: no slot")
 198	}
 199	pay := mintPrice
 200	if wlPhase() {
 201		pay = wlPrice
 202	}
 203	takeExact(pay)
 204	name := colName + " #" + strconv.Itoa(minted+1)
 205	image := publicImage()
 206	var slot *dropSlot
 207	slotIndex := -1
 208	if loaded > 0 {
 209		if minted >= loaded {
 210			panic("col: no slot")
 211		}
 212		v := dropSlots.Get(slotKey(minted))
 213		if v == nil {
 214			panic("col: no slot")
 215		}
 216		slot = v.(*dropSlot)
 217		slotIndex = minted
 218		if hidden {
 219			name = "Unrevealed"
 220			image = prerevealImage()
 221		} else {
 222			name = slot.name
 223			image = slot.image
 224		}
 225	}
 226	id := mintTo(cur, caller, name, image)
 227	if slot != nil {
 228		if hidden {
 229			it := mustItem(id)
 230			it.revealed = false
 231			it.slot = slotIndex
 232			items.Set(itemKey(id), it)
 233		} else {
 234			applySlot(id, slot, slotIndex)
 235		}
 236	}
 237	writeTokenMeta(id)
 238	noteMint(caller)
 239	if pay > 0 {
 240		bk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
 241		bk.SendCoins(self, creator, chain.Coins{{Denom: nativeDenom, Amount: pay}})
 242	}
 243	chain.Emit("PublicMint", "id", strconv.Itoa(id), "n", strconv.Itoa(minted))
 244	return id
 245}
 246
 247func AddDropItems(cur realm, blob string) {
 248	caller := requireUser(cur)
 249	if caller != creator {
 250		panic("col: not creator")
 251	}
 252	lines := splitDropLines(blob)
 253	if len(lines) < 1 || len(lines) > dropAddCap {
 254		panic("col: slots")
 255	}
 256	if loaded+len(lines) > slotCap() {
 257		panic("col: slots")
 258	}
 259	for _, line := range lines {
 260		name, image, rarity, traits := parseDropLine(line)
 261		if !validName(name) {
 262			panic("col: name")
 263		}
 264		if !validImage(image) {
 265			panic("col: image")
 266		}
 267		if !validRarity(rarity) {
 268			panic("col: rarity")
 269		}
 270		if !validTraits(traits) {
 271			panic("col: traits")
 272		}
 273		dropSlots.Set(slotKey(loaded), &dropSlot{name: name, image: image, rarity: rarity, traits: traits})
 274		loaded++
 275	}
 276	chain.Emit("AddDropItems", "slug", slug, "loaded", strconv.Itoa(loaded))
 277}
 278
 279func ListDropSlots() string {
 280	requireInit()
 281	if hidden {
 282		return ""
 283	}
 284	out := ""
 285	for i := 0; i < loaded; i++ {
 286		v := dropSlots.Get(slotKey(i))
 287		if v == nil {
 288			continue
 289		}
 290		s := v.(*dropSlot)
 291		if out != "" {
 292			out += "\n"
 293		}
 294		out += strconv.Itoa(i) + "|" + s.name + "|" + s.image + "|" + s.rarity + "|" + s.traits
 295	}
 296	return out
 297}
 298
 299func LoadedOf() int {
 300	requireInit()
 301	return loaded
 302}
 303
 304func PauseMint(cur realm) {
 305	caller := requireUser(cur)
 306	if caller != creator {
 307		panic("col: not creator")
 308	}
 309	paused = true
 310	chain.Emit("PauseMint", "slug", slug)
 311}
 312
 313func ResumeMint(cur realm) {
 314	caller := requireUser(cur)
 315	if caller != creator {
 316		panic("col: not creator")
 317	}
 318	if maxSupply > 0 && loaded < 1 {
 319		panic("col: no slot")
 320	}
 321	paused = false
 322	chain.Emit("ResumeMint", "slug", slug)
 323}
 324
 325func AddAllowlist(cur realm, blob string) {
 326	caller := requireUser(cur)
 327	if caller != creator {
 328		panic("col: not creator")
 329	}
 330	lines := splitDropLines(blob)
 331	if len(lines) < 1 || len(lines) > allowAddCap {
 332		panic("col: allowlist")
 333	}
 334	for _, line := range lines {
 335		who := strings.ToLower(strings.TrimSpace(line))
 336		if !validG1(who) {
 337			panic("col: address")
 338		}
 339		if allowlist.Get(who) != nil {
 340			continue
 341		}
 342		allowlist.Set(who, true)
 343		allowN++
 344	}
 345	chain.Emit("AddAllowlist", "n", strconv.Itoa(allowN))
 346}
 347
 348func AllowN() int {
 349	requireInit()
 350	return allowN
 351}
 352
 353func OnAllowlist(addr string) bool {
 354	requireInit()
 355	who := strings.ToLower(strings.TrimSpace(addr))
 356	return allowlist.Get(who) != nil
 357}
 358
 359func SetDropSale(cur realm, wlPriceUgnot int64, wlQty int) {
 360	caller := requireUser(cur)
 361	if caller != creator {
 362		panic("col: not creator")
 363	}
 364	if minted > 0 {
 365		panic("col: minted")
 366	}
 367	if wlPriceUgnot < 0 {
 368		panic("col: wlPrice")
 369	}
 370	if wlQty < 0 {
 371		panic("col: wlSupply")
 372	}
 373	if maxSupply > 0 && wlQty > maxSupply {
 374		panic("col: wlSupply")
 375	}
 376	wlPrice = wlPriceUgnot
 377	wlSupply = wlQty
 378	chain.Emit("SetDropSale", "wlSupply", strconv.Itoa(wlQty))
 379}
 380
 381func StartPublic(cur realm) {
 382	caller := requireUser(cur)
 383	if caller != creator {
 384		panic("col: not creator")
 385	}
 386	if wlSupply <= 0 || minted >= wlSupply {
 387		panic("col: public")
 388	}
 389	publicForced = true
 390	wlSupply = minted
 391	now := time.Now().Unix()
 392	if wlStart > now {
 393		wlStart = now
 394	}
 395	if publicStart > now {
 396		publicStart = now
 397	}
 398	if publicEnd > 0 && publicEnd <= now {
 399		publicEnd = 0
 400	}
 401	chain.Emit("StartPublic", "minted", strconv.Itoa(minted))
 402}
 403
 404// SetMintSchedule sets unix-second bounds for both rounds. 0,0,0,0 clears the clock.
 405// An end of 0 means that round has no hard close. wlStart > 0 requires publicStart > wlStart.
 406// A set end must be after its own start. publicStart must not fall inside the whitelist window.
 407// Locked once any token has minted.
 408func SetMintSchedule(cur realm, wlStartUnix, wlEndUnix, publicStartUnix, publicEndUnix int64) {
 409	caller := requireUser(cur)
 410	if caller != creator {
 411		panic("col: not creator")
 412	}
 413	if minted > 0 {
 414		panic("col: minted")
 415	}
 416	if wlStartUnix < 0 || wlEndUnix < 0 || publicStartUnix < 0 || publicEndUnix < 0 {
 417		panic("col: schedule")
 418	}
 419	if wlStartUnix == 0 && wlEndUnix == 0 && publicStartUnix == 0 && publicEndUnix == 0 {
 420		wlStart = 0
 421		wlEnd = 0
 422		publicStart = 0
 423		publicEnd = 0
 424		chain.Emit("SetMintSchedule", "wlStart", "0", "wlEnd", "0", "publicStart", "0", "publicEnd", "0")
 425		return
 426	}
 427	if wlStartUnix > 0 && publicStartUnix <= wlStartUnix {
 428		panic("col: schedule")
 429	}
 430	if wlEndUnix > 0 && (wlStartUnix == 0 || wlEndUnix <= wlStartUnix) {
 431		panic("col: schedule")
 432	}
 433	if publicEndUnix > 0 && (publicStartUnix == 0 || publicEndUnix <= publicStartUnix) {
 434		panic("col: schedule")
 435	}
 436	if wlEndUnix > 0 && publicStartUnix > 0 && publicStartUnix < wlEndUnix {
 437		panic("col: schedule")
 438	}
 439	wlStart = wlStartUnix
 440	wlEnd = wlEndUnix
 441	publicStart = publicStartUnix
 442	publicEnd = publicEndUnix
 443	chain.Emit(
 444		"SetMintSchedule",
 445		"wlStart", strconv.FormatInt(wlStartUnix, 10),
 446		"wlEnd", strconv.FormatInt(wlEndUnix, 10),
 447		"publicStart", strconv.FormatInt(publicStartUnix, 10),
 448		"publicEnd", strconv.FormatInt(publicEndUnix, 10),
 449	)
 450}
 451
 452func MintScheduleOf() string {
 453	requireInit()
 454	return strconv.FormatInt(wlStart, 10) + "|" + strconv.FormatInt(wlEnd, 10) + "|" + strconv.FormatInt(publicStart, 10) + "|" + strconv.FormatInt(publicEnd, 10)
 455}
 456
 457func SetMintCap(cur realm, n int) {
 458	if n < 0 {
 459		panic("col: mintCap")
 460	}
 461	caller := requireUser(cur)
 462	if caller != creator {
 463		panic("col: not creator")
 464	}
 465	mintCap = n
 466	chain.Emit("SetMintCap", "cap", strconv.Itoa(n))
 467}
 468
 469// SetPhaseMintCap sets how many times one wallet may PublicMint in each round.
 470// 0 means that round has no per-wallet limit. Counts are separate: a whitelist
 471// mint does not consume the public allowance. mintCap still caps the total.
 472func SetPhaseMintCap(cur realm, wlN, pubN int) {
 473	if wlN < 0 || pubN < 0 {
 474		panic("col: phase cap")
 475	}
 476	caller := requireUser(cur)
 477	if caller != creator {
 478		panic("col: not creator")
 479	}
 480	wlMintCap = wlN
 481	pubMintCap = pubN
 482	chain.Emit("SetPhaseMintCap", "wl", strconv.Itoa(wlN), "public", strconv.Itoa(pubN))
 483}
 484
 485func SetMintPrice(cur realm, priceUgnot int64) {
 486	if priceUgnot < 0 {
 487		panic("col: price")
 488	}
 489	caller := requireUser(cur)
 490	if caller != creator {
 491		panic("col: not creator")
 492	}
 493	mintPrice = priceUgnot
 494	chain.Emit("SetMintPrice", "price", strconv.FormatInt(priceUgnot, 10))
 495}
 496
 497func MintCapOf() int {
 498	requireInit()
 499	return mintCap
 500}
 501
 502func PhaseMintCapOf() string {
 503	requireInit()
 504	return strconv.Itoa(wlMintCap) + "|" + strconv.Itoa(pubMintCap)
 505}
 506
 507func DropSaleOf() string {
 508	requireInit()
 509	return strconv.Itoa(royaltyBps) + "|" + strconv.FormatInt(wlPrice, 10) + "|" + strconv.Itoa(wlSupply) + "|" + mintPhase(time.Now().Unix()) + "|" + strconv.FormatInt(mintPrice, 10)
 510}
 511
 512func wlPhase() bool {
 513	return mintPhase(time.Now().Unix()) == "whitelist"
 514}
 515
 516// mintPhase is closed, whitelist, or public. A future publicStart does not
 517// fall through to public price when the allowlist quantity is already used.
 518// After wlEnd, and after publicEnd, the sale is closed until the next open bound.
 519func mintPhase(now int64) string {
 520	if publicForced {
 521		if publicEnd > 0 && now >= publicEnd {
 522			return "closed"
 523		}
 524		return "public"
 525	}
 526	if wlStart > 0 && now < wlStart {
 527		return "closed"
 528	}
 529	if publicStart > 0 && now >= publicStart {
 530		if publicEnd > 0 && now >= publicEnd {
 531			return "closed"
 532		}
 533		return "public"
 534	}
 535	if publicStart > 0 && now < publicStart {
 536		if wlEnd > 0 && now >= wlEnd {
 537			return "closed"
 538		}
 539		if wlSupply > 0 && minted < wlSupply {
 540			return "whitelist"
 541		}
 542		return "closed"
 543	}
 544	if wlEnd > 0 && now >= wlEnd {
 545		return "public"
 546	}
 547	if wlSupply > 0 && minted < wlSupply {
 548		return "whitelist"
 549	}
 550	return "public"
 551}
 552
 553func requireAllow(caller address) {
 554	if publicForced {
 555		return
 556	}
 557	who := strings.ToLower(caller.String())
 558	if wlPhase() {
 559		if allowN <= 0 {
 560			panic("col: allowlist")
 561		}
 562		if allowlist.Get(who) == nil {
 563			panic("col: not allowed")
 564		}
 565		return
 566	}
 567	if wlSupply > 0 {
 568		return
 569	}
 570	if allowN <= 0 {
 571		return
 572	}
 573	if allowlist.Get(who) == nil {
 574		panic("col: not allowed")
 575	}
 576}
 577
 578func walletPhase(who string) phaseCount {
 579	if v := mintedBy.Get(who); v != nil {
 580		return v.(phaseCount)
 581	}
 582	return phaseCount{}
 583}
 584
 585func checkMintCap(caller address) {
 586	who := strings.ToLower(caller.String())
 587	got := walletPhase(who)
 588	if mintCap > 0 && got.wl+got.pub >= mintCap {
 589		panic("col: mint cap")
 590	}
 591	if wlPhase() {
 592		if wlMintCap > 0 && got.wl >= wlMintCap {
 593			panic("col: wl mint cap")
 594		}
 595		return
 596	}
 597	if pubMintCap > 0 && got.pub >= pubMintCap {
 598		panic("col: public mint cap")
 599	}
 600}
 601
 602func noteMint(caller address) {
 603	who := strings.ToLower(caller.String())
 604	got := walletPhase(who)
 605	if wlPhase() {
 606		got.wl++
 607	} else {
 608		got.pub++
 609	}
 610	mintedBy.Set(who, got)
 611}
 612
 613func Hidden() bool {
 614	requireInit()
 615	return hidden
 616}
 617
 618func SetHidden(cur realm, hide bool) {
 619	caller := requireUser(cur)
 620	if caller != creator {
 621		panic("col: not creator")
 622	}
 623	if !hide {
 624		panic("col: reveal")
 625	}
 626	if loaded < 1 {
 627		panic("col: no slots")
 628	}
 629	if !hidden && minted > 0 {
 630		panic("col: revealed")
 631	}
 632	hidden = true
 633	chain.Emit("SetHidden", "hidden", "true")
 634}
 635
 636// SetPrereveal is the image every sealed NFT shows. Empty uses the cover.
 637// Creator only. After RevealCollection the real slot image is already public.
 638func SetPrereveal(cur realm, image string) {
 639	caller := requireUser(cur)
 640	if caller != creator {
 641		panic("col: not creator")
 642	}
 643	image = strings.TrimSpace(image)
 644	if image != "" && !validImage(image) {
 645		panic("col: image")
 646	}
 647	if !hidden && minted > 0 {
 648		panic("col: revealed")
 649	}
 650	prereveal = image
 651	if hidden {
 652		paintSealed(prerevealImage())
 653	}
 654	chain.Emit("SetPrereveal", "image", image)
 655}
 656
 657func Prereveal() string {
 658	requireInit()
 659	return prereveal
 660}
 661
 662// RevealCollection opens every minted NFT. Creator only, and only while sealed.
 663func RevealCollection(cur realm) {
 664	caller := requireUser(cur)
 665	if caller != creator {
 666		panic("col: not creator")
 667	}
 668	if !hidden {
 669		panic("col: revealed")
 670	}
 671	if loaded < 1 {
 672		panic("col: no slots")
 673	}
 674	hidden = false
 675	for _, id := range sealedIDs() {
 676		it := mustItem(id)
 677		v := dropSlots.Get(slotKey(it.slot))
 678		if v == nil {
 679			panic("col: no slot")
 680		}
 681		applySlot(id, v.(*dropSlot), it.slot)
 682		writeTokenMeta(id)
 683	}
 684	chain.Emit("RevealCollection", "slug", slug)
 685}
 686
 687func Revealed(id int) bool {
 688	return mustItem(id).revealed
 689}
 690
 691func Reveal(cur realm, id int) {
 692	caller := requireUser(cur)
 693	it := mustItem(id)
 694	if caller != it.owner {
 695		panic("col: not owner")
 696	}
 697	if hidden {
 698		panic("col: sealed")
 699	}
 700	if it.revealed {
 701		panic("col: revealed")
 702	}
 703	if it.slot < 0 {
 704		panic("col: no slot")
 705	}
 706	v := dropSlots.Get(slotKey(it.slot))
 707	if v == nil {
 708		panic("col: no slot")
 709	}
 710	applySlot(id, v.(*dropSlot), it.slot)
 711	writeTokenMeta(id)
 712	chain.Emit("Reveal", "id", strconv.Itoa(id))
 713}
 714
 715// Adena IIFE: owner, err := OwnerOf("1"); owner.String() == wallet
 716func OwnerOf(tid string) (address, error) {
 717	requireInit()
 718	id, err := strconv.Atoi(tid)
 719	if err != nil || id < 1 {
 720		return address(""), errors.New("col: tid")
 721	}
 722	v := items.Get(itemKey(id))
 723	if v == nil {
 724		return address(""), errors.New("col: unknown item")
 725	}
 726	return v.(*item).owner, nil
 727}
 728
 729func GrcName() string {
 730	requireInit()
 731	return token.GetName()
 732}
 733
 734func GrcSymbol() string {
 735	requireInit()
 736	return token.GetSymbol()
 737}
 738
 739func GrcBalanceOf(addr string) int64 {
 740	requireInit()
 741	who := address(addr)
 742	if !who.IsValid() {
 743		return 0
 744	}
 745	n, err := token.BalanceOf(who)
 746	if err != nil {
 747		return 0
 748	}
 749	return n
 750}
 751
 752func GrcOwnerOf(id int) string {
 753	requireInit()
 754	owner, err := token.OwnerOf(itemTid(id))
 755	if err != nil {
 756		panic("col: owner")
 757	}
 758	return owner.String()
 759}
 760
 761func Name() string {
 762	return GrcName()
 763}
 764
 765func Symbol() string {
 766	return GrcSymbol()
 767}
 768
 769func BalanceOf(addr string) int64 {
 770	return GrcBalanceOf(addr)
 771}
 772
 773func TokenURI(tid string) string {
 774	id := parseTid(tid)
 775	mustItem(id)
 776	if metaView != nil {
 777		u, err := metaView.TokenURI(itemTid(id))
 778		if err == nil && u != "" {
 779			return u
 780		}
 781	}
 782	return tokenJSON(mustItem(id))
 783}
 784
 785func guardUnlisted(id int) {
 786	if mustItem(id).listed {
 787		panic("col: listed")
 788	}
 789}
 790
 791func Approve(cur realm, to, tid string) {
 792	caller := requireUser(cur)
 793	id := parseTid(tid)
 794	guardUnlisted(id)
 795	who := address(to)
 796	if to != "" && !who.IsValid() {
 797		panic("col: addr")
 798	}
 799	if err := ledger.Approve(caller, who, itemTid(id)); err != nil {
 800		panic("col: approve")
 801	}
 802}
 803
 804func SetApprovalForAll(cur realm, operator string, approved bool) {
 805	caller := requireUser(cur)
 806	op := address(operator)
 807	if !op.IsValid() {
 808		panic("col: addr")
 809	}
 810	if err := ledger.SetApprovalForAll(caller, op, approved); err != nil {
 811		panic("col: approve")
 812	}
 813}
 814
 815func TransferFrom(cur realm, from, to, tid string) {
 816	caller := requireUser(cur)
 817	id := parseTid(tid)
 818	guardUnlisted(id)
 819	src := address(from)
 820	dst := address(to)
 821	if !src.IsValid() || !dst.IsValid() {
 822		panic("col: addr")
 823	}
 824	if err := ledger.TransferFrom(caller, src, dst, itemTid(id)); err != nil {
 825		panic("col: transfer")
 826	}
 827	it := mustItem(id)
 828	it.owner = dst
 829	items.Set(itemKey(id), it)
 830}
 831
 832func GetApproved(id int) string {
 833	requireInit()
 834	who, err := token.GetApproved(itemTid(id))
 835	if err != nil {
 836		return ""
 837	}
 838	return who.String()
 839}
 840
 841func IsApprovedForAll(owner, operator string) bool {
 842	requireInit()
 843	a := address(owner)
 844	b := address(operator)
 845	if !a.IsValid() || !b.IsValid() {
 846		return false
 847	}
 848	return token.IsApprovedForAll(a, b)
 849}
 850
 851func TotalSupply() int64 {
 852	requireInit()
 853	return token.TotalSupply()
 854}
 855
 856func SellerOf(id int) address {
 857	return mustItem(id).seller
 858}
 859
 860func PriceOf(id int) int64 {
 861	return mustItem(id).price
 862}
 863
 864func NameOf(id int) string {
 865	it := mustItem(id)
 866	if !it.revealed {
 867		return "Unrevealed"
 868	}
 869	return it.name
 870}
 871
 872func ImageOf(id int) string {
 873	it := mustItem(id)
 874	if !it.revealed {
 875		return prerevealImage()
 876	}
 877	return it.image
 878}
 879
 880func Listed(id int) bool {
 881	return mustItem(id).listed
 882}
 883
 884func NextID() int {
 885	return nextID
 886}
 887
 888func CollectionOf(_ int) string {
 889	requireInit()
 890	return slug
 891}
 892
 893func ItemLine(id int) string {
 894	it := mustItem(id)
 895	name := it.name
 896	image := it.image
 897	rarity := it.rarity
 898	traits := it.traits
 899	if !it.revealed {
 900		name = "Unrevealed"
 901		image = prerevealImage()
 902		rarity = ""
 903		traits = ""
 904	}
 905	return strconv.Itoa(it.id) + "|" + name + "|" + it.owner.String() + "|" + it.seller.String() + "|" + strconv.FormatInt(it.price, 10) + "|" + strconv.FormatBool(it.listed) + "|" + image + "|" + slug + "|" + rarity + "|" + traits + "|" + strconv.FormatBool(it.revealed) + "|" + strconv.Itoa(it.slot)
 906}
 907
 908func ListOpen() string {
 909	requireInit()
 910	out := ""
 911	n := 0
 912	items.Iterate("", "", func(_ string, value any) bool {
 913		it := value.(*item)
 914		if !it.listed {
 915			return false
 916		}
 917		if n >= 50 {
 918			return true
 919		}
 920		if out != "" {
 921			out += "\n"
 922		}
 923		out += ItemLine(it.id)
 924		n++
 925		return false
 926	})
 927	return out
 928}
 929
 930func TokensOf(owner string) string {
 931	requireInit()
 932	who := address(owner)
 933	if !who.IsValid() {
 934		return ""
 935	}
 936	out := ""
 937	items.Iterate("", "", func(_ string, value any) bool {
 938		it := value.(*item)
 939		if it.owner != who {
 940			return false
 941		}
 942		if out != "" {
 943			out += ","
 944		}
 945		out += strconv.Itoa(it.id)
 946		return false
 947	})
 948	return out
 949}
 950
 951func TokenOfOwnerByIndex(owner string, index int64) string {
 952	requireInit()
 953	who := address(owner)
 954	if !who.IsValid() {
 955		panic("col: addr")
 956	}
 957	if index < 0 {
 958		panic("col: index")
 959	}
 960	var n int64
 961	found := ""
 962	items.Iterate("", "", func(_ string, value any) bool {
 963		it := value.(*item)
 964		if it.owner != who {
 965			return false
 966		}
 967		if n == index {
 968			found = strconv.Itoa(it.id)
 969			return true
 970		}
 971		n++
 972		return false
 973	})
 974	if found == "" {
 975		panic("col: index")
 976	}
 977	return found
 978}
 979
 980func TokenByIndex(index int64) string {
 981	requireInit()
 982	if index < 0 {
 983		panic("col: index")
 984	}
 985	var n int64
 986	found := ""
 987	items.Iterate("", "", func(_ string, value any) bool {
 988		if n == index {
 989			found = strconv.Itoa(value.(*item).id)
 990			return true
 991		}
 992		n++
 993		return false
 994	})
 995	if found == "" {
 996		panic("col: index")
 997	}
 998	return found
 999}
1000
1001func Creator() string {
1002	requireInit()
1003	return creator.String()
1004}
1005
1006func MintPrice() int64 {
1007	requireInit()
1008	return mintPrice
1009}
1010
1011func MaxSupply() int {
1012	requireInit()
1013	return maxSupply
1014}
1015
1016func Minted() int {
1017	requireInit()
1018	return minted
1019}
1020
1021func RoyaltyBps() int {
1022	requireInit()
1023	return royaltyBps
1024}
1025
1026func SetRoyalty(cur realm, bps int) {
1027	caller := requireUser(cur)
1028	if caller != creator {
1029		panic("col: not creator")
1030	}
1031	if minted != 0 {
1032		panic("col: minted")
1033	}
1034	if bps < 0 || bps > maxRoyaltyBps {
1035		panic("col: royalty")
1036	}
1037	royaltyBps = bps
1038	factory.NoteRoyalty(cross(cur), bps)
1039	chain.Emit("SetRoyalty", "slug", slug, "bps", strconv.Itoa(bps))
1040}
1041
1042func RarityOf(id int) string {
1043	it := mustItem(id)
1044	if !it.revealed {
1045		return ""
1046	}
1047	return it.rarity
1048}
1049
1050func TraitsOf(id int) string {
1051	it := mustItem(id)
1052	if !it.revealed {
1053		return ""
1054	}
1055	return it.traits
1056}
1057
1058func Paused() bool {
1059	requireInit()
1060	return paused
1061}
1062
1063func Render(_ string) string {
1064	if !inited {
1065		return "# Collection\n\nCall Init first.\n"
1066	}
1067	out := "# " + colName + " ($" + colSymbol + ")\n\n"
1068	out += "- minted: " + strconv.Itoa(minted) + "\n"
1069	out += "- mint price: " + strconv.FormatInt(mintPrice, 10) + " ugnot\n\n"
1070	open := ListOpen()
1071	if open == "" {
1072		out += "_No open listings._\n"
1073	} else {
1074		out += "```\n" + open + "\n```\n"
1075	}
1076	return out
1077}
1078
1079func mintTo(cur realm, caller address, name, imageURL string) int {
1080	requireInit()
1081	if !cur.IsCurrent() {
1082		panic("col: spoofed realm")
1083	}
1084	if maxSupply > 0 && minted >= maxSupply {
1085		panic("col: sold out")
1086	}
1087	if !validName(name) {
1088		panic("col: name")
1089	}
1090	if !validImage(imageURL) {
1091		panic("col: image")
1092	}
1093	nextID++
1094	id := nextID
1095	it := &item{
1096		id:       id,
1097		name:     name,
1098		image:    imageURL,
1099		owner:    caller,
1100		rarity:   rarityOf(id),
1101		revealed: true,
1102		slot:     -1,
1103	}
1104	items.Set(itemKey(id), it)
1105	err := ledger.Mint(caller, itemTid(id))
1106	if err != nil {
1107		panic("col: mint")
1108	}
1109	minted++
1110	factory.NoteMinted(cross(cur), minted)
1111	return id
1112}
1113
1114func takeExact(want int64) {
1115	sent := unsafe.OriginSend()
1116	if want == 0 {
1117		if len(sent) != 0 {
1118			panic("col: extra denoms")
1119		}
1120		return
1121	}
1122	if len(sent) != 1 || sent[0].Denom != nativeDenom || sent[0].Amount != want {
1123		panic("col: wrong payment")
1124	}
1125}
1126
1127func prerevealImage() string {
1128	if prereveal != "" {
1129		return prereveal
1130	}
1131	return colCover
1132}
1133
1134func sealedIDs() []int {
1135	out := []int{}
1136	items.Iterate("", "", func(_ string, value any) bool {
1137		it := value.(*item)
1138		if !it.revealed && it.slot >= 0 {
1139			out = append(out, it.id)
1140		}
1141		return false
1142	})
1143	return out
1144}
1145
1146func paintSealed(image string) {
1147	for _, id := range sealedIDs() {
1148		it := mustItem(id)
1149		it.image = image
1150		items.Set(itemKey(id), it)
1151		writeTokenMeta(id)
1152	}
1153}
1154
1155func publicImage() string {
1156	img := "/samples/" + slug + "-0" + strconv.Itoa(minted+1) + ".png"
1157	if validImage(img) {
1158		return img
1159	}
1160	return colCover
1161}
1162
1163func requireInit() {
1164	if !inited {
1165		panic("col: call Init first")
1166	}
1167}
1168
1169func requireUser(cur realm) address {
1170	requireInit()
1171	if !cur.IsCurrent() {
1172		panic("col: spoofed realm")
1173	}
1174	if !cur.Previous().IsUserCall() {
1175		panic("col: EOA only")
1176	}
1177	return cur.Previous().Address()
1178}
1179
1180func itemTid(id int) grc721.TokenID {
1181	return grc721.TokenID(strconv.Itoa(id))
1182}
1183
1184func parseTid(tid string) int {
1185	id, err := strconv.Atoi(tid)
1186	if err != nil || id < 1 {
1187		panic("col: tid")
1188	}
1189	return id
1190}
1191
1192func mustItem(id int) *item {
1193	v := items.Get(itemKey(id))
1194	if v == nil {
1195		panic("col: unknown item")
1196	}
1197	return v.(*item)
1198}
1199
1200func itemKey(id int) string {
1201	return strconv.Itoa(id)
1202}
1203
1204func lastElem(pkg string) string {
1205	i := strings.LastIndex(pkg, "/")
1206	if i < 0 || i+1 >= len(pkg) {
1207		panic("col: pkg")
1208	}
1209	return pkg[i+1:]
1210}
1211
1212func slotKey(i int) string {
1213	return strconv.Itoa(i)
1214}
1215
1216func slotCap() int {
1217	if maxSupply > 0 {
1218		return maxSupply
1219	}
1220	return dropSlotHardCap
1221}
1222
1223func applySlot(id int, s *dropSlot, slotIndex int) {
1224	it := mustItem(id)
1225	it.name = s.name
1226	it.image = s.image
1227	if s.rarity != "" {
1228		it.rarity = s.rarity
1229	}
1230	it.traits = s.traits
1231	it.revealed = true
1232	it.slot = slotIndex
1233	items.Set(itemKey(id), it)
1234}
1235
1236func writeTokenMeta(id int) {
1237	it := mustItem(id)
1238	name, image, attrs := publicMeta(it)
1239	uri := tokenJSON(it)
1240	if metaLed == nil {
1241		return
1242	}
1243	if err := metaLed.SetTokenURI(itemTid(id), uri); err != nil {
1244		panic("col: tokenURI")
1245	}
1246	if err := metaLed.SetTokenMetadata(itemTid(id), metadata.Data{
1247		Name:        name,
1248		Description: colName + " #" + strconv.Itoa(id),
1249		Image:       absImage(image),
1250		Attributes:  attrs,
1251	}); err != nil {
1252		panic("col: metadata")
1253	}
1254}
1255
1256func publicMeta(it *item) (string, string, []metadata.Trait) {
1257	if !it.revealed {
1258		return "Unrevealed", prerevealImage(), []metadata.Trait{}
1259	}
1260	return it.name, it.image, traitAttrs(it)
1261}
1262
1263func tokenJSON(it *item) string {
1264	name, image, traits := publicMeta(it)
1265	attrs := ""
1266	for _, tr := range traits {
1267		if attrs != "" {
1268			attrs += ","
1269		}
1270		attrs += `{"trait_type":` + jsonStr(tr.TraitType) + `,"value":` + jsonStr(tr.Value) + `}`
1271	}
1272	body := `{"name":` + jsonStr(name) + `,"description":` + jsonStr(colName+" #"+strconv.Itoa(it.id)) + `,"image":` + jsonStr(absImage(image)) + `,"attributes":[` + attrs + `]}`
1273	return "data:application/json;charset=utf-8," + pctURI(body)
1274}
1275
1276func traitAttrs(it *item) []metadata.Trait {
1277	out := []metadata.Trait{}
1278	if it.rarity != "" {
1279		out = append(out, metadata.Trait{TraitType: "Rarity", Value: it.rarity})
1280	}
1281	for _, part := range strings.Split(it.traits, ";") {
1282		part = strings.TrimSpace(part)
1283		colon := strings.Index(part, ":")
1284		if colon <= 0 || colon >= len(part)-1 {
1285			continue
1286		}
1287		key := strings.TrimSpace(part[:colon])
1288		val := strings.TrimSpace(part[colon+1:])
1289		if key == "" || val == "" {
1290			continue
1291		}
1292		out = append(out, metadata.Trait{TraitType: key, Value: val})
1293	}
1294	return out
1295}
1296
1297func absImage(u string) string {
1298	if u == "" {
1299		return u
1300	}
1301	if strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "ipfs://") {
1302		return u
1303	}
1304	if strings.HasPrefix(u, "/") {
1305		return publicOrigin + u
1306	}
1307	return u
1308}
1309
1310func jsonStr(s string) string {
1311	out := `"`
1312	for _, r := range s {
1313		switch r {
1314		case '"':
1315			out += `\"`
1316		case '\\':
1317			out += `\\`
1318		case '\n':
1319			out += `\n`
1320		case '\r':
1321			out += `\r`
1322		case '\t':
1323			out += `\t`
1324		default:
1325			if r < 0x20 {
1326				out += `\u00`
1327				hex := "0123456789abcdef"
1328				out += string([]byte{hex[r>>4], hex[r&15]})
1329			} else {
1330				out += string(r)
1331			}
1332		}
1333	}
1334	return out + `"`
1335}
1336
1337func pctURI(s string) string {
1338	out := ""
1339	for i := 0; i < len(s); i++ {
1340		c := s[i]
1341		if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~' {
1342			out += string(c)
1343			continue
1344		}
1345		hex := "0123456789ABCDEF"
1346		out += "%"
1347		out += string([]byte{hex[c>>4], hex[c&15]})
1348	}
1349	return out
1350}
1351
1352func splitDropLines(blob string) []string {
1353	raw := strings.Split(blob, "\n")
1354	out := make([]string, 0, len(raw))
1355	for _, line := range raw {
1356		line = strings.TrimSpace(line)
1357		if line == "" {
1358			continue
1359		}
1360		out = append(out, line)
1361	}
1362	return out
1363}
1364
1365func parseDropLine(line string) (string, string, string, string) {
1366	parts := strings.Split(line, "|")
1367	name := ""
1368	image := ""
1369	rarity := ""
1370	traits := ""
1371	if len(parts) > 0 {
1372		name = strings.TrimSpace(parts[0])
1373	}
1374	if len(parts) > 1 {
1375		image = strings.TrimSpace(parts[1])
1376	}
1377	if len(parts) > 2 {
1378		rarity = strings.TrimSpace(parts[2])
1379	}
1380	if len(parts) > 3 {
1381		traits = strings.TrimSpace(parts[3])
1382	}
1383	return name, image, rarity, traits
1384}
1385
1386func validRarity(s string) bool {
1387	if s == "" {
1388		return true
1389	}
1390	return s == "Common" || s == "Uncommon" || s == "Rare" || s == "Epic" || s == "Legendary" || s == "Mythic"
1391}
1392
1393func validTraits(s string) bool {
1394	if s == "" {
1395		return true
1396	}
1397	if len(s) > 200 {
1398		return false
1399	}
1400	if strings.ContainsAny(s, "\n\r|") {
1401		return false
1402	}
1403	n := 0
1404	for _, part := range strings.Split(s, ";") {
1405		part = strings.TrimSpace(part)
1406		if part == "" {
1407			continue
1408		}
1409		colon := strings.Index(part, ":")
1410		if colon <= 0 || colon >= len(part)-1 {
1411			return false
1412		}
1413		key := strings.TrimSpace(part[:colon])
1414		val := strings.TrimSpace(part[colon+1:])
1415		if key == "" || val == "" {
1416			return false
1417		}
1418		if !asciiSet(key, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 _-") {
1419			return false
1420		}
1421		if !asciiSet(val, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 _-") {
1422			return false
1423		}
1424		n++
1425		if n > 8 {
1426			return false
1427		}
1428	}
1429	return n >= 1
1430}
1431
1432func validG1(s string) bool {
1433	if len(s) < 39 || len(s) > 64 {
1434		return false
1435	}
1436	if s[0] != 'g' || s[1] != '1' {
1437		return false
1438	}
1439	return asciiSet(s, "abcdefghijklmnopqrstuvwxyz0123456789")
1440}
1441
1442func validPkgName(s string) bool {
1443	if len(s) < minSlugLen || len(s) > grc721.MaxSymbolLen {
1444		return false
1445	}
1446	if s[0] < 'a' || s[0] > 'z' {
1447		return false
1448	}
1449	return asciiSet(s, "abcdefghijklmnopqrstuvwxyz0123456789")
1450}
1451
1452func rarityOf(id int) string {
1453	r := id % 10
1454	if r == 0 {
1455		return "Legendary"
1456	}
1457	if r == 1 {
1458		return "Common"
1459	}
1460	if r == 2 {
1461		return "Uncommon"
1462	}
1463	if r == 3 {
1464		return "Rare"
1465	}
1466	if r == 4 {
1467		return "Epic"
1468	}
1469	if r <= 7 {
1470		return "Uncommon"
1471	}
1472	return "Common"
1473}
1474
1475func validName(name string) bool {
1476	if len(name) < 1 || len(name) > maxNameLen {
1477		return false
1478	}
1479	if strings.ContainsAny(name, "\n\r|\t\"\\") {
1480		return false
1481	}
1482	for _, r := range name {
1483		if r < 0x20 {
1484			return false
1485		}
1486	}
1487	return true
1488}
1489
1490func validImage(u string) bool {
1491	if u == "" {
1492		return true
1493	}
1494	if len(u) > maxImageLen {
1495		return false
1496	}
1497	if strings.ContainsAny(u, " \t\n\r|<>\"'`") {
1498		return false
1499	}
1500	if strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "http://") {
1501		return true
1502	}
1503	const prefix = "/samples/"
1504	if !strings.HasPrefix(u, prefix) {
1505		return false
1506	}
1507	rest := u[len(prefix):]
1508	if rest == "" {
1509		return false
1510	}
1511	return asciiSet(rest, "abcdefghijklmnopqrstuvwxyz0123456789._-")
1512}
1513
1514func asciiSet(s, allowed string) bool {
1515	for i := 0; i < len(s); i++ {
1516		if !strings.ContainsRune(allowed, rune(s[i])) {
1517			return false
1518		}
1519	}
1520	return true
1521}