Search Apps Documentation Source Content File Folder Download Copy Actions Download

market.gno

13.65 Kb · 384 lines
  1package memba_nft_market_v3_1
  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	core "gno.land/p/samcrew/memba_market_core_v2"   // v3.1: shared per-lane fee math (SplitProceedsBPS)
 60	cfg "gno.land/r/samcrew/memba_market_config"   // v3.1: DAO-owned per-lane fee + treasury spine
 61	nft "gno.land/r/samcrew/memba_collections"     // v3 change #4: canonical registry (was memba_nft_v2)
 62)
 63
 64// laneNFT is this engine's lane key into the DAO fee config.
 65const laneNFT = "nft"
 66
 67// resolveFee reads the NFT-lane protocol fee (bps) and treasury from the DAO config
 68// realm. It is FAIL-SAFE (panel C1): the config getters are pure and non-failing, and
 69// on any implausible value the engine falls back to its own constants rather than
 70// reverting a user's trade — a fee misconfig must never strand a settlement. The fee
 71// is clamped to [0, core.MaxFeeBPS] so a compromised config can't overcharge.
 72func resolveFee() (int64, address) {
 73	bps := int64(cfg.GetFeeBPS(laneNFT))
 74	if bps < 0 || bps > core.MaxFeeBPS {
 75		bps = FeeBPS // fallback to the engine's frozen default
 76	}
 77	treasury := cfg.GetTreasury()
 78	if treasury == "" {
 79		treasury = feeRecipient // fallback to the engine's local recipient
 80	}
 81	return bps, treasury
 82}
 83
 84// ── Types ─────────────────────────────────────────────────────────────────────
 85
 86// Listing represents a fixed-price sale order.
 87type Listing struct {
 88	CollectionID string
 89	TokenID      string
 90	Seller       address
 91	Price        int64
 92	CreatedBlk   int64
 93}
 94
 95// Sale records a completed transaction for the salesLog.
 96type Sale struct {
 97	CollectionID string
 98	TokenID      string
 99	Seller       address
100	Buyer        address
101	Price        int64
102	Fee          int64
103	Royalty      int64
104	Blk          int64
105}
106
107// ── State ─────────────────────────────────────────────────────────────────────
108
109var (
110	listings     *avl.Tree // listingKey -> *Listing
111	offers       *avl.Tree // offerKey   -> *Offer
112	salesLog     *avl.Tree // itoa(saleId) -> *Sale
113	listingOrder []string  // insertion-ordered listing keys (pagination)
114	nextSaleId   int64
115	totalVolume  int64
116	paused       bool
117	feeRecipient = address(AdminAddress)
118)
119
120func init() {
121	listings = avl.NewTree()
122	offers = avl.NewTree()
123	salesLog = avl.NewTree()
124}
125
126// ── Key helpers ───────────────────────────────────────────────────────────────
127
128func listingKey(collectionID, tokenID string) string {
129	return collectionID + ":" + tokenID
130}
131
132func offerKey(collectionID, tokenID string, buyer address) string {
133	return collectionID + ":" + tokenID + ":" + string(buyer)
134}
135
136func itoa(n int64) string { return strconv.FormatInt(n, 10) }
137
138// sumUgnot returns the total ugnot in a coin set.
139func sumUgnot(coins chain.Coins) int64 {
140	for _, c := range coins {
141		if c.Denom == "ugnot" {
142			return c.Amount
143		}
144	}
145	return 0
146}
147
148// removeFromOrder removes a key from the ordered listing slice (O(n)).
149func removeFromOrder(key string) {
150	for i, k := range listingOrder {
151		if k == key {
152			listingOrder = append(listingOrder[:i], listingOrder[i+1:]...)
153			return
154		}
155	}
156}
157
158// countListingsBySeller counts a seller's open listings. Bounded by MaxListings.
159func countListingsBySeller(seller address) int {
160	n := 0
161	listings.Iterate("", "", func(_ string, v interface{}) bool {
162		if v.(*Listing).Seller == seller {
163			n++
164		}
165		return false
166	})
167	return n
168}
169
170// countOffersByBuyer counts a buyer's open offers. Bounded by MaxOffers.
171func countOffersByBuyer(buyer address) int {
172	n := 0
173	offers.Iterate("", "", func(_ string, v interface{}) bool {
174		if v.(*Offer).Buyer == buyer {
175			n++
176		}
177		return false
178	})
179	return n
180}
181
182// ── List / Delist ─────────────────────────────────────────────────────────────
183
184// ListNFT lists an NFT for fixed-price sale.
185// PREREQUISITE: Owner must call memba_collections.SetApprovalForAll(collectionID,
186// marketplace, true) first so MarketTransfer can execute on behalf of the seller.
187func ListNFT(cur realm, collectionID string, tid grc721.TokenID, price int64) {
188	if paused {
189		panic("market paused")
190	}
191	seller := unsafe.PreviousRealm().Address()
192
193	if price < MinPrice {
194		panic(ufmt.Sprintf("price must be >= %d ugnot", MinPrice))
195	}
196	if price > MaxPrice {
197		panic(ufmt.Sprintf("price must be <= %d ugnot", MaxPrice))
198	}
199	if listings.Size() >= MaxListings {
200		panic("marketplace listing limit reached")
201	}
202	if countListingsBySeller(seller) >= MaxListingsPerAddr {
203		panic("seller listing limit reached")
204	}
205
206	key := listingKey(collectionID, string(tid))
207	if _, exists := listings.Get(key); exists {
208		panic("already listed: " + key)
209	}
210
211	// Effects
212	listings.Set(key, &Listing{
213		CollectionID: collectionID,
214		TokenID:      string(tid),
215		Seller:       seller,
216		Price:        price,
217		CreatedBlk:   runtime.ChainHeight(),
218	})
219	listingOrder = append(listingOrder, key)
220
221	chain.Emit("NFTListed",
222		"collection", collectionID,
223		"tokenId", string(tid),
224		"seller", seller.String(),
225		"price", itoa(price),
226	)
227}
228
229// DelistNFT removes a listing. Only the original lister can delist. Pause-exempt
230// (value-exit: unwinds the seller's position).
231func DelistNFT(cur realm, collectionID string, tid grc721.TokenID) {
232	caller := unsafe.PreviousRealm().Address()
233	key := listingKey(collectionID, string(tid))
234
235	val, exists := listings.Get(key)
236	if !exists {
237		panic("not listed: " + key)
238	}
239	l := val.(*Listing)
240	if l.Seller != caller {
241		panic("only seller can delist")
242	}
243
244	// Effects
245	listings.Remove(key)
246	removeFromOrder(key)
247
248	chain.Emit("NFTDelisted",
249		"collection", collectionID,
250		"tokenId", string(tid),
251		"seller", caller.String(),
252	)
253}
254
255// ── Buy ───────────────────────────────────────────────────────────────────────
256
257// BuyNFT purchases a listed NFT atomically (CEI order).
258//
259// CEI:
260//   Checks  — paused / IsUserCall / listed / self-buy / payment amount
261//   Effects — remove listing from state, record sale, update volume
262//   Interactions — cross-call MarketTransfer, then banker payouts (seller last)
263func BuyNFT(cur realm, collectionID string, tid grc721.TokenID) {
264	if paused {
265		panic("market paused")
266	}
267	if !unsafe.PreviousRealm().IsUserCall() {
268		panic("must be a direct user call")
269	}
270	key := listingKey(collectionID, string(tid))
271	v, ok := listings.Get(key)
272	if !ok {
273		panic("listing not found")
274	}
275	l := v.(*Listing)
276	buyer := unsafe.PreviousRealm().Address()
277	if buyer == l.Seller {
278		panic("cannot buy own listing")
279	}
280	if sumUgnot(unsafe.OriginSend()) != l.Price {
281		panic("incorrect payment")
282	}
283
284	royRecip, royAmt := nft.RoyaltyInfo(collectionID, tid, l.Price)
285	// v3.1: per-lane fee + treasury from the DAO config spine (fail-safe), settled via
286	// the shared SplitProceedsBPS money-math.
287	feeBps, treasury := resolveFee()
288	fee, royalty, sellerAmt := core.SplitProceedsBPS(l.Price, feeBps, royAmt)
289
290	// Effects (before cross-call + sends)
291	seller := l.Seller
292	price := l.Price
293	listings.Remove(key)
294	removeFromOrder(key)
295	recordSale(collectionID, string(tid), seller, buyer, price, fee, royalty)
296	totalVolume += price
297
298	// Interactions
299	nft.MarketTransfer(cross(cur), collectionID, seller, buyer, tid)
300	bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
301	self := unsafe.CurrentRealm().Address()
302	if royalty > 0 {
303		bnk.SendCoins(self, royRecip, chain.Coins{chain.NewCoin("ugnot", royalty)})
304	}
305	if fee > 0 {
306		bnk.SendCoins(self, treasury, chain.Coins{chain.NewCoin("ugnot", fee)})
307	}
308	bnk.SendCoins(self, seller, chain.Coins{chain.NewCoin("ugnot", sellerAmt)}) // seller last
309
310	// v3 change #3: single canonical settlement event (via="buy").
311	emitSale("buy", collectionID, string(tid), seller, buyer, price, fee, royalty, royRecip, sellerAmt, feeBps, treasury)
312}
313
314// ── emitSale ──────────────────────────────────────────────────────────────────
315
316// emitSale emits the ONE canonical settlement event for a completed sale.
317// v3 change #3: replaces v2's split PurchaseConfirmed / (OfferAccepted + TokenSold)
318// events with a single `Sale` event keyed by `via` ("buy" | "offer"), so indexers
319// count each sale exactly once. v3 change #2: carries the payment `denom`.
320// v3.1: the event now also carries the `feeBps` and `treasury` ACTUALLY used for this
321// settlement (read from the DAO config at settlement time), so the indexer and audit
322// trail are authoritative even across a DAO fee/treasury change (panel M3).
323func emitSale(via, collectionID, tokenID string, seller, buyer address, price, fee, royalty int64, royRecip address, sellerAmt, feeBps int64, treasury address) {
324	chain.Emit("Sale",
325		"via", via,
326		"collection", collectionID,
327		"tokenId", tokenID,
328		"seller", seller.String(),
329		"buyer", buyer.String(),
330		"price", itoa(price),
331		"fee", itoa(fee),
332		"royalty", itoa(royalty),
333		"royaltyRecipient", royRecip.String(),
334		"sellerAmount", itoa(sellerAmt),
335		"denom", SettlementDenom,
336		"feeBps", itoa(feeBps),
337		"treasury", treasury.String(),
338		// schemaVersion last, per the memba_market_core_v2 event contract — lets the
339		// indexer branch parsing deterministically (review finding H1).
340		"schemaVersion", core.SchemaVersion,
341	)
342}
343
344// v3.1: the three-way split now lives in p/samcrew/memba_market_core_v2 (SplitProceedsBPS),
345// taking the per-lane fee bps so every engine shares one audited money-math
346// implementation. The old local splitProceeds (hardcoded FeeBPS) was removed.
347
348// ── recordSale ────────────────────────────────────────────────────────────────
349
350func recordSale(collectionID, tokenID string, seller, buyer address, price, fee, royalty int64) {
351	nextSaleId++
352	salesLog.Set(itoa(nextSaleId), &Sale{
353		CollectionID: collectionID,
354		TokenID:      tokenID,
355		Seller:       seller,
356		Buyer:        buyer,
357		Price:        price,
358		Fee:          fee,
359		Royalty:      royalty,
360		Blk:          runtime.ChainHeight(),
361	})
362}
363
364// ── IsPaused ─────────────────────────────────────────────────────────────────
365
366func IsPaused() bool { return paused }
367
368// ── truncAddr / truncPath (render helpers) ────────────────────────────────────
369
370func truncAddr(addr address) string {
371	s := string(addr)
372	if len(s) > 13 {
373		return s[:10] + "..."
374	}
375	return s
376}
377
378func truncPath(path string) string {
379	parts := strings.Split(path, "/")
380	if len(parts) > 2 {
381		return parts[len(parts)-1]
382	}
383	return path
384}