market.gno
12.66 Kb · 378 lines
1package memba_nft_market_v3
2
3// NFT Marketplace v3 — General-purpose NFT trading for the Memba ecosystem on test13.
4//
5// v3 is a fork of the audited memba_nft_market_v2 engine, repointed at the new
6// canonical registry realm `memba_collections` (was `memba_nft_v2`). It keeps the
7// proven structure: CEI payment ordering, IsUserCall guards on payable entrypoints,
8// escrowed offers, GRC2981-style royalty settlement, and a platform-fee split.
9//
10// Changes vs v2:
11// 1. Platform fee 2.5% → 2.0% (FeeBPS 250 → 200). See params.gno.
12// 2. Every settlement event now carries the payment `denom` (forward-compat for
13// the GRC20 settlement desk; "ugnot" today). See SettlementDenom.
14// 3. Settlement is reported via ONE canonical `Sale` event per sale, with a
15// `via` field = "buy" | "offer". v2 emitted BOTH `OfferAccepted` AND
16// `TokenSold` on an accepted offer, which double-counted volume in indexers.
17// v3 collapses that to the single `Sale` event (BuyNFT emits via="buy",
18// AcceptOffer emits via="offer"). The legacy `PurchaseConfirmed` /
19// `OfferAccepted` / `TokenSold` event names are intentionally retired.
20// 4. Imports `memba_collections` for MarketTransfer / RoyaltyInfo. The collection
21// ABI is identical to v2's (same signatures), so no call-site adaptation was
22// needed beyond the import path.
23//
24// SCOPE (this engine): fixed-price listings (list/delist), buy, escrowed offers
25// (make/cancel/accept/claim-expired), royalty settlement, platform-fee split,
26// IsUserCall guards, pause.
27//
28// PHASE 3+ (deferred): the following are intentionally NOT built in this engine and
29// are follow-on initiatives:
30// - Collection offers (offers on any token in a collection, not a specific id)
31// - Sweep (buy N cheapest listings in one tx)
32// - Auctions (timed / English / Dutch)
33// - GRC20 settlement desk (settle in $MEMBA or other GRC20; SettlementDenom is the
34// forward-compat hook already threaded onto every settlement event)
35// - DAO-curated badge (verified-collection curation)
36// - Points → $MEMBA claim (trading-rewards accrual + claim)
37//
38// Security:
39// - CEI (Checks-Effects-Interactions) in all payment flows
40// - IsUserCall guard on BuyNFT and MakeOffer (payment-bearing fns)
41// - Offer escrow with OfferTimeoutBlk safety valve (ClaimExpiredOffer)
42// - MinOfferLifetimeBlk prevents instant-cancel front-run
43// - Only original lister can delist (pause-exempt exit)
44// - Self-buy prevention
45// - Defense-in-depth royalty clamp even when collection enforces its own cap
46
47import (
48 "chain"
49 "chain/banker"
50 "chain/runtime"
51 "chain/runtime/unsafe"
52 "strconv"
53 "strings"
54
55 "gno.land/p/samcrew/grc721"
56 "gno.land/p/nt/avl/v0"
57 "gno.land/p/nt/ufmt/v0"
58
59 nft "gno.land/r/samcrew/memba_collections" // v3 change #4: canonical registry (was memba_nft_v2)
60)
61
62// ── Types ─────────────────────────────────────────────────────────────────────
63
64// Listing represents a fixed-price sale order.
65type Listing struct {
66 CollectionID string
67 TokenID string
68 Seller address
69 Price int64
70 CreatedBlk int64
71}
72
73// Sale records a completed transaction for the salesLog.
74type Sale struct {
75 CollectionID string
76 TokenID string
77 Seller address
78 Buyer address
79 Price int64
80 Fee int64
81 Royalty int64
82 Blk int64
83}
84
85// ── State ─────────────────────────────────────────────────────────────────────
86
87var (
88 listings *avl.Tree // listingKey -> *Listing
89 offers *avl.Tree // offerKey -> *Offer
90 salesLog *avl.Tree // itoa(saleId) -> *Sale
91 listingOrder []string // insertion-ordered listing keys (pagination)
92 nextSaleId int64
93 totalVolume int64
94 paused bool
95 feeRecipient = address(AdminAddress)
96)
97
98func init() {
99 listings = avl.NewTree()
100 offers = avl.NewTree()
101 salesLog = avl.NewTree()
102}
103
104// ── Key helpers ───────────────────────────────────────────────────────────────
105
106func listingKey(collectionID, tokenID string) string {
107 return collectionID + ":" + tokenID
108}
109
110func offerKey(collectionID, tokenID string, buyer address) string {
111 return collectionID + ":" + tokenID + ":" + string(buyer)
112}
113
114func itoa(n int64) string { return strconv.FormatInt(n, 10) }
115
116// sumUgnot returns the total ugnot in a coin set.
117func sumUgnot(coins chain.Coins) int64 {
118 for _, c := range coins {
119 if c.Denom == "ugnot" {
120 return c.Amount
121 }
122 }
123 return 0
124}
125
126// removeFromOrder removes a key from the ordered listing slice (O(n)).
127func removeFromOrder(key string) {
128 for i, k := range listingOrder {
129 if k == key {
130 listingOrder = append(listingOrder[:i], listingOrder[i+1:]...)
131 return
132 }
133 }
134}
135
136// countListingsBySeller counts a seller's open listings. Bounded by MaxListings.
137func countListingsBySeller(seller address) int {
138 n := 0
139 listings.Iterate("", "", func(_ string, v interface{}) bool {
140 if v.(*Listing).Seller == seller {
141 n++
142 }
143 return false
144 })
145 return n
146}
147
148// countOffersByBuyer counts a buyer's open offers. Bounded by MaxOffers.
149func countOffersByBuyer(buyer address) int {
150 n := 0
151 offers.Iterate("", "", func(_ string, v interface{}) bool {
152 if v.(*Offer).Buyer == buyer {
153 n++
154 }
155 return false
156 })
157 return n
158}
159
160// ── List / Delist ─────────────────────────────────────────────────────────────
161
162// ListNFT lists an NFT for fixed-price sale.
163// PREREQUISITE: Owner must call memba_collections.SetApprovalForAll(collectionID,
164// marketplace, true) first so MarketTransfer can execute on behalf of the seller.
165func ListNFT(cur realm, collectionID string, tid grc721.TokenID, price int64) {
166 if paused {
167 panic("market paused")
168 }
169 seller := unsafe.PreviousRealm().Address()
170
171 if price < MinPrice {
172 panic(ufmt.Sprintf("price must be >= %d ugnot", MinPrice))
173 }
174 if price > MaxPrice {
175 panic(ufmt.Sprintf("price must be <= %d ugnot", MaxPrice))
176 }
177 if listings.Size() >= MaxListings {
178 panic("marketplace listing limit reached")
179 }
180 if countListingsBySeller(seller) >= MaxListingsPerAddr {
181 panic("seller listing limit reached")
182 }
183
184 key := listingKey(collectionID, string(tid))
185 if _, exists := listings.Get(key); exists {
186 panic("already listed: " + key)
187 }
188
189 // Effects
190 listings.Set(key, &Listing{
191 CollectionID: collectionID,
192 TokenID: string(tid),
193 Seller: seller,
194 Price: price,
195 CreatedBlk: runtime.ChainHeight(),
196 })
197 listingOrder = append(listingOrder, key)
198
199 chain.Emit("NFTListed",
200 "collection", collectionID,
201 "tokenId", string(tid),
202 "seller", seller.String(),
203 "price", itoa(price),
204 )
205}
206
207// DelistNFT removes a listing. Only the original lister can delist. Pause-exempt
208// (value-exit: unwinds the seller's position).
209func DelistNFT(cur realm, collectionID string, tid grc721.TokenID) {
210 caller := unsafe.PreviousRealm().Address()
211 key := listingKey(collectionID, string(tid))
212
213 val, exists := listings.Get(key)
214 if !exists {
215 panic("not listed: " + key)
216 }
217 l := val.(*Listing)
218 if l.Seller != caller {
219 panic("only seller can delist")
220 }
221
222 // Effects
223 listings.Remove(key)
224 removeFromOrder(key)
225
226 chain.Emit("NFTDelisted",
227 "collection", collectionID,
228 "tokenId", string(tid),
229 "seller", caller.String(),
230 )
231}
232
233// ── Buy ───────────────────────────────────────────────────────────────────────
234
235// BuyNFT purchases a listed NFT atomically (CEI order).
236//
237// CEI:
238// Checks — paused / IsUserCall / listed / self-buy / payment amount
239// Effects — remove listing from state, record sale, update volume
240// Interactions — cross-call MarketTransfer, then banker payouts (seller last)
241func BuyNFT(cur realm, collectionID string, tid grc721.TokenID) {
242 if paused {
243 panic("market paused")
244 }
245 if !unsafe.PreviousRealm().IsUserCall() {
246 panic("must be a direct user call")
247 }
248 key := listingKey(collectionID, string(tid))
249 v, ok := listings.Get(key)
250 if !ok {
251 panic("listing not found")
252 }
253 l := v.(*Listing)
254 buyer := unsafe.PreviousRealm().Address()
255 if buyer == l.Seller {
256 panic("cannot buy own listing")
257 }
258 if sumUgnot(unsafe.OriginSend()) != l.Price {
259 panic("incorrect payment")
260 }
261
262 royRecip, royAmt := nft.RoyaltyInfo(collectionID, tid, l.Price)
263 fee, royalty, sellerAmt := splitProceeds(l.Price, royAmt)
264
265 // Effects (before cross-call + sends)
266 seller := l.Seller
267 price := l.Price
268 listings.Remove(key)
269 removeFromOrder(key)
270 recordSale(collectionID, string(tid), seller, buyer, price, fee, royalty)
271 totalVolume += price
272
273 // Interactions
274 nft.MarketTransfer(cross(cur), collectionID, seller, buyer, tid)
275 bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
276 self := unsafe.CurrentRealm().Address()
277 if royalty > 0 {
278 bnk.SendCoins(self, royRecip, chain.Coins{chain.NewCoin("ugnot", royalty)})
279 }
280 if fee > 0 {
281 bnk.SendCoins(self, feeRecipient, chain.Coins{chain.NewCoin("ugnot", fee)})
282 }
283 bnk.SendCoins(self, seller, chain.Coins{chain.NewCoin("ugnot", sellerAmt)}) // seller last
284
285 // v3 change #3: single canonical settlement event (via="buy").
286 emitSale("buy", collectionID, string(tid), seller, buyer, price, fee, royalty, royRecip, sellerAmt)
287}
288
289// ── emitSale ──────────────────────────────────────────────────────────────────
290
291// emitSale emits the ONE canonical settlement event for a completed sale.
292// v3 change #3: replaces v2's split PurchaseConfirmed / (OfferAccepted + TokenSold)
293// events with a single `Sale` event keyed by `via` ("buy" | "offer"), so indexers
294// count each sale exactly once. v3 change #2: carries the payment `denom`.
295func emitSale(via, collectionID, tokenID string, seller, buyer address, price, fee, royalty int64, royRecip address, sellerAmt int64) {
296 chain.Emit("Sale",
297 "via", via,
298 "collection", collectionID,
299 "tokenId", tokenID,
300 "seller", seller.String(),
301 "buyer", buyer.String(),
302 "price", itoa(price),
303 "fee", itoa(fee),
304 "royalty", itoa(royalty),
305 "royaltyRecipient", royRecip.String(),
306 "sellerAmount", itoa(sellerAmt),
307 "denom", SettlementDenom,
308 )
309}
310
311// ── splitProceeds ─────────────────────────────────────────────────────────────
312
313// splitProceeds computes the three-way split of a sale price.
314// royaltyAmount comes pre-computed from nft.RoyaltyInfo (already the actual
315// amount, not bps). We clamp it defensively against MaxRoyaltyBPS.
316func splitProceeds(price, royaltyAmount int64) (fee, royalty, seller int64) {
317 if price < MinPrice {
318 panic("price below minimum")
319 }
320 if price > MaxPrice {
321 panic("price above maximum")
322 }
323 fee = price * FeeBPS / 10000
324 royalty = royaltyAmount
325 if royalty < 0 {
326 royalty = 0
327 }
328 maxRoy := price * MaxRoyaltyBPS / 10000
329 if royalty > maxRoy {
330 royalty = maxRoy // defense-in-depth clamp
331 }
332 if fee+royalty >= price {
333 panic("fee plus royalty exceeds price")
334 }
335 seller = price - fee - royalty
336 if seller <= 0 {
337 panic("seller amount not positive")
338 }
339 return
340}
341
342// ── recordSale ────────────────────────────────────────────────────────────────
343
344func recordSale(collectionID, tokenID string, seller, buyer address, price, fee, royalty int64) {
345 nextSaleId++
346 salesLog.Set(itoa(nextSaleId), &Sale{
347 CollectionID: collectionID,
348 TokenID: tokenID,
349 Seller: seller,
350 Buyer: buyer,
351 Price: price,
352 Fee: fee,
353 Royalty: royalty,
354 Blk: runtime.ChainHeight(),
355 })
356}
357
358// ── IsPaused ─────────────────────────────────────────────────────────────────
359
360func IsPaused() bool { return paused }
361
362// ── truncAddr / truncPath (render helpers) ────────────────────────────────────
363
364func truncAddr(addr address) string {
365 s := string(addr)
366 if len(s) > 13 {
367 return s[:10] + "..."
368 }
369 return s
370}
371
372func truncPath(path string) string {
373 parts := strings.Split(path, "/")
374 if len(parts) > 2 {
375 return parts[len(parts)-1]
376 }
377 return path
378}