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}