edcol.gno
10.18 Kb · 428 lines
1// Package edcol is an edition collection. One id is one print run.
2// List escrows an amount on this realm. Buy can take part of that amount.
3// The book is efactory, not bazaarv5. Copies do not enter perk2.
4package edcol
5
6import (
7 "chain"
8 "chain/banker"
9 "chain/runtime/unsafe"
10 "math"
11 "strconv"
12 "strings"
13
14 "gno.land/p/g1n4pl5uc4yt5r96m9w6fmdznx3x0jyg8l6arhmt/bazaar/fee/v1"
15 "gno.land/p/g1n4pl5uc4yt5r96m9w6fmdznx3x0jyg8l6arhmt/bazaar/grc1155/v0"
16 "gno.land/p/nt/avl/v0"
17 "gno.land/r/g1n4pl5uc4yt5r96m9w6fmdznx3x0jyg8l6arhmt/bazaar/efactory"
18)
19
20const (
21 nativeDenom = "ugnot"
22 maxSupply = int64(1_000_000_000)
23 maxNameLen = 64
24 maxImageLen = 200
25)
26
27type edition struct {
28 name string
29 image string
30 max int64
31 minted int64
32 mintPrice int64
33}
34
35type listing struct {
36 amount int64
37 unit int64
38}
39
40var (
41 inited bool
42 creator address
43 self address
44 name_ string
45 symbol string
46 cover string
47 royalty int64
48 copies int64
49 ledger *grc1155.Ledger
50 editions avl.Tree
51 listings avl.Tree
52)
53
54func Init(cur realm, name, symbol_, cover_ string, royaltyBps int64) {
55 if inited {
56 panic("edcol: already")
57 }
58 if !cur.IsCurrent() || !cur.Previous().IsUserCall() {
59 panic("edcol: EOA only")
60 }
61 caller := cur.Previous().Address()
62 if !validName(name) || !validSymbol(symbol_) || !validImage(cover_) {
63 panic("edcol: meta")
64 }
65 creator = caller
66 self = cur.Address()
67 name_ = name
68 symbol = symbol_
69 cover = cover_
70 royalty = royaltyBps
71 copies = 0
72 ledger = grc1155.New(0, cur)
73 editions = avl.Tree{}
74 listings = avl.Tree{}
75 efactory.Register(cross(cur), name, symbol_, cover_, royaltyBps)
76 inited = true
77}
78
79// Create opens an edition. Only the creator can call it. max is the copy cap.
80// mintPrice 0 means only the creator can mint.
81func Create(cur realm, id, name, image string, max, mintPrice int64) {
82 caller := requireUser(cur)
83 if caller != creator {
84 panic("edcol: creator")
85 }
86 if !validID(id) || !validName(name) || !validImage(image) {
87 panic("edcol: meta")
88 }
89 if max <= 0 || max > maxSupply || mintPrice < 0 {
90 panic("edcol: supply")
91 }
92 if editions.Has(id) {
93 panic("edcol: exists")
94 }
95 editions.Set(id, &edition{name: name, image: image, max: max, mintPrice: mintPrice})
96 chain.Emit("EditionCreate", "id", id, "max", strconv.FormatInt(max, 10))
97}
98
99// Mint adds copies. The creator mints without payment. Anyone else pays mintPrice each.
100func Mint(cur realm, id string, to address, amount int64) {
101 caller := requireUser(cur)
102 ed := mustEdition(id)
103 if !to.IsValid() || to == self || amount <= 0 {
104 panic("edcol: amount")
105 }
106 if amount > ed.max || ed.minted > ed.max-amount {
107 panic("edcol: supply")
108 }
109 if caller == creator {
110 takeExact(0)
111 } else {
112 if ed.mintPrice <= 0 {
113 panic("edcol: creator")
114 }
115 takeExact(mul(ed.mintPrice, amount))
116 }
117 ledger.Mint(0, cur, to, id, amount)
118 ed.minted += amount
119 copies += amount
120 editions.Set(id, ed)
121 efactory.NoteMinted(cross(cur), copies)
122 if caller != creator {
123 bk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
124 bk.SendCoins(self, creator, chain.Coins{{Denom: nativeDenom, Amount: mul(ed.mintPrice, amount)}})
125 }
126 chain.Emit("EditionMint", "id", id, "to", to.String(), "amount", strconv.FormatInt(amount, 10))
127}
128
129// List escrows amount of id at a unit price. One open listing per seller and id.
130func List(cur realm, id string, amount, unitPrice int64) {
131 caller := requireUser(cur)
132 mustEdition(id)
133 if amount <= 0 || unitPrice <= 0 {
134 panic("edcol: price")
135 }
136 key := listKey(caller, id)
137 if listings.Has(key) {
138 panic("edcol: listed")
139 }
140 if ledger.Balance(caller, id) < amount {
141 panic("edcol: balance")
142 }
143 ledger.Move(0, cur, caller, self, id, amount)
144 listings.Set(key, &listing{amount: amount, unit: unitPrice})
145 chain.Emit("EditionList", "id", id, "seller", caller.String(), "amount", strconv.FormatInt(amount, 10), "price", strconv.FormatInt(unitPrice, 10))
146}
147
148func Cancel(cur realm, id string) {
149 caller := requireUser(cur)
150 key := listKey(caller, id)
151 row := listings.Get(key)
152 if row == nil {
153 panic("edcol: not listed")
154 }
155 hold := row.(*listing)
156 ledger.Move(0, cur, self, caller, id, hold.amount)
157 listings.Remove(key)
158 chain.Emit("EditionCancel", "id", id, "seller", caller.String())
159}
160
161// Buy takes amount copies from seller's listing. Payment is unit price times amount.
162func Buy(cur realm, seller address, id string, amount int64) {
163 caller := requireUser(cur)
164 if !seller.IsValid() || caller == seller || amount <= 0 {
165 panic("edcol: amount")
166 }
167 key := listKey(seller, id)
168 row := listings.Get(key)
169 if row == nil {
170 panic("edcol: not listed")
171 }
172 hold := row.(*listing)
173 if amount > hold.amount {
174 panic("edcol: amount")
175 }
176 price := mul(hold.unit, amount)
177 takeExact(price)
178 sellerAmt, royal, sinkAmt := split(price)
179 pay(cur, seller, sellerAmt, royal, sinkAmt)
180 ledger.Move(0, cur, self, caller, id, amount)
181 hold.amount -= amount
182 if hold.amount == 0 {
183 listings.Remove(key)
184 } else {
185 listings.Set(key, hold)
186 }
187 chain.Emit("EditionBuy", "id", id, "buyer", caller.String(), "seller", seller.String(), "amount", strconv.FormatInt(amount, 10))
188}
189
190func Transfer(cur realm, to address, id string, amount int64) {
191 caller := requireUser(cur)
192 if !to.IsValid() || to == caller || to == self || amount <= 0 {
193 panic("edcol: amount")
194 }
195 mustEdition(id)
196 if ledger.Balance(caller, id) < amount {
197 panic("edcol: balance")
198 }
199 ledger.Move(0, cur, caller, to, id, amount)
200}
201
202func BalanceOf(addr string, id string) int64 {
203 who, ok := parseAddr(addr)
204 if !ok {
205 return 0
206 }
207 return ledger.Balance(who, id)
208}
209
210func Supply(id string) int64 {
211 if ledger == nil {
212 return 0
213 }
214 return ledger.Supply(id)
215}
216
217func TokenURI(id string) string {
218 ed := mustEdition(id)
219 body := `{"name":` + jsonStr(ed.name) + `,"image":` + jsonStr(ed.image) + `,"edition":` + jsonStr(id) + `}`
220 return "data:application/json;charset=utf-8," + pctURI(body)
221}
222
223func Listing(seller, id string) string {
224 who, ok := parseAddr(seller)
225 if !ok {
226 return ""
227 }
228 row := listings.Get(listKey(who, id))
229 if row == nil {
230 return ""
231 }
232 hold := row.(*listing)
233 return strconv.FormatInt(hold.amount, 10) + "|" + strconv.FormatInt(hold.unit, 10)
234}
235
236func Name() string { return name_ }
237func Symbol() string { return symbol }
238
239// Editions is id|name|image|max|minted|mintPrice, one edition per line.
240func Editions() string {
241 if !inited {
242 return ""
243 }
244 out := ""
245 editions.Iterate("", "", func(key string, value any) bool {
246 ed := value.(*edition)
247 if out != "" {
248 out += "\n"
249 }
250 out += key + "|" + ed.name + "|" + ed.image + "|" +
251 strconv.FormatInt(ed.max, 10) + "|" +
252 strconv.FormatInt(ed.minted, 10) + "|" +
253 strconv.FormatInt(ed.mintPrice, 10)
254 return false
255 })
256 return out
257}
258
259// OpenListings is seller|id|amount|unitPrice, one listing per line.
260func OpenListings() string {
261 out := ""
262 listings.Iterate("", "", func(key string, value any) bool {
263 hold := value.(*listing)
264 if out != "" {
265 out += "\n"
266 }
267 out += key + "|" + strconv.FormatInt(hold.amount, 10) + "|" + strconv.FormatInt(hold.unit, 10)
268 return false
269 })
270 return out
271}
272
273func requireUser(cur realm) address {
274 if !inited {
275 panic("edcol: init")
276 }
277 if !cur.IsCurrent() {
278 panic("edcol: spoofed realm")
279 }
280 if !cur.Previous().IsUserCall() {
281 panic("edcol: EOA only")
282 }
283 return cur.Previous().Address()
284}
285
286func mustEdition(id string) *edition {
287 v := editions.Get(id)
288 if v == nil {
289 panic("edcol: unknown id")
290 }
291 return v.(*edition)
292}
293
294func listKey(seller address, id string) string {
295 return seller.String() + "|" + id
296}
297
298func split(price int64) (sellerAmt, royal, sinkAmt int64) {
299 proto := fee.ProtocolFee(price, efactory.ProtocolBps())
300 if royalty > 0 {
301 royal = fee.ProtocolFee(price, royalty)
302 }
303 if proto+royal > price {
304 panic("edcol: fee")
305 }
306 sellerAmt = price - proto - royal
307 sinkAmt = proto
308 if sinkAmt > 0 && !efactory.ProtocolSink().IsValid() {
309 panic("edcol: sink")
310 }
311 return
312}
313
314func pay(cur realm, seller address, sellerAmt, royal, sinkAmt int64) {
315 bk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
316 if sellerAmt > 0 {
317 bk.SendCoins(self, seller, chain.Coins{{Denom: nativeDenom, Amount: sellerAmt}})
318 }
319 if royal > 0 {
320 bk.SendCoins(self, creator, chain.Coins{{Denom: nativeDenom, Amount: royal}})
321 }
322 if sinkAmt > 0 {
323 bk.SendCoins(self, efactory.ProtocolSink(), chain.Coins{{Denom: nativeDenom, Amount: sinkAmt}})
324 }
325}
326
327func takeExact(want int64) {
328 sent := unsafe.OriginSend()
329 if want == 0 {
330 if len(sent) != 0 {
331 panic("edcol: extra payment")
332 }
333 return
334 }
335 if len(sent) != 1 || sent[0].Denom != nativeDenom || sent[0].Amount != want {
336 panic("edcol: wrong payment")
337 }
338}
339
340func mul(a, b int64) int64 {
341 if a < 0 || b <= 0 {
342 panic("edcol: amount")
343 }
344 if a == 0 {
345 return 0
346 }
347 if a > math.MaxInt64/b {
348 panic("edcol: overflow")
349 }
350 return a * b
351}
352
353func parseAddr(raw string) (address, bool) {
354 s := strings.TrimSpace(raw)
355 if s == "" {
356 return address(""), false
357 }
358 who := address(s)
359 if !who.IsValid() {
360 return address(""), false
361 }
362 return who, true
363}
364
365func validID(id string) bool {
366 if len(id) < 1 || len(id) > 16 || id[0] < 'a' || id[0] > 'z' {
367 return false
368 }
369 for i := 0; i < len(id); i++ {
370 c := id[i]
371 if (c < 'a' || c > 'z') && (c < '0' || c > '9') {
372 return false
373 }
374 }
375 return true
376}
377
378func validName(name string) bool {
379 if len(name) < 1 || len(name) > maxNameLen {
380 return false
381 }
382 return !strings.ContainsAny(name, "\n\r|\"\\")
383}
384
385func validSymbol(s string) bool {
386 if len(s) < 1 || len(s) > 11 {
387 return false
388 }
389 for i := 0; i < len(s); i++ {
390 c := s[i]
391 if (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') && (c < '0' || c > '9') {
392 return false
393 }
394 }
395 return true
396}
397
398func validImage(u string) bool {
399 if u == "" {
400 return true
401 }
402 if len(u) > maxImageLen || strings.ContainsAny(u, " \t\n\r|<>\"'`") {
403 return false
404 }
405 if strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "http://") {
406 return true
407 }
408 rest := strings.TrimPrefix(u, "/samples/")
409 return strings.HasPrefix(u, "/samples/") && rest != "" && rest != u
410}
411
412func jsonStr(s string) string {
413 return `"` + s + `"`
414}
415
416func pctURI(s string) string {
417 hex := "0123456789ABCDEF"
418 out := ""
419 for i := 0; i < len(s); i++ {
420 c := s[i]
421 if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~' {
422 out += string(rune(c))
423 continue
424 }
425 out += "%" + string([]byte{hex[c>>4], hex[c&15]})
426 }
427 return out
428}