terms.gno
4.57 Kb · 150 lines
1package nft
2
3import (
4 "chain"
5 "strings"
6
7 sm "gno.land/p/samcrew/launchpad/safemath/v1"
8 "gno.land/r/samcrew/launchpad/config/v1"
9)
10
11// Hard limits, fixed for the life of this realm.
12const (
13 maxRoyaltyReceivers = 10
14 maxRoyaltyBPS = 1000 // 10% of a sale, across all receivers
15 maxMarkets = 5
16)
17
18// royalty is one receiver's share of every sale, in basis points.
19type royalty struct {
20 Account address
21 BPS int64
22}
23
24type RoyaltyPayout struct {
25 Account address
26 Amount int64
27}
28
29// parseBPS reads a positive decimal with no sign, space or leading zero.
30func parseBPS(s string) int64 {
31 if s == "" || len(s) > 4 || s[0] == '0' {
32 panic("launchpad/nft: invalid royalty share")
33 }
34 var n int64
35 for i := 0; i < len(s); i++ {
36 if s[i] < '0' || s[i] > '9' {
37 panic("launchpad/nft: invalid royalty share")
38 }
39 n = n*10 + int64(s[i]-'0')
40 }
41 return n
42}
43
44// parseRoyalties reads "address:bps;address:bps". Strictly ascending addresses
45// give every split exactly one encoding and rule out duplicates.
46func parseRoyalties(encoded, mode string) ([]royalty, int64) {
47 if encoded == "" {
48 if mode == ModeRoyaltyProtected {
49 panic("launchpad/nft: a royalty-protected collection needs royalties")
50 }
51 return nil, 0
52 }
53 if mode == ModeSoulbound {
54 panic("launchpad/nft: soulbound tokens are never sold")
55 }
56 records := strings.Split(encoded, ";")
57 if len(records) > maxRoyaltyReceivers {
58 panic("launchpad/nft: too many royalty receivers")
59 }
60 out := make([]royalty, 0, len(records))
61 previous := ""
62 var total int64
63 for _, record := range records {
64 fields := strings.Split(record, ":")
65 if len(fields) != 2 || fields[0] <= previous {
66 panic("launchpad/nft: invalid royalty list")
67 }
68 who := address(fields[0])
69 // Unspendable covers every text but the chain's spelling of an account.
70 if config.IsUnspendable(who) {
71 panic("launchpad/nft: invalid royalty receiver")
72 }
73 bps := parseBPS(fields[1])
74 if total = sm.Add(total, bps); total > maxRoyaltyBPS {
75 panic("launchpad/nft: royalties exceed 10%")
76 }
77 out = append(out, royalty{Account: who, BPS: bps})
78 previous = fields[0]
79 }
80 return out, total
81}
82
83// validName is the chain's rule for one segment of a package path: a letter
84// first, then letters and digits, joined by single "_" or "-".
85func validName(s string) bool {
86 if s == "" || s[0] < 'a' || s[0] > 'z' {
87 return false
88 }
89 joined := false // the previous character joins two runs
90 for i := 1; i < len(s); i++ {
91 switch c := s[i]; {
92 case (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9'):
93 joined = false
94 case (c == '_' || c == '-') && !joined:
95 joined = true
96 default:
97 return false
98 }
99 }
100 return !joined
101}
102
103// A market may be any realm the chain can host, a third party's included, so
104// its path follows the chain's own rule for a realm path: "gno.land/r/", then
105// names separated by "/", none ending in "_test", in at most 200 bytes. A slot
106// is permanent: none is given to a path no realm can have. The rule for
107// issuers is config's and is narrower (plain segments in our own namespace):
108// an issuer is one of ours, a market need not be.
109func validMarketPath(path string) bool {
110 if len(path) > 200 || !strings.HasPrefix(path, "gno.land/r/") || strings.HasSuffix(path, "_test") {
111 return false
112 }
113 for _, name := range strings.Split(path[len("gno.land/r/"):], "/") {
114 if !validName(name) {
115 return false
116 }
117 }
118 return true
119}
120
121func RoyaltyBPS(id string) int64 { return mustCollection(id).royaltyBPS }
122
123// RoyaltyQuote floors each receiver's share of price. The seller keeps the
124// rounding remainder.
125func RoyaltyQuote(id string, price int64) []RoyaltyPayout {
126 royalties := mustCollection(id).royalties
127 out := make([]RoyaltyPayout, len(royalties))
128 for i, r := range royalties {
129 out[i] = RoyaltyPayout{Account: r.Account, Amount: sm.BPS(price, r.BPS)}
130 }
131 return out
132}
133
134// AllowMarket adds a realm through which a royalty-protected collection's
135// tokens may be sold. The list starts with the Launchpad market and only
136// grows: a holder's way out can be widened, never taken away. The ledger
137// cannot make a realm pay: the creator vouches that the market it lists pays
138// this collection's royalties, and every holder can read the list.
139func AllowMarket(cur realm, id, pkgPath string) {
140 c := creatorOf(cur, id)
141 if c.info.Mode != ModeRoyaltyProtected || !validMarketPath(pkgPath) ||
142 len(c.markets) >= maxMarkets ||
143 operator(c, chain.PackageAddress(pkgPath)) {
144 panic("launchpad/nft: market cannot be added")
145 }
146 market := chain.PackageAddress(pkgPath)
147 c.markets = append(c.markets, market)
148 chain.Emit("MarketAllowed", "collection", id, "market", pkgPath,
149 "address", market.String())
150}