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

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