nomaq.gno
14.09 Kb · 646 lines
1package nomaq
2
3import (
4 "chain"
5 "chain/banker"
6 "chain/runtime/unsafe"
7 "strconv"
8 "strings"
9
10 "gno.land/p/nt/avl/v0"
11)
12
13const (
14 pkgPath = "gno.land/r/g1n4pl5uc4yt5r96m9w6fmdznx3x0jyg8l6arhmt/nomaq"
15 nativeDenom = "ugnot"
16 defaultLaunchFee = int64(1_000_000_000) // 1000 GNOT
17 maxNameLen = 64
18 maxImageLen = 200
19 maxSupplyBound = 1_000_000
20 maxRoyaltyBps = 1000
21 maxSymbolLen = 11
22 maxSlugLen = 11
23 minSlugLen = 2
24 defaultProtocolBps = int64(200) // 2% of the sale price
25 maxProtocolBps = int64(500)
26 // defaultGenesisShareBps is 25% of the protocol fee, not of the sale price.
27 // 10000 would send the whole protocol fee to GenesisSink.
28 defaultGenesisShareBps = int64(2500)
29 maxGenesisShareBps = int64(10000)
30)
31
32type collection struct {
33 slug string
34 name string
35 symbol string
36 cover string
37 pkg string
38 creator address
39 maxSupply int
40 mintPrice int64
41 minted int
42 royaltyBps int
43}
44
45type reservation struct {
46 who address
47 paid int64
48}
49
50var (
51 bySlug avl.Tree
52 byPkg avl.Tree
53 reserved avl.Tree
54 inited bool
55 admin address
56 self address
57 protocolSink address
58 launchFee int64
59 protocolBps int64
60 genesisPkg string
61 genesisSink address
62 genesisShareBps int64
63 discountPusher string
64 holderDisc avl.Tree
65)
66
67func Init(cur realm) {
68 if inited {
69 panic("factory: already initialized")
70 }
71 if !cur.Previous().IsUserCall() {
72 panic("factory: EOA only")
73 }
74 admin = cur.Previous().Address()
75 protocolSink = admin
76 self = cur.Address()
77 bySlug = avl.Tree{}
78 byPkg = avl.Tree{}
79 reserved = avl.Tree{}
80 holderDisc = avl.Tree{}
81 genesisPkg = ""
82 genesisShareBps = defaultGenesisShareBps
83 discountPusher = ""
84 var z address
85 genesisSink = z
86 launchFee = defaultLaunchFee
87 protocolBps = defaultProtocolBps
88 inited = true
89 chain.Emit("Init", "admin", admin.String())
90}
91
92func Address() address {
93 return chain.PackageAddress(pkgPath)
94}
95
96func LaunchFee() int64 {
97 requireInit()
98 return launchFee
99}
100
101func Admin() string {
102 requireInit()
103 return admin.String()
104}
105
106func SetLaunchFee(cur realm, ugnot int64) {
107 requireAdmin(cur)
108 if ugnot < 0 {
109 panic("factory: launch fee")
110 }
111 launchFee = ugnot
112 chain.Emit("SetLaunchFee", "ugnot", strconv.FormatInt(ugnot, 10))
113}
114
115func ProtocolBps() int64 {
116 requireInit()
117 if protocolBps <= 0 {
118 return defaultProtocolBps
119 }
120 return protocolBps
121}
122
123func SetProtocolBps(cur realm, bps int64) {
124 requireAdmin(cur)
125 if bps < 0 || bps > maxProtocolBps {
126 panic("factory: protocol bps")
127 }
128 protocolBps = bps
129 chain.Emit("SetProtocolBps", "bps", strconv.FormatInt(bps, 10))
130}
131
132func ProtocolSink() address {
133 requireInit()
134 return protocolSink
135}
136
137func SetProtocolSink(cur realm, next address) {
138 requireAdmin(cur)
139 if !next.IsValid() {
140 panic("factory: invalid address")
141 }
142 if next == protocolSink {
143 panic("factory: already sink")
144 }
145 protocolSink = next
146 chain.Emit("SetProtocolSink", "addr", next.String())
147}
148
149func GenesisPkg() string {
150 requireInit()
151 return genesisPkg
152}
153
154func GenesisSink() address {
155 requireInit()
156 return genesisSink
157}
158
159// GenesisShareBps is the holder share of the protocol fee, in bps of 10000.
160// It is 0 until a genesis package is attached. After that it returns the
161// stored value, including 0. The Init default is 2500 (25%).
162func GenesisShareBps() int64 {
163 requireInit()
164 if genesisPkg == "" {
165 return 0
166 }
167 return genesisShareBps
168}
169
170// SetGenesisShareBps changes the holder share for later sales.
171// bps is 0 through 10000. Only the admin EOA may call it.
172// Collection books must read GenesisShareBps at Buy time.
173func SetGenesisShareBps(cur realm, bps int64) {
174 requireAdmin(cur)
175 if bps < 0 || bps > maxGenesisShareBps {
176 panic("factory: genesis share")
177 }
178 genesisShareBps = bps
179 chain.Emit("SetGenesisShareBps", "bps", strconv.FormatInt(bps, 10))
180}
181
182func HolderDiscount(owner string) int64 {
183 requireInit()
184 if genesisPkg == "" {
185 return 0
186 }
187 v := holderDisc.Get(strings.ToLower(strings.TrimSpace(owner)))
188 if v == nil {
189 return 0
190 }
191 return v.(int64)
192}
193
194func AttachGenesis(cur realm) {
195 requireInit()
196 prev := cur.Previous()
197 if !prev.IsCode() {
198 panic("factory: genesis realm")
199 }
200 eoa := prev.Previous()
201 if !eoa.IsUserCall() || eoa.Address() != admin {
202 panic("factory: not admin")
203 }
204 pkg := prev.PkgPath()
205 if pkg == "" {
206 panic("factory: genesis pkg")
207 }
208 if genesisPkg != "" && genesisPkg != pkg {
209 panic("factory: genesis set")
210 }
211 genesisPkg = pkg
212 genesisSink = prev.Address()
213 chain.Emit("AttachGenesis", "pkg", pkg)
214}
215
216func SetGenesis(cur realm, pkg string) {
217 requireAdmin(cur)
218 pkg = strings.TrimSpace(pkg)
219 if pkg == "" || !strings.HasPrefix(pkg, "gno.land/r/") {
220 panic("factory: genesis pkg")
221 }
222 genesisPkg = pkg
223 genesisSink = chain.PackageAddress(pkg)
224 chain.Emit("SetGenesis", "pkg", pkg)
225}
226
227func SetDiscountPusher(cur realm, pkg string) {
228 requireAdmin(cur)
229 pkg = strings.TrimSpace(pkg)
230 if pkg == "" || !strings.HasPrefix(pkg, "gno.land/r/") {
231 panic("factory: pusher pkg")
232 }
233 discountPusher = pkg
234 chain.Emit("SetDiscountPusher", "pkg", pkg)
235}
236
237func DiscountPusher() string {
238 requireInit()
239 return discountPusher
240}
241
242func NoteHolderDiscount(cur realm, owner string, bps int64) {
243 requireInit()
244 prev := cur.Previous()
245 if !prev.IsCode() {
246 panic("factory: not genesis")
247 }
248 from := prev.PkgPath()
249 ok := (genesisPkg != "" && from == genesisPkg) || (discountPusher != "" && from == discountPusher)
250 if !ok {
251 panic("factory: not genesis")
252 }
253 if bps < 0 {
254 panic("factory: discount")
255 }
256 who := strings.ToLower(strings.TrimSpace(owner))
257 if who == "" {
258 panic("factory: owner")
259 }
260 if bps == 0 {
261 holderDisc.Remove(who)
262 return
263 }
264 holderDisc.Set(who, bps)
265}
266
267func Register(cur realm, name, symbol, cover string, maxSupply int, mintPrice, royaltyBps int64) {
268 requireInit()
269 prev := cur.Previous()
270 if !prev.IsCode() {
271 panic("factory: collection realm")
272 }
273 creatorRlm := prev.Previous()
274 if !creatorRlm.IsUserCall() {
275 panic("factory: EOA creator")
276 }
277 pkg := prev.PkgPath()
278 slug := lastElem(pkg)
279 res := mustReserve(slug)
280 if creatorRlm.Address() != res.who {
281 panic("factory: reserved")
282 }
283 reserved.Remove(slug)
284 register(pkg, creatorRlm.Address(), name, symbol, cover, maxSupply, mintPrice, royaltyBps)
285}
286
287func Reserve(cur realm, slug string) {
288 requireInit()
289 if !cur.Previous().IsUserCall() {
290 panic("factory: EOA only")
291 }
292 slug = strings.ToLower(strings.TrimSpace(slug))
293 if !validSlug(slug) {
294 panic("factory: slug")
295 }
296 if bySlug.Has(slug) || reserved.Has(slug) {
297 panic("factory: slug taken")
298 }
299 caller := cur.Previous().Address()
300 paid := reserveDue(caller)
301 takeFactorySend(paid)
302 reserved.Set(slug, &reservation{who: caller, paid: paid})
303 chain.Emit("Reserve", "slug", slug, "paid", strconv.FormatInt(paid, 10))
304}
305
306// ReserveDue is the ugnot Reserve will require from addr. The factory admin
307// (the wallet that called Init) pays 0. Everyone else pays LaunchFee.
308func ReserveDue(addr string) int64 {
309 requireInit()
310 who, ok := parseAddr(addr)
311 if !ok {
312 return launchFee
313 }
314 return reserveDue(who)
315}
316
317func reserveDue(caller address) int64 {
318 if caller == admin {
319 return 0
320 }
321 return launchFee
322}
323
324func parseAddr(raw string) (address, bool) {
325 s := strings.TrimSpace(raw)
326 if s == "" {
327 var zero address
328 return zero, false
329 }
330 who := address(s)
331 if !who.IsValid() {
332 var zero address
333 return zero, false
334 }
335 return who, true
336}
337
338func CancelReserve(cur realm, slug string) {
339 requireInit()
340 if !cur.Previous().IsUserCall() {
341 panic("factory: EOA only")
342 }
343 slug = strings.ToLower(strings.TrimSpace(slug))
344 res := mustReserve(slug)
345 if res.who != cur.Previous().Address() {
346 panic("factory: not reserver")
347 }
348 if bySlug.Has(slug) {
349 panic("factory: already registered")
350 }
351 reserved.Remove(slug)
352 if res.paid > 0 {
353 bk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
354 bk.SendCoins(self, res.who, chain.Coins{{Denom: nativeDenom, Amount: res.paid}})
355 }
356 chain.Emit("CancelReserve", "slug", slug, "paid", strconv.FormatInt(res.paid, 10))
357}
358
359func ReservedCreator(slug string) string {
360 requireInit()
361 slug = strings.ToLower(strings.TrimSpace(slug))
362 if !reserved.Has(slug) {
363 return ""
364 }
365 return reserved.Get(slug).(*reservation).who.String()
366}
367
368func takeFactorySend(want int64) {
369 sent := unsafe.OriginSend()
370 if want == 0 {
371 if len(sent) != 0 {
372 panic("factory: extra denoms")
373 }
374 return
375 }
376 if len(sent) != 1 || sent[0].Denom != nativeDenom || sent[0].Amount != want {
377 panic("factory: wrong payment")
378 }
379}
380
381func NoteMinted(cur realm, n int) {
382 requireInit()
383 if n < 0 {
384 panic("factory: minted")
385 }
386 prev := cur.Previous()
387 if !prev.IsCode() {
388 panic("factory: collection realm")
389 }
390 c := mustByPkg(prev.PkgPath())
391 c.minted = n
392 byPkg.Set(c.pkg, c)
393 bySlug.Set(c.slug, c)
394}
395
396func NoteRoyalty(cur realm, bps int) {
397 requireInit()
398 if bps < 0 || bps > maxRoyaltyBps {
399 panic("factory: royalty")
400 }
401 prev := cur.Previous()
402 if !prev.IsCode() {
403 panic("factory: collection realm")
404 }
405 c := mustByPkg(prev.PkgPath())
406 c.royaltyBps = bps
407 byPkg.Set(c.pkg, c)
408 bySlug.Set(c.slug, c)
409}
410
411func CollectionOf(slug string) string {
412 requireInit()
413 c := mustBySlug(slug)
414 return c.pkg
415}
416
417func CollectionAddr(slug string) string {
418 requireInit()
419 c := mustBySlug(slug)
420 return chain.PackageAddress(c.pkg).String()
421}
422
423func RoyaltyOf(slug string) int {
424 requireInit()
425 c := mustBySlug(slug)
426 return c.royaltyBps
427}
428
429func ListCollections() string {
430 requireInit()
431 out := ""
432 bySlug.Iterate("", "", func(_ string, value any) bool {
433 c := value.(*collection)
434 if out != "" {
435 out += "\n"
436 }
437 out += c.slug + "|" + c.name + "|" + c.cover + "|" + c.pkg + "|" + strconv.FormatInt(c.mintPrice, 10) + "|" + strconv.Itoa(c.maxSupply) + "|" + strconv.Itoa(c.minted) + "|" + c.creator.String() + "|" + chain.PackageAddress(c.pkg).String() + "|" + strconv.Itoa(c.royaltyBps)
438 return false
439 })
440 return out
441}
442
443func Withdraw(cur realm) {
444 requireAdmin(cur)
445 bk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
446 ugnot := bk.GetCoins(self).AmountOf(nativeDenom)
447 locked := reservedPaid()
448 avail := ugnot - locked
449 if avail <= 0 {
450 panic("factory: no funds")
451 }
452 bk.SendCoins(self, admin, chain.Coins{{Denom: nativeDenom, Amount: avail}})
453 chain.Emit("Withdraw", "amount", strconv.FormatInt(avail, 10))
454}
455
456func TransferAdmin(cur realm, next address) {
457 requireAdmin(cur)
458 if !next.IsValid() {
459 panic("factory: invalid address")
460 }
461 if next == admin {
462 panic("factory: already admin")
463 }
464 admin = next
465 chain.Emit("TransferAdmin", "addr", next.String())
466}
467
468func Render(_ string) string {
469 if !inited {
470 return "# Nomaq\n\nCall Init first.\n"
471 }
472 out := "# Nomaq\n\n"
473 out += "- launch fee: " + strconv.FormatInt(launchFee, 10) + " ugnot\n"
474 out += "- admin reserve: 0 ugnot\n"
475 out += "- holder share: " + strconv.FormatInt(GenesisShareBps(), 10) + " bps of the protocol fee\n"
476 out += "- collections: " + strconv.Itoa(bySlug.Size()) + "\n\n"
477 lines := ListCollections()
478 if lines == "" {
479 out += "_No collections._\n"
480 } else {
481 out += "```\n" + lines + "\n```\n"
482 }
483 return out
484}
485
486func register(pkg string, creator address, name, symbol, cover string, maxSupply int, mintPrice, royaltyBps int64) {
487 if !strings.HasPrefix(pkg, "gno.land/r/") {
488 panic("factory: pkg")
489 }
490 slug := lastElem(pkg)
491 if !validSlug(slug) {
492 panic("factory: slug")
493 }
494 if !validName(name) {
495 panic("factory: name")
496 }
497 if !validSymbol(symbol) {
498 panic("factory: symbol")
499 }
500 if !validImage(cover) {
501 panic("factory: cover")
502 }
503 if maxSupply < 0 || maxSupply > maxSupplyBound {
504 panic("factory: maxSupply")
505 }
506 if mintPrice < 0 {
507 panic("factory: mintPrice")
508 }
509 if royaltyBps < 0 || royaltyBps > maxRoyaltyBps {
510 panic("factory: royalty")
511 }
512 if bySlug.Has(slug) {
513 panic("factory: slug taken")
514 }
515 if byPkg.Has(pkg) {
516 panic("factory: pkg already registered")
517 }
518 c := &collection{
519 slug: slug,
520 name: name,
521 symbol: symbol,
522 cover: cover,
523 pkg: pkg,
524 creator: creator,
525 maxSupply: maxSupply,
526 mintPrice: mintPrice,
527 royaltyBps: int(royaltyBps),
528 }
529 bySlug.Set(slug, c)
530 byPkg.Set(pkg, c)
531 chain.Emit("Register", "slug", slug, "pkg", pkg, "creator", creator.String())
532}
533
534func requireInit() {
535 if !inited {
536 panic("factory: call Init first")
537 }
538}
539
540func requireAdmin(cur realm) {
541 requireInit()
542 if !cur.Previous().IsUserCall() {
543 panic("factory: EOA only")
544 }
545 if cur.Previous().Address() != admin {
546 panic("factory: not admin")
547 }
548}
549
550func mustReserve(slug string) *reservation {
551 v := reserved.Get(slug)
552 if v == nil {
553 panic("factory: not reserved")
554 }
555 return v.(*reservation)
556}
557
558func reservedPaid() int64 {
559 var n int64
560 reserved.Iterate("", "", func(_ string, value any) bool {
561 n += value.(*reservation).paid
562 return false
563 })
564 return n
565}
566
567func mustBySlug(slug string) *collection {
568 v := bySlug.Get(slug)
569 if v == nil {
570 panic("factory: unknown collection")
571 }
572 return v.(*collection)
573}
574
575func mustByPkg(pkg string) *collection {
576 v := byPkg.Get(pkg)
577 if v == nil {
578 panic("factory: unknown collection")
579 }
580 return v.(*collection)
581}
582
583func lastElem(pkg string) string {
584 i := strings.LastIndex(pkg, "/")
585 if i < 0 || i+1 >= len(pkg) {
586 panic("factory: pkg")
587 }
588 return pkg[i+1:]
589}
590
591func validSlug(s string) bool {
592 if len(s) < minSlugLen || len(s) > maxSlugLen {
593 return false
594 }
595 if s[0] < 'a' || s[0] > 'z' {
596 return false
597 }
598 return asciiSet(s, "abcdefghijklmnopqrstuvwxyz0123456789")
599}
600
601func validSymbol(s string) bool {
602 if len(s) < 1 || len(s) > maxSymbolLen {
603 return false
604 }
605 return asciiSet(s, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-")
606}
607
608func validName(name string) bool {
609 if len(name) < 1 || len(name) > maxNameLen {
610 return false
611 }
612 return !strings.ContainsAny(name, "\n\r|")
613}
614
615func validImage(u string) bool {
616 if u == "" {
617 return true
618 }
619 if len(u) > maxImageLen {
620 return false
621 }
622 if strings.ContainsAny(u, " \t\n\r|<>\"'`") {
623 return false
624 }
625 if strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "http://") {
626 return true
627 }
628 const prefix = "/samples/"
629 if !strings.HasPrefix(u, prefix) {
630 return false
631 }
632 rest := u[len(prefix):]
633 if rest == "" {
634 return false
635 }
636 return asciiSet(rest, "abcdefghijklmnopqrstuvwxyz0123456789._-")
637}
638
639func asciiSet(s, allowed string) bool {
640 for i := 0; i < len(s); i++ {
641 if !strings.ContainsRune(allowed, rune(s[i])) {
642 return false
643 }
644 }
645 return true
646}