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}