market.gno
11.72 Kb · 395 lines
1// Package market is the platform's immutable fixed-price market
2// (design §2.5.4). It never holds a token: the seller approves this
3// realm in core, the token stays in the seller's wallet, and Buy moves it
4// through core's official grc721 RealmTeller in the same transaction that
5// pays everyone. It never holds coins either (I-3).
6//
7// Stale listings (RA H-2) cannot be bought: a listing pins the token's
8// move counter at listing time, and Buy requires owner == seller and the
9// counter unchanged (I-8). A listing that went stale never blocks the new
10// owner: List overwrites it and anyone may CancelStale it.
11//
12// Pause ("trade" in core) stops List and Buy only. Cancel, CancelStale and
13// UpdatePrice are never paused (I-2).
14package market
15
16import (
17 "chain"
18 "chain/banker"
19 "chain/runtime"
20 "chain/runtime/unsafe"
21 "strconv"
22
23 "gno.land/p/nt/avl/v0"
24 "gno.land/p/nt/grc721/v0"
25
26 "gno.land/p/g19dzk54hhsdc3gllpksuj6gu4a3e6fl8cr6rn4w/minhwa/safe/v1"
27 "gno.land/p/g19dzk54hhsdc3gllpksuj6gu4a3e6fl8cr6rn4w/minhwa/types/v1"
28 "gno.land/r/g19dzk54hhsdc3gllpksuj6gu4a3e6fl8cr6rn4w/minhwa/core"
29)
30
31const (
32 MaxPageSize = 50
33 idWidth = 12
34 priceWidth = 16 // types.MaxUnitPrice has 16 digits
35)
36
37type listing struct {
38 id int64
39 cid, tid string
40 seller address
41 price int64
42 moves int64 // core.TokenMoves at listing time
43 height int64
44}
45
46var (
47 listings avl.Tree // padded id -> *listing
48 byToken avl.Tree // cid:tid -> id
49 byCollection avl.Tree // cid:paddedPrice:paddedID -> id (floor = first key)
50 bySeller avl.Tree // seller:paddedID -> id
51 nextID int64
52 selfPath string // captured in init for read paths without a cur
53)
54
55func init(cur realm) {
56 selfPath = cur.PkgPath()
57}
58
59// List offers a token of a Final collection at price (ugnot). The caller
60// must own it and have approved this realm (Approve or SetApprovalForAll in
61// core). A stale listing of the same token is replaced.
62func List(cur realm, cid, tid string, price int64) string {
63 assertNoDeposit(cur)
64 assertTradeOpen()
65 assertPrice(price)
66 seller := cur.Previous().Address()
67 if core.Info(cid).State != types.StateFinal {
68 panic("tokens of this collection are locked until it is final")
69 }
70 owner, err := core.OwnerOf(cid, tid)
71 if err != nil {
72 panic(err)
73 }
74 if owner != seller {
75 panic("only the token owner can list it")
76 }
77 if !approved(cid, tid, owner, cur.Address()) {
78 panic("approve the market for this token first: core.Approve(cid, tid, " + cur.Address().String() + ")")
79 }
80 if v := byToken.Get(tokenKey(cid, tid)); v != nil {
81 old := mustListing(v.(int64))
82 if live(old, cur.Address()) {
83 panic("token is already listed as #" + itoa(old.id) + "; use UpdatePrice")
84 }
85 remove(old, "stale")
86 }
87
88 nextID++
89 l := &listing{
90 id: nextID, cid: cid, tid: tid, seller: seller, price: price,
91 moves: core.TokenMoves(cid, tid), height: runtime.ChainHeight(),
92 }
93 listings.Set(safe.PadInt(l.id, idWidth), l)
94 byToken.Set(tokenKey(cid, tid), l.id)
95 byCollection.Set(collectionKey(l), l.id)
96 bySeller.Set(sellerKey(l), l.id)
97 chain.Emit("Listed", "id", itoa(l.id), "cid", cid, "tid", tid, "seller", seller.String(), "price", itoa(price))
98 return itoa(l.id)
99}
100
101// UpdatePrice changes the price of a live listing; Buy's expectedPrice
102// protects buyers from a change racing their purchase (RA M-10).
103func UpdatePrice(cur realm, listingID string, price int64) {
104 assertNoDeposit(cur)
105 assertPrice(price)
106 l := mustListing(parseID(listingID))
107 assertSeller(cur, l)
108 if !live(l, cur.Address()) {
109 panic("listing is stale; cancel it and list again")
110 }
111 byCollection.Remove(collectionKey(l))
112 l.price = price
113 byCollection.Set(collectionKey(l), l.id)
114 chain.Emit("PriceUpdated", "id", listingID, "cid", l.cid, "tid", l.tid, "price", itoa(price))
115}
116
117// Cancel removes the caller's listing. Never paused.
118func Cancel(cur realm, listingID string) {
119 assertNoDeposit(cur)
120 l := mustListing(parseID(listingID))
121 assertSeller(cur, l)
122 remove(l, "cancelled")
123}
124
125// CancelStale lets anyone remove a listing that can no longer be bought.
126func CancelStale(cur realm, listingID string) {
127 assertNoDeposit(cur)
128 l := mustListing(parseID(listingID))
129 if live(l, cur.Address()) {
130 panic("listing is still valid")
131 }
132 remove(l, "stale")
133}
134
135// Buy pays exactly expectedPrice for the listing (excess attached ugnot is
136// refunded). Order: checks, remove the listing, move the token, then pay —
137// protocol fee to the treasury, royalty to the collection's sub-treasury
138// (then distributed to its payees), the rest to the seller. I-4:
139// fee + royalty + seller + refund == attached.
140func Buy(cur realm, listingID string, expectedPrice int64) {
141 paid := received(cur)
142 assertTradeOpen()
143 buyer := cur.Previous().Address()
144 l := mustListing(parseID(listingID))
145 if l.price != expectedPrice {
146 panic("price is " + itoa(l.price) + "ugnot, expected " + itoa(expectedPrice))
147 }
148 if paid < l.price {
149 panic("send " + itoa(l.price) + "ugnot, sent " + itoa(paid))
150 }
151 if buyer == l.seller {
152 panic("cannot buy your own listing")
153 }
154 if !live(l, cur.Address()) {
155 panic("listing is stale: the token moved or approval was revoked since it was listed")
156 }
157 remove(l, "sold")
158
159 tok := core.Token(l.cid)
160 if err := tok.RealmTeller(0, cur).TransferFrom(0, cur, l.seller, buyer, grc721.TokenID(l.tid)); err != nil {
161 panic(err)
162 }
163
164 fee := core.FeeConfig()
165 protocol := safe.BpsOf(l.price, fee.SecondaryBps)
166 royaltyTo, royalty, err := core.Royalty(l.cid).RoyaltyInfo(grc721.TokenID(l.tid), l.price)
167 if err != nil {
168 panic(err)
169 }
170 pay(cur, fee.Treasury, protocol)
171 pay(cur, royaltyTo, royalty)
172 pay(cur, l.seller, l.price-protocol-royalty)
173 pay(cur, buyer, paid-l.price)
174 if royalty > 0 {
175 core.DistributeRoyalties(cross(cur), l.cid)
176 }
177 chain.Emit("Bought", "id", listingID, "cid", l.cid, "tid", l.tid,
178 "seller", l.seller.String(), "buyer", buyer.String(), "price", itoa(l.price),
179 "protocolFee", itoa(protocol), "royalty", itoa(royalty))
180}
181
182// --- reads ---------------------------------------------------------------------
183
184func Listing(listingID string) string {
185 l := mustListing(parseID(listingID))
186 w := &safe.JSONWriter{}
187 writeListing(w, l)
188 return w.String()
189}
190
191// ListingOf is the live listing of a token as JSON, or "null".
192func ListingOf(cid, tid string) string {
193 v := byToken.Get(tokenKey(cid, tid))
194 if v == nil {
195 return "null"
196 }
197 l := mustListing(v.(int64))
198 if !live(l, selfAddress()) {
199 return "null"
200 }
201 w := &safe.JSONWriter{}
202 writeListingLive(w, l, true)
203 return w.String()
204}
205
206// ActiveListing is the typed read for other realms: the live listing of a
207// token, if any.
208func ActiveListing(cid, tid string) (id string, price int64, ok bool) {
209 v := byToken.Get(tokenKey(cid, tid))
210 if v == nil {
211 return "", 0, false
212 }
213 l := mustListing(v.(int64))
214 if !live(l, selfAddress()) {
215 return "", 0, false
216 }
217 return itoa(l.id), l.price, true
218}
219
220// ListingsByCollection lists by ascending price. Stale listings are
221// included with "live":false so pages stay stable; frontends hide them.
222func ListingsByCollection(cid string, offset, limit int64) string {
223 return listPrefix(&byCollection, cid+":", offset, limit)
224}
225
226func ListingsBySeller(seller address, offset, limit int64) string {
227 return listPrefix(&bySeller, seller.String()+":", offset, limit)
228}
229
230// Floor is the lowest live price in cid, or -1. It inspects at most
231// MaxPageSize listings from the cheapest, so spam cannot make it unbounded.
232func Floor(cid string) int64 {
233 floor := int64(-1)
234 seen := 0
235 prefix := cid + ":"
236 self := selfAddress()
237 byCollection.Iterate(prefix, prefixEnd(prefix), func(_ string, v any) bool {
238 seen++
239 l := mustListing(v.(int64))
240 if live(l, self) {
241 floor = l.price
242 return true
243 }
244 return seen >= MaxPageSize
245 })
246 return floor
247}
248
249func Render(path string) string {
250 return "# market/v1\n\n" + itoa(int64(listings.Size())) + " open listings. " +
251 "Read them with `ListingsByCollection` or browse the gallery.\n"
252}
253
254// --- internals -------------------------------------------------------------------
255
256// live: still owned by the seller, not moved since listing, still approved.
257func live(l *listing, market address) bool {
258 owner, err := core.OwnerOf(l.cid, l.tid)
259 if err != nil || owner != l.seller {
260 return false
261 }
262 return core.TokenMoves(l.cid, l.tid) == l.moves && approved(l.cid, l.tid, owner, market)
263}
264
265func approved(cid, tid string, owner, market address) bool {
266 tok := core.Token(cid)
267 if tok.IsApprovedForAll(owner, market) {
268 return true
269 }
270 a, err := tok.GetApproved(grc721.TokenID(tid))
271 return err == nil && a == market
272}
273
274// remove deletes a listing and its indexes; closed listings are not kept
275// (RA L-6), the "Cancelled" event (reason sold/cancelled/stale) is the record.
276func remove(l *listing, reason string) {
277 listings.Remove(safe.PadInt(l.id, idWidth))
278 byToken.Remove(tokenKey(l.cid, l.tid))
279 byCollection.Remove(collectionKey(l))
280 bySeller.Remove(sellerKey(l))
281 chain.Emit("Cancelled", "id", itoa(l.id), "cid", l.cid, "tid", l.tid, "reason", reason)
282}
283
284func listPrefix(index *avl.Tree, prefix string, offset, limit int64) string {
285 if limit <= 0 || limit > MaxPageSize {
286 limit = MaxPageSize
287 }
288 self := selfAddress()
289 w := &safe.JSONWriter{}
290 w.BeginArray()
291 var skipped, written int64
292 index.Iterate(prefix, prefixEnd(prefix), func(_ string, v any) bool {
293 if skipped < offset {
294 skipped++
295 return false
296 }
297 l := mustListing(v.(int64))
298 writeListingLive(w, l, live(l, self))
299 written++
300 return written >= limit
301 })
302 w.EndArray()
303 return w.String()
304}
305
306func writeListing(w *safe.JSONWriter, l *listing) {
307 writeListingLive(w, l, live(l, selfAddress()))
308}
309
310func writeListingLive(w *safe.JSONWriter, l *listing, isLive bool) {
311 w.BeginObject()
312 w.StrField("id", itoa(l.id))
313 w.StrField("cid", l.cid)
314 w.StrField("tid", l.tid)
315 w.StrField("seller", l.seller.String())
316 w.IntField("price", l.price)
317 w.IntField("moves", l.moves)
318 w.IntField("height", l.height)
319 w.BoolField("live", isLive)
320 w.EndObject()
321}
322
323func mustListing(id int64) *listing {
324 v := listings.Get(safe.PadInt(id, idWidth))
325 if v == nil {
326 panic("unknown listing " + itoa(id))
327 }
328 return v.(*listing)
329}
330
331func assertSeller(cur realm, l *listing) {
332 if cur.Previous().Address() != l.seller {
333 panic("only the seller can do this")
334 }
335}
336
337func assertTradeOpen() {
338 if core.Paused("trade") {
339 panic("paused: trade")
340 }
341}
342
343func assertPrice(price int64) {
344 if price <= 0 || price > types.MaxUnitPrice {
345 panic("price must be 1.." + itoa(types.MaxUnitPrice) + " ugnot")
346 }
347}
348
349func received(cur realm) int64 {
350 if !cur.Previous().IsUserCall() {
351 panic("payments must come directly from a wallet, not through another realm")
352 }
353 sent := unsafe.OriginSend()
354 for _, c := range sent {
355 if c.Denom != "ugnot" && c.Amount != 0 {
356 panic("only ugnot is accepted")
357 }
358 }
359 return sent.AmountOf("ugnot")
360}
361
362func assertNoDeposit(cur realm) {
363 if cur.Previous().IsUserCall() && !unsafe.OriginSend().IsZero() {
364 panic("this function does not accept coins")
365 }
366}
367
368func pay(cur realm, to address, amount int64) {
369 if amount <= 0 {
370 return
371 }
372 banker.NewBanker(banker.BankerTypeRealmSend, cur).
373 SendCoins(cur.Address(), to, chain.NewCoins(chain.NewCoin("ugnot", amount)))
374}
375
376func tokenKey(cid, tid string) string { return cid + ":" + tid }
377
378func collectionKey(l *listing) string {
379 return l.cid + ":" + safe.PadInt(l.price, priceWidth) + ":" + safe.PadInt(l.id, idWidth)
380}
381
382func sellerKey(l *listing) string { return l.seller.String() + ":" + safe.PadInt(l.id, idWidth) }
383
384// prefixEnd is the smallest key greater than every key with this prefix
385// (prefixes here end in ':', so bump it to ';').
386func prefixEnd(prefix string) string {
387 return prefix[:len(prefix)-1] + string(rune(prefix[len(prefix)-1]+1))
388}
389
390func parseID(s string) int64 { return safe.ParseID("listingID", s) }
391
392func itoa(n int64) string { return strconv.FormatInt(n, 10) }
393
394// selfAddress is this realm's address for read paths that have no cur.
395func selfAddress() address { return chain.PackageAddress(selfPath) }