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}