Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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) }