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}