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