views.gno
7.43 Kb · 220 lines
1package market
2
3import (
4 "strconv"
5 "strings"
6
7 "gno.land/p/nt/avl/v0"
8 "gno.land/p/nt/seqid/v0"
9 "gno.land/p/samcrew/launchpad/meta/v1"
10 sm "gno.land/p/samcrew/launchpad/safemath/v1"
11 "gno.land/r/samcrew/launchpad/config/v1"
12 "gno.land/r/samcrew/launchpad/nft/v1"
13)
14
15func num(n int64) string { return meta.JSONString(strconv.FormatInt(n, 10)) }
16
17// splitJSON is how a price would be divided at a protocol fee: what the
18// seller keeps, the treasury's fee and each royalty.
19func splitJSON(collection string, price, feeBPS int64) string {
20 fee := sm.BPS(price, feeBPS)
21 seller := sm.Sub(price, fee)
22 var b strings.Builder
23 for i, r := range nft.RoyaltyQuote(collection, price) {
24 if i > 0 {
25 b.WriteByte(',')
26 }
27 b.WriteString(`{"account":` + meta.JSONString(r.Account.String()) +
28 `,"amount":` + num(r.Amount) + "}")
29 seller = sm.Sub(seller, r.Amount)
30 }
31 return `{"seller":` + num(seller) + `,"fee":` + num(fee) +
32 `,"royalties":[` + b.String() + "]}"
33}
34
35func listingJSON(l *Listing) string {
36 return "{" +
37 `"id":` + meta.JSONString(l.ID) +
38 `,"collection":` + meta.JSONString(l.Collection) +
39 `,"number":` + num(l.Number) +
40 `,"seller":` + meta.JSONString(l.Seller.String()) +
41 `,"price":` + num(l.Price) +
42 `,"currency":` + meta.JSONString(l.Currency) +
43 `,"createdAt":` + num(l.CreatedAt) +
44 `,"expiresAt":` + num(l.ExpiresAt) +
45 `,"feeBPS":` + num(l.FeeBPS) +
46 `,"buyable":` + strconv.FormatBool(live(l)) +
47 `,"split":` + splitJSON(l.Collection, l.Price, l.FeeBPS) + "}"
48}
49
50func offerJSON(o *Offer) string {
51 return "{" +
52 `"id":` + meta.JSONString(o.ID) +
53 `,"kind":` + meta.JSONString(o.Kind) +
54 `,"collection":` + meta.JSONString(o.Collection) +
55 `,"number":` + num(o.Number) +
56 `,"trait":` + meta.JSONString(o.Trait) +
57 `,"buyer":` + meta.JSONString(o.Buyer.String()) +
58 `,"price":` + num(o.Price) +
59 `,"currency":` + meta.JSONString(o.Currency) +
60 `,"createdAt":` + num(o.CreatedAt) +
61 `,"expiresAt":` + num(o.ExpiresAt) +
62 `,"feeBPS":` + num(o.FeeBPS) +
63 `,"split":` + splitJSON(o.Collection, o.Price, o.FeeBPS) + "}"
64}
65
66// ListingJSON is an open listing, or "null" once it is closed.
67func ListingJSON(id string) string {
68 if l := findListing(id); l != nil {
69 return listingJSON(l)
70 }
71 return "null"
72}
73
74// OfferJSON is an open offer, or "null" once it is closed. An open offer can
75// be past its expiry: it then only waits for its refund.
76func OfferJSON(id string) string {
77 if o, _ := findOffer(id); o != nil {
78 return offerJSON(o)
79 }
80 return "null"
81}
82
83// Lists are read in slices of 1 to maxSlice entries that start after a
84// cursor: what identifies the last entry already read, or nothing to start
85// from the beginning. Orders come and go between two reads, and a cursor
86// never skips or repeats one that stayed. A slice costs the same wherever it
87// starts.
88const maxSlice = 50
89
90// sliceJSON renders up to size entries of tree whose keys lie in [from, to),
91// in key order, or from the last key down when reverse.
92func sliceJSON(tree *avl.Tree, from, to string, reverse bool, size int64, render func(v any) string) string {
93 if size < 1 || size > maxSlice {
94 panic("launchpad/market: invalid slice size")
95 }
96 var b strings.Builder
97 b.WriteByte('[')
98 visit := func(_ string, v any) bool {
99 if b.Len() > 1 {
100 b.WriteByte(',')
101 }
102 b.WriteString(render(v))
103 size--
104 return size == 0
105 }
106 if reverse {
107 tree.ReverseIterate(from, to, visit)
108 } else {
109 tree.Iterate(from, to, visit)
110 }
111 b.WriteByte(']')
112 return b.String()
113}
114
115// cursorKey reads an order cursor: the ID of the last order already read.
116func cursorKey(prefix byte, cursor string) seqid.ID {
117 key, ok := orderKey(prefix, cursor)
118 if !ok {
119 panic("launchpad/market: invalid cursor")
120 }
121 n, _ := seqid.FromString(key)
122 return n
123}
124
125// after is the first key an oldest-first slice may hold: the one following
126// the cursor, or "" with no cursor.
127func after(prefix byte, cursor string) string {
128 if cursor == "" {
129 return ""
130 }
131 return (cursorKey(prefix, cursor) + 1).String()
132}
133
134// below is the last key a newest-first slice may hold: the one preceding the
135// cursor, or "" with no cursor. No order has key zero, so the first order's
136// ID leaves nothing below it.
137func below(prefix byte, cursor string) string {
138 if cursor == "" {
139 return ""
140 }
141 return (cursorKey(prefix, cursor) - 1).String()
142}
143
144func renderListing(v any) string { return listingJSON(v.(*Listing)) }
145func renderOffer(v any) string { return offerJSON(v.(*Offer)) }
146
147// ListingsJSON reads every open listing, newest first, starting below the
148// listing ID before ("" starts from the newest).
149func ListingsJSON(before string, size int64) string {
150 // A descending range includes its upper bound.
151 return sliceJSON(&listings, "", below('L', before), true, size, renderListing)
152}
153
154// CollectionListingsJSON reads a collection's open listings in token number
155// order, starting after the token number afterNumber (0 starts from the
156// first).
157func CollectionListingsJSON(collection string, afterNumber, size int64) string {
158 if afterNumber < 0 {
159 panic("launchpad/market: invalid cursor")
160 }
161 // "0" is the byte after "/": the range ends with this collection's keys.
162 return sliceJSON(&listed, tokenKey(collection, sm.Add(afterNumber, 1)), collection+"0", false, size, renderListing)
163}
164
165// account checks an address argument of a view.
166func account(who string) string {
167 if !meta.ValidAddress(address(who)) {
168 panic("launchpad/market: invalid account")
169 }
170 return who
171}
172
173// SellerListingsJSON reads an account's open listings, oldest first, starting
174// after the listing ID afterID ("" starts from the oldest).
175func SellerListingsJSON(seller, afterID string, size int64) string {
176 return sliceJSON(&bySeller, account(seller)+"/"+after('L', afterID), seller+"0", false, size, renderListing)
177}
178
179// OffersJSON reads every open offer, newest first, starting below the offer
180// ID before ("" starts from the newest).
181func OffersJSON(before string, size int64) string {
182 return sliceJSON(&offers, "", below('O', before), true, size, renderOffer)
183}
184
185// CollectionOffersJSON reads a collection's open offers of every kind, oldest
186// first, starting after the offer ID afterID ("" starts from the oldest).
187func CollectionOffersJSON(collection, afterID string, size int64) string {
188 return sliceJSON(&open, collection+"/"+after('O', afterID), collection+"0", false, size, renderOffer)
189}
190
191// BuyerOffersJSON reads an account's open offers, oldest first, starting
192// after the offer ID afterID ("" starts from the oldest).
193func BuyerOffersJSON(buyer, afterID string, size int64) string {
194 return sliceJSON(&byBuyer, account(buyer)+"/"+after('O', afterID), buyer+"0", false, size, renderOffer)
195}
196
197// TokenListingJSON is the open listing of a token, or "null".
198func TokenListingJSON(collection string, number int64) string {
199 if l := listingOf(collection, number); l != nil {
200 return listingJSON(l)
201 }
202 return "null"
203}
204
205// TermsJSON is what a sale costs now: the protocol fee a new listing would
206// pin, and how a price would split for the collection. A fee of
207// -1 means the lane is closed; config's ActionStatusJSON tells whether the
208// market lane accepts new orders right now.
209func TermsJSON(collection string, price int64) string {
210 p := config.GetVersion(config.GetCurrentVersion())
211 split := "null"
212 if p.NFTSecondaryFeeBPS >= 0 && p.NFTSecondaryFeeBPS <= maxSecondaryFeeBPS {
213 split = splitJSON(collection, price, p.NFTSecondaryFeeBPS)
214 }
215 return "{" +
216 `"feeBPS":` + num(p.NFTSecondaryFeeBPS) +
217 `,"maxFeeBPS":` + num(maxSecondaryFeeBPS) +
218 `,"treasury":` + meta.JSONString(p.Treasury.String()) +
219 `,"split":` + split + "}"
220}