Search Apps Documentation Source Content File Folder Download Copy Actions Download

market.gno

9.95 Kb · 336 lines
  1package memba_nft_market_v2
  2
  3// NFT Marketplace v2 — General-purpose NFT trading for the Memba ecosystem on test13.
  4//
  5// Supports the memba_nft_v2 collection realm via cross-realm MarketTransfer.
  6// Platform fee: 2.5% to AdminAddress multisig.
  7// Royalty: clamped to MaxRoyaltyBPS (10%), returned by nft.RoyaltyInfo().
  8//
  9// Security:
 10//   - CEI (Checks-Effects-Interactions) in all payment flows
 11//   - IsUserCall guard on BuyNFT and MakeOffer (payment-bearing fns)
 12//   - Offer escrow with OfferTimeoutBlk safety valve (ClaimExpiredOffer)
 13//   - MinOfferLifetimeBlk prevents instant-cancel front-run
 14//   - Only original lister can delist (pause-exempt exit)
 15//   - Self-buy prevention
 16//   - Defense-in-depth royalty clamp even when collection enforces its own cap
 17
 18import (
 19	"chain"
 20	"chain/banker"
 21	"chain/runtime"
 22	"chain/runtime/unsafe"
 23	"strconv"
 24	"strings"
 25
 26	"gno.land/p/samcrew/grc721"
 27	"gno.land/p/nt/avl/v0"
 28	"gno.land/p/nt/ufmt/v0"
 29
 30	nft "gno.land/r/samcrew/memba_nft_v2"
 31)
 32
 33// ── Types ─────────────────────────────────────────────────────────────────────
 34
 35// Listing represents a fixed-price sale order.
 36type Listing struct {
 37	CollectionID string
 38	TokenID      string
 39	Seller       address
 40	Price        int64
 41	CreatedBlk   int64
 42}
 43
 44// Sale records a completed transaction for the salesLog.
 45type Sale struct {
 46	CollectionID string
 47	TokenID      string
 48	Seller       address
 49	Buyer        address
 50	Price        int64
 51	Fee          int64
 52	Royalty      int64
 53	Blk          int64
 54}
 55
 56// ── State ─────────────────────────────────────────────────────────────────────
 57
 58var (
 59	listings     *avl.Tree // listingKey -> *Listing
 60	offers       *avl.Tree // offerKey   -> *Offer
 61	salesLog     *avl.Tree // itoa(saleId) -> *Sale
 62	listingOrder []string  // insertion-ordered listing keys (pagination)
 63	nextSaleId   int64
 64	totalVolume  int64
 65	paused       bool
 66	feeRecipient = address(AdminAddress)
 67)
 68
 69func init() {
 70	listings = avl.NewTree()
 71	offers = avl.NewTree()
 72	salesLog = avl.NewTree()
 73}
 74
 75// ── Key helpers ───────────────────────────────────────────────────────────────
 76
 77func listingKey(collectionID, tokenID string) string {
 78	return collectionID + ":" + tokenID
 79}
 80
 81func offerKey(collectionID, tokenID string, buyer address) string {
 82	return collectionID + ":" + tokenID + ":" + string(buyer)
 83}
 84
 85func itoa(n int64) string { return strconv.FormatInt(n, 10) }
 86
 87// sumUgnot returns the total ugnot in a coin set.
 88func sumUgnot(coins chain.Coins) int64 {
 89	for _, c := range coins {
 90		if c.Denom == "ugnot" {
 91			return c.Amount
 92		}
 93	}
 94	return 0
 95}
 96
 97// removeFromOrder removes a key from the ordered listing slice (O(n)).
 98func removeFromOrder(key string) {
 99	for i, k := range listingOrder {
100		if k == key {
101			listingOrder = append(listingOrder[:i], listingOrder[i+1:]...)
102			return
103		}
104	}
105}
106
107// countListingsBySeller counts a seller's open listings. Bounded by MaxListings.
108func countListingsBySeller(seller address) int {
109	n := 0
110	listings.Iterate("", "", func(_ string, v interface{}) bool {
111		if v.(*Listing).Seller == seller {
112			n++
113		}
114		return false
115	})
116	return n
117}
118
119// countOffersByBuyer counts a buyer's open offers. Bounded by MaxOffers.
120func countOffersByBuyer(buyer address) int {
121	n := 0
122	offers.Iterate("", "", func(_ string, v interface{}) bool {
123		if v.(*Offer).Buyer == buyer {
124			n++
125		}
126		return false
127	})
128	return n
129}
130
131// ── List / Delist ─────────────────────────────────────────────────────────────
132
133// ListNFT lists an NFT for fixed-price sale.
134// PREREQUISITE: Owner must call nft.SetApprovalForAll(marketplace, true) first so
135// MarketTransfer can execute on behalf of the seller.
136func ListNFT(cur realm, collectionID string, tid grc721.TokenID, price int64) {
137	if paused {
138		panic("market paused")
139	}
140	seller := unsafe.PreviousRealm().Address()
141
142	if price < MinPrice {
143		panic(ufmt.Sprintf("price must be >= %d ugnot", MinPrice))
144	}
145	if price > MaxPrice {
146		panic(ufmt.Sprintf("price must be <= %d ugnot", MaxPrice))
147	}
148	if listings.Size() >= MaxListings {
149		panic("marketplace listing limit reached")
150	}
151	if countListingsBySeller(seller) >= MaxListingsPerAddr {
152		panic("seller listing limit reached")
153	}
154
155	key := listingKey(collectionID, string(tid))
156	if _, exists := listings.Get(key); exists {
157		panic("already listed: " + key)
158	}
159
160	// Effects
161	listings.Set(key, &Listing{
162		CollectionID: collectionID,
163		TokenID:      string(tid),
164		Seller:       seller,
165		Price:        price,
166		CreatedBlk:   runtime.ChainHeight(),
167	})
168	listingOrder = append(listingOrder, key)
169
170	chain.Emit("NFTListed",
171		"collection", collectionID,
172		"tokenId", string(tid),
173		"seller", seller.String(),
174		"price", itoa(price),
175	)
176}
177
178// DelistNFT removes a listing. Only the original lister can delist. Pause-exempt
179// (value-exit: unwinds the seller's position).
180func DelistNFT(cur realm, collectionID string, tid grc721.TokenID) {
181	caller := unsafe.PreviousRealm().Address()
182	key := listingKey(collectionID, string(tid))
183
184	val, exists := listings.Get(key)
185	if !exists {
186		panic("not listed: " + key)
187	}
188	l := val.(*Listing)
189	if l.Seller != caller {
190		panic("only seller can delist")
191	}
192
193	// Effects
194	listings.Remove(key)
195	removeFromOrder(key)
196
197	chain.Emit("NFTDelisted",
198		"collection", collectionID,
199		"tokenId", string(tid),
200		"seller", caller.String(),
201	)
202}
203
204// ── Buy ───────────────────────────────────────────────────────────────────────
205
206// BuyNFT purchases a listed NFT atomically (CEI order).
207//
208// CEI:
209//   Checks  — paused / IsUserCall / listed / self-buy / payment amount
210//   Effects — remove listing from state, record sale, update volume
211//   Interactions — cross-call MarketTransfer, then banker payouts (seller last)
212func BuyNFT(cur realm, collectionID string, tid grc721.TokenID) {
213	if paused {
214		panic("market paused")
215	}
216	if !unsafe.PreviousRealm().IsUserCall() {
217		panic("must be a direct user call")
218	}
219	key := listingKey(collectionID, string(tid))
220	v, ok := listings.Get(key)
221	if !ok {
222		panic("listing not found")
223	}
224	l := v.(*Listing)
225	buyer := unsafe.PreviousRealm().Address()
226	if buyer == l.Seller {
227		panic("cannot buy own listing")
228	}
229	if sumUgnot(unsafe.OriginSend()) != l.Price {
230		panic("incorrect payment")
231	}
232
233	royRecip, royAmt := nft.RoyaltyInfo(collectionID, tid, l.Price)
234	fee, royalty, sellerAmt := splitProceeds(l.Price, royAmt)
235
236	// Effects (before cross-call + sends)
237	seller := l.Seller
238	price := l.Price
239	listings.Remove(key)
240	removeFromOrder(key)
241	recordSale(collectionID, string(tid), seller, buyer, price, fee, royalty)
242	totalVolume += price
243
244	// Interactions
245	nft.MarketTransfer(cross(cur), collectionID, seller, buyer, tid)
246	bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
247	self := unsafe.CurrentRealm().Address()
248	if royalty > 0 {
249		bnk.SendCoins(self, royRecip, chain.Coins{chain.NewCoin("ugnot", royalty)})
250	}
251	if fee > 0 {
252		bnk.SendCoins(self, feeRecipient, chain.Coins{chain.NewCoin("ugnot", fee)})
253	}
254	bnk.SendCoins(self, seller, chain.Coins{chain.NewCoin("ugnot", sellerAmt)}) // seller last
255
256	chain.Emit("PurchaseConfirmed",
257		"collection", collectionID,
258		"tokenId", string(tid),
259		"buyer", buyer.String(),
260		"seller", seller.String(),
261		"price", itoa(price),
262		"fee", itoa(fee),
263		"royalty", itoa(royalty),
264		"royaltyRecipient", royRecip.String(),
265		"sellerAmount", itoa(sellerAmt),
266	)
267}
268
269// ── splitProceeds ─────────────────────────────────────────────────────────────
270
271// splitProceeds computes the three-way split of a sale price.
272// royaltyAmount comes pre-computed from nft.RoyaltyInfo (already the actual
273// amount, not bps). We clamp it defensively against MaxRoyaltyBPS.
274func splitProceeds(price, royaltyAmount int64) (fee, royalty, seller int64) {
275	if price < MinPrice {
276		panic("price below minimum")
277	}
278	if price > MaxPrice {
279		panic("price above maximum")
280	}
281	fee = price * FeeBPS / 10000
282	royalty = royaltyAmount
283	if royalty < 0 {
284		royalty = 0
285	}
286	maxRoy := price * MaxRoyaltyBPS / 10000
287	if royalty > maxRoy {
288		royalty = maxRoy // defense-in-depth clamp
289	}
290	if fee+royalty >= price {
291		panic("fee plus royalty exceeds price")
292	}
293	seller = price - fee - royalty
294	if seller <= 0 {
295		panic("seller amount not positive")
296	}
297	return
298}
299
300// ── recordSale ────────────────────────────────────────────────────────────────
301
302func recordSale(collectionID, tokenID string, seller, buyer address, price, fee, royalty int64) {
303	nextSaleId++
304	salesLog.Set(itoa(nextSaleId), &Sale{
305		CollectionID: collectionID,
306		TokenID:      tokenID,
307		Seller:       seller,
308		Buyer:        buyer,
309		Price:        price,
310		Fee:          fee,
311		Royalty:      royalty,
312		Blk:          runtime.ChainHeight(),
313	})
314}
315
316// ── IsPaused ─────────────────────────────────────────────────────────────────
317
318func IsPaused() bool { return paused }
319
320// ── truncAddr / truncPath (render helpers) ────────────────────────────────────
321
322func truncAddr(addr address) string {
323	s := string(addr)
324	if len(s) > 13 {
325		return s[:10] + "..."
326	}
327	return s
328}
329
330func truncPath(path string) string {
331	parts := strings.Split(path, "/")
332	if len(parts) > 2 {
333		return parts[len(parts)-1]
334	}
335	return path
336}