Search Apps Documentation Source Content File Folder Download Copy Actions Download

market.gno

14.52 Kb · 401 lines
  1package memba_nft_market_v3_2
  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	// totalEscrowed is the sum of all ugnot currently held for open offers —
120	// the realm's on-chain liabilities ledger (NF-2). Every escrow mutation
121	// (MakeOffer / CancelOffer / ClaimExpiredOffer / AcceptOffer) keeps it in
122	// lockstep with the offers tree, and TotalLiabilities() exposes it so the
123	// solvency monitor can reconcile realm balance >= liabilities.
124	totalEscrowed int64
125
126	// owner is the current admin (2-step handoff — a hard-coded admin could
127	// never be transferred to a DAO executor without redeploying the realm).
128	// Initialized to AdminAddress; changed only via TransferOwnership +
129	// AcceptOwnership by the staged address.
130	owner        = address(AdminAddress)
131	pendingOwner address
132)
133
134func init() {
135	listings = avl.NewTree()
136	offers = avl.NewTree()
137	salesLog = avl.NewTree()
138}
139
140// ── Key helpers ───────────────────────────────────────────────────────────────
141
142func listingKey(collectionID, tokenID string) string {
143	return collectionID + ":" + tokenID
144}
145
146func offerKey(collectionID, tokenID string, buyer address) string {
147	return collectionID + ":" + tokenID + ":" + string(buyer)
148}
149
150func itoa(n int64) string { return strconv.FormatInt(n, 10) }
151
152// sumUgnot returns the total ugnot in a coin set.
153func sumUgnot(coins chain.Coins) int64 {
154	for _, c := range coins {
155		if c.Denom == "ugnot" {
156			return c.Amount
157		}
158	}
159	return 0
160}
161
162// removeFromOrder removes a key from the ordered listing slice (O(n)).
163func removeFromOrder(key string) {
164	for i, k := range listingOrder {
165		if k == key {
166			listingOrder = append(listingOrder[:i], listingOrder[i+1:]...)
167			return
168		}
169	}
170}
171
172// countListingsBySeller counts a seller's open listings. Bounded by MaxListings.
173func countListingsBySeller(seller address) int {
174	n := 0
175	listings.Iterate("", "", func(_ string, v interface{}) bool {
176		if v.(*Listing).Seller == seller {
177			n++
178		}
179		return false
180	})
181	return n
182}
183
184// countOffersByBuyer counts a buyer's open offers. Bounded by MaxOffers.
185func countOffersByBuyer(buyer address) int {
186	n := 0
187	offers.Iterate("", "", func(_ string, v interface{}) bool {
188		if v.(*Offer).Buyer == buyer {
189			n++
190		}
191		return false
192	})
193	return n
194}
195
196// ── List / Delist ─────────────────────────────────────────────────────────────
197
198// ListNFT lists an NFT for fixed-price sale.
199// PREREQUISITE: Owner must call memba_collections.SetApprovalForAll(collectionID,
200// marketplace, true) first so MarketTransfer can execute on behalf of the seller.
201func ListNFT(cur realm, collectionID string, tid grc721.TokenID, price int64) {
202	if paused {
203		panic("market paused")
204	}
205	seller := unsafe.PreviousRealm().Address()
206
207	if price < MinPrice {
208		panic(ufmt.Sprintf("price must be >= %d ugnot", MinPrice))
209	}
210	if price > MaxPrice {
211		panic(ufmt.Sprintf("price must be <= %d ugnot", MaxPrice))
212	}
213	if listings.Size() >= MaxListings {
214		panic("marketplace listing limit reached")
215	}
216	if countListingsBySeller(seller) >= MaxListingsPerAddr {
217		panic("seller listing limit reached")
218	}
219
220	key := listingKey(collectionID, string(tid))
221	if _, exists := listings.Get(key); exists {
222		panic("already listed: " + key)
223	}
224
225	// Effects
226	listings.Set(key, &Listing{
227		CollectionID: collectionID,
228		TokenID:      string(tid),
229		Seller:       seller,
230		Price:        price,
231		CreatedBlk:   runtime.ChainHeight(),
232	})
233	listingOrder = append(listingOrder, key)
234
235	chain.Emit("NFTListed",
236		"collection", collectionID,
237		"tokenId", string(tid),
238		"seller", seller.String(),
239		"price", itoa(price),
240	)
241}
242
243// DelistNFT removes a listing. Only the original lister can delist. Pause-exempt
244// (value-exit: unwinds the seller's position).
245func DelistNFT(cur realm, collectionID string, tid grc721.TokenID) {
246	caller := unsafe.PreviousRealm().Address()
247	key := listingKey(collectionID, string(tid))
248
249	val, exists := listings.Get(key)
250	if !exists {
251		panic("not listed: " + key)
252	}
253	l := val.(*Listing)
254	if l.Seller != caller {
255		panic("only seller can delist")
256	}
257
258	// Effects
259	listings.Remove(key)
260	removeFromOrder(key)
261
262	chain.Emit("NFTDelisted",
263		"collection", collectionID,
264		"tokenId", string(tid),
265		"seller", caller.String(),
266	)
267}
268
269// ── Buy ───────────────────────────────────────────────────────────────────────
270
271// BuyNFT purchases a listed NFT atomically (CEI order).
272//
273// CEI:
274//   Checks  — paused / IsUserCall / listed / self-buy / payment amount
275//   Effects — remove listing from state, record sale, update volume
276//   Interactions — cross-call MarketTransfer, then banker payouts (seller last)
277func BuyNFT(cur realm, collectionID string, tid grc721.TokenID) {
278	if paused {
279		panic("market paused")
280	}
281	if !unsafe.PreviousRealm().IsUserCall() {
282		panic("must be a direct user call")
283	}
284	key := listingKey(collectionID, string(tid))
285	v, ok := listings.Get(key)
286	if !ok {
287		panic("listing not found")
288	}
289	l := v.(*Listing)
290	buyer := unsafe.PreviousRealm().Address()
291	if buyer == l.Seller {
292		panic("cannot buy own listing")
293	}
294	if sumUgnot(unsafe.OriginSend()) != l.Price {
295		panic("incorrect payment")
296	}
297
298	royRecip, royAmt := nft.RoyaltyInfo(collectionID, tid, l.Price)
299	// v3.1: per-lane fee + treasury from the DAO config spine (fail-safe), settled via
300	// the shared SplitProceedsBPS money-math.
301	feeBps, treasury := resolveFee()
302	fee, royalty, sellerAmt := core.SplitProceedsBPS(l.Price, feeBps, royAmt)
303
304	// Effects (before cross-call + sends)
305	seller := l.Seller
306	price := l.Price
307	listings.Remove(key)
308	removeFromOrder(key)
309	recordSale(collectionID, string(tid), seller, buyer, price, fee, royalty)
310	totalVolume += price
311
312	// Interactions
313	nft.MarketTransfer(cross(cur), collectionID, seller, buyer, tid)
314	bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
315	self := unsafe.CurrentRealm().Address()
316	if royalty > 0 {
317		bnk.SendCoins(self, royRecip, chain.Coins{chain.NewCoin("ugnot", royalty)})
318	}
319	if fee > 0 {
320		bnk.SendCoins(self, treasury, chain.Coins{chain.NewCoin("ugnot", fee)})
321	}
322	bnk.SendCoins(self, seller, chain.Coins{chain.NewCoin("ugnot", sellerAmt)}) // seller last
323
324	// v3 change #3: single canonical settlement event (via="buy").
325	emitSale("buy", collectionID, string(tid), seller, buyer, price, fee, royalty, royRecip, sellerAmt, feeBps, treasury)
326}
327
328// ── emitSale ──────────────────────────────────────────────────────────────────
329
330// emitSale emits the ONE canonical settlement event for a completed sale.
331// v3 change #3: replaces v2's split PurchaseConfirmed / (OfferAccepted + TokenSold)
332// events with a single `Sale` event keyed by `via` ("buy" | "offer"), so indexers
333// count each sale exactly once. v3 change #2: carries the payment `denom`.
334// v3.1: the event now also carries the `feeBps` and `treasury` ACTUALLY used for this
335// settlement (read from the DAO config at settlement time), so the indexer and audit
336// trail are authoritative even across a DAO fee/treasury change (panel M3).
337func emitSale(via, collectionID, tokenID string, seller, buyer address, price, fee, royalty int64, royRecip address, sellerAmt, feeBps int64, treasury address) {
338	chain.Emit("Sale",
339		"via", via,
340		"collection", collectionID,
341		"tokenId", tokenID,
342		"seller", seller.String(),
343		"buyer", buyer.String(),
344		"price", itoa(price),
345		"fee", itoa(fee),
346		"royalty", itoa(royalty),
347		"royaltyRecipient", royRecip.String(),
348		"sellerAmount", itoa(sellerAmt),
349		"denom", SettlementDenom,
350		"feeBps", itoa(feeBps),
351		"treasury", treasury.String(),
352		// schemaVersion last, per the memba_market_core_v2 event contract — lets the
353		// indexer branch parsing deterministically (review finding H1).
354		"schemaVersion", core.SchemaVersion,
355	)
356}
357
358// v3.1: the three-way split now lives in p/samcrew/memba_market_core_v2 (SplitProceedsBPS),
359// taking the per-lane fee bps so every engine shares one audited money-math
360// implementation. The old local splitProceeds (hardcoded FeeBPS) was removed.
361
362// ── recordSale ────────────────────────────────────────────────────────────────
363
364func recordSale(collectionID, tokenID string, seller, buyer address, price, fee, royalty int64) {
365	nextSaleId++
366	salesLog.Set(itoa(nextSaleId), &Sale{
367		CollectionID: collectionID,
368		TokenID:      tokenID,
369		Seller:       seller,
370		Buyer:        buyer,
371		Price:        price,
372		Fee:          fee,
373		Royalty:      royalty,
374		Blk:          runtime.ChainHeight(),
375	})
376	// v3.2: keep the purchase-query indexes (sales_index.gno) in lockstep
377	// with the log — HasPurchased must never lag a settled sale.
378	indexSale(seller, buyer, nextSaleId)
379}
380
381// ── IsPaused ─────────────────────────────────────────────────────────────────
382
383func IsPaused() bool { return paused }
384
385// ── truncAddr / truncPath (render helpers) ────────────────────────────────────
386
387func truncAddr(addr address) string {
388	s := string(addr)
389	if len(s) > 13 {
390		return s[:10] + "..."
391	}
392	return s
393}
394
395func truncPath(path string) string {
396	parts := strings.Split(path, "/")
397	if len(parts) > 2 {
398		return parts[len(parts)-1]
399	}
400	return path
401}