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

market.gno

9.10 Kb · 250 lines
  1// Package market trades Launchpad NFTs: fixed-price listings and funded
  2// offers. A sale pays the seller, the treasury and the collection's royalty
  3// receivers inside the same transaction. The realm holds only the escrow of
  4// open offers, which their buyers can always take back, and has no admin. It
  5// keeps open orders only: a closed one is deleted, so its storage deposit is
  6// released, and its history is the event log.
  7package market
  8
  9import (
 10	"chain"
 11	"strconv"
 12	"time"
 13
 14	"gno.land/p/nt/avl/v0"
 15	"gno.land/p/nt/seqid/v0"
 16	"gno.land/p/samcrew/launchpad/currency/v1"
 17	"gno.land/r/samcrew/launchpad/nft/v1"
 18)
 19
 20const (
 21	marketPath = "gno.land/r/samcrew/launchpad/market/v1"
 22
 23	// Why an order closed, as its closing event says.
 24	StatusFilled    = "filled"
 25	StatusCancelled = "cancelled"
 26	StatusReplaced  = "replaced"
 27	StatusExpired   = "expired"
 28
 29	// Hard limits, fixed for the life of this realm.
 30	maxSecondaryFeeBPS = 200 // 2% of a sale price
 31	maxLifetime        = 90 * 24 * 60 * 60
 32	// How long after its expiry an order is still its owner's alone to close.
 33	// The chain returns the storage deposit of the bytes a call frees to that
 34	// call's sender (while ugnot is not a restricted denomination): the grace
 35	// keeps a stranger from taking a closed order's deposit at the very second
 36	// it expires, and leaves abandoned orders to whoever cleans them up.
 37	graceToClose = 7 * 24 * 60 * 60
 38	maxOrders    = 9_999_999_999
 39)
 40
 41// Listing offers one token at a fixed price. The seller keeps the token and
 42// approves this realm; nothing is held here. A listing exists while it is
 43// open and no longer, and can be bought only within the token's custody epoch
 44// it was made in.
 45type Listing struct {
 46	ID         string // "L<n>"
 47	Collection string
 48	Number     int64
 49	Seller     address
 50	Price      int64
 51	Currency   string
 52	CreatedAt  int64
 53	ExpiresAt  int64
 54	FeeBPS     int64  // protocol fee, pinned when the seller signed
 55	key        string // its key in listings
 56	epoch      int64  // the token's custody epoch when it was listed
 57}
 58
 59var (
 60	lastListing seqid.ID
 61	listings    avl.Tree // listing key -> *Listing, in creation order
 62	listed      avl.Tree // "<collection>/<token number key>" -> *Listing
 63	bySeller    avl.Tree // "<seller>/<listing key>" -> *Listing
 64)
 65
 66func directCaller(cur realm) address {
 67	if !cur.IsCurrent() || !cur.Previous().IsUserCall() {
 68		panic("launchpad/market: direct user call required")
 69	}
 70	return cur.Previous().Address()
 71}
 72
 73// orderKey reads an order ID, its prefix ('L' listing, 'O' offer) then its
 74// number, as its tree key.
 75func orderKey(prefix byte, id string) (string, bool) {
 76	if len(id) < 2 || len(id) > 11 || id[0] != prefix || id[1] == '0' {
 77		return "", false
 78	}
 79	n, err := strconv.ParseUint(id[1:], 10, 64)
 80	if err != nil {
 81		return "", false
 82	}
 83	return seqid.ID(n).String(), true
 84}
 85
 86func nextOrder(last *seqid.ID, prefix string) (string, string) {
 87	if uint64(*last) >= maxOrders {
 88		panic("launchpad/market: order ID exhausted")
 89	}
 90	n := last.Next()
 91	return prefix + strconv.FormatUint(uint64(n), 10), n.String()
 92}
 93
 94// findListing returns an open listing, or nil: a closed one is gone.
 95func findListing(id string) *Listing {
 96	if key, ok := orderKey('L', id); ok {
 97		if l := listings.Get(key); l != nil {
 98			return l.(*Listing)
 99		}
100	}
101	return nil
102}
103
104func mustListing(id string) *Listing {
105	if l := findListing(id); l != nil {
106		return l
107	}
108	panic("launchpad/market: unknown listing")
109}
110
111// tokenKey sorts a collection's tokens in number order.
112func tokenKey(collection string, number int64) string {
113	return collection + "/" + seqid.ID(number).String()
114}
115
116// listingOf returns the open listing of a token, or nil.
117func listingOf(collection string, number int64) *Listing {
118	if l := listed.Get(tokenKey(collection, number)); l != nil {
119		return l.(*Listing)
120	}
121	return nil
122}
123
124// sellable reports whether seller owns the active token and has approved this
125// realm for that token. Only a single-token approval counts, as an approval
126// for all would outlive the token's moves.
127func sellable(collection string, number int64, seller address) bool {
128	return nft.StatusOf(collection, number) == nft.StatusActive &&
129		nft.OwnerOf(collection, number) == seller &&
130		nft.GetApproved(collection, number) == chain.PackageAddress(marketPath)
131}
132
133// pinned checks what every new order needs (a collection that can be sold, a
134// price, a lifetime of at most 90 days) and returns the protocol fee to pin
135// on it: the current one, which must not exceed what its owner accepts nor
136// this realm's cap.
137func pinned(collection string, price, expiresAt int64, currencyKey string, maxFeeBPS int64) int64 {
138	fee := policy(currencyKey).feeBPS
139	if fee > maxFeeBPS || fee > maxSecondaryFeeBPS {
140		panic("launchpad/market: protocol fee above the accepted maximum")
141	}
142	now := time.Now().Unix()
143	if nft.GetInfo(collection).Mode == nft.ModeSoulbound || price <= 0 ||
144		expiresAt <= now || expiresAt > now+maxLifetime {
145		panic("launchpad/market: invalid order")
146	}
147	return fee
148}
149
150// List offers the caller's token for sale and returns the listing ID. The
151// caller must first approve this realm in the NFT ledger, and names the
152// highest protocol fee it accepts; the fee in force is pinned on the listing.
153// A new listing replaces the token's previous one.
154func List(cur realm, collection string, number, price, expiresAt int64,
155	currencyKey string, maxFeeBPS int64) string {
156	seller := directCaller(cur)
157	currency.AssertNoCoins(0, cur)
158	fee := pinned(collection, price, expiresAt, currencyKey, maxFeeBPS)
159	if !sellable(collection, number, seller) {
160		panic("launchpad/market: not the owner, or the market is not approved")
161	}
162	if old := listingOf(collection, number); old != nil {
163		closeListing(old, StatusReplaced)
164	}
165	id, treeKey := nextOrder(&lastListing, "L")
166	l := &Listing{ID: id, Collection: collection, Number: number, Seller: seller,
167		Price: price, Currency: currencyKey, CreatedAt: time.Now().Unix(),
168		ExpiresAt: expiresAt, FeeBPS: fee, key: treeKey,
169		epoch: nft.Epoch(collection, number)}
170	listings.Set(treeKey, l)
171	listed.Set(tokenKey(collection, number), l)
172	bySeller.Set(seller.String()+"/"+treeKey, l)
173	chain.Emit("Listed", "listing", id, "collection", collection,
174		"number", strconv.FormatInt(number, 10), "seller", seller.String(),
175		"currency", currencyKey, "price", strconv.FormatInt(price, 10),
176		"fee_bps", strconv.FormatInt(fee, 10),
177		"expires_at", strconv.FormatInt(expiresAt, 10))
178	return id
179}
180
181// closeListing deletes a listing and says why in its closing event.
182func closeListing(l *Listing, status string) {
183	listings.Remove(l.key)
184	listed.Remove(tokenKey(l.Collection, l.Number))
185	bySeller.Remove(l.Seller.String() + "/" + l.key)
186	chain.Emit("ListingClosed", "listing", l.ID, "status", status)
187}
188
189// live reports whether a listing can be bought right now. A move or an
190// approval change starts a new custody epoch, so a listing never comes back to
191// life when a token that left the seller returns or the market is approved
192// again: the seller lists anew.
193func live(l *Listing) bool {
194	return time.Now().Unix() < l.ExpiresAt && sellable(l.Collection, l.Number, l.Seller) &&
195		nft.Epoch(l.Collection, l.Number) == l.epoch
196}
197
198// Cancel withdraws a listing. The seller can always do it, paused market or
199// not. Anyone can clear a listing whose seller no longer owns the token, or
200// one that expired more than graceToClose ago; the chain returns the storage
201// deposit of what a call frees to its caller, so clearing pays whoever does
202// it.
203func Cancel(cur realm, id string) {
204	who := directCaller(cur)
205	currency.AssertNoCoins(0, cur)
206	l := mustListing(id)
207	stale := time.Now().Unix() >= l.ExpiresAt+graceToClose ||
208		nft.StatusOf(l.Collection, l.Number) != nft.StatusActive ||
209		nft.OwnerOf(l.Collection, l.Number) != l.Seller
210	switch {
211	case who == l.Seller:
212		closeListing(l, StatusCancelled)
213	case stale:
214		closeListing(l, StatusExpired)
215	default:
216		panic("launchpad/market: seller only")
217	}
218}
219
220// Buy pays a listing's exact price and moves the token to the caller.
221// currencyKey and price are the terms the buyer read: a listing on other
222// terms, such as an ID the buyer never saw, takes nothing.
223func Buy(cur realm, id, currencyKey string, price int64) {
224	buyer := directCaller(cur)
225	l := mustListing(id)
226	if l.Currency != currencyKey || l.Price != price {
227		panic("launchpad/market: order terms differ from those signed")
228	}
229	p := policy(l.Currency)
230	if buyer == l.Seller || !live(l) {
231		panic("launchpad/market: listing unavailable")
232	}
233	before := take(cur, l.Currency, l.Price)
234	closeListing(l, StatusFilled)
235	nft.TransferFrom(cross(cur), l.Collection, l.Seller, buyer, l.Number)
236	settle(cur, l.Currency, l.Collection, l.Price, l.FeeBPS, l.Seller, p.treasury)
237	settled(cur, l.Currency, before)
238	chain.Emit("Sold", "listing", id, "collection", l.Collection,
239		"number", strconv.FormatInt(l.Number, 10), "buyer", buyer.String(),
240		"seller", l.Seller.String(), "currency", l.Currency,
241		"price", strconv.FormatInt(l.Price, 10))
242}
243
244// GetListing returns an open listing; a closed or unknown ID aborts.
245func GetListing(id string) Listing { return *mustListing(id) }
246
247func Render(path string) string {
248	return "# Launchpad NFT market\n\n" + strconv.Itoa(listings.Size()) +
249		" open listings, " + strconv.Itoa(offers.Size()) + " open offers.\n"
250}