Search Apps Documentation Source Content File Folder Download Copy Actions Download

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}