offers.gno
6.21 Kb · 209 lines
1package memba_nft_market_v3
2
3// Offer management — escrowed buyer offers on any listed NFT.
4//
5// Lifecycle:
6// MakeOffer — buyer sends ugnot, funds held in escrow by this realm
7// CancelOffer — buyer cancels (pause-exempt; must wait MinOfferLifetimeBlk)
8// AcceptOffer — listing seller accepts; settles atomically via MarketTransfer + payouts
9// ClaimExpiredOffer — anyone claims refund after OfferTimeoutBlk (safety valve)
10//
11// Pause policy: MakeOffer + AcceptOffer are blocked while paused (new escrow /
12// new settlement). CancelOffer + ClaimExpiredOffer are pause-exempt (value-exit).
13//
14// PHASE 3+ (deferred): collection-wide offers (an offer that matches ANY token in a
15// collection rather than a specific token id) are NOT built here — see the deferred
16// list in market.gno. This engine only supports per-token offers.
17
18import (
19 "chain"
20 "chain/banker"
21 "chain/runtime"
22 "chain/runtime/unsafe"
23
24 "gno.land/p/samcrew/grc721"
25 "gno.land/p/nt/ufmt/v0"
26
27 nft "gno.land/r/samcrew/memba_collections" // v3 change #4: canonical registry (was memba_nft_v2)
28)
29
30// Offer is an escrowed buy proposal on a specific (collection, token) pair.
31type Offer struct {
32 CollectionID string
33 TokenID string
34 Buyer address
35 Amount int64 // ugnot held in escrow
36 CreatedBlk int64
37}
38
39// MakeOffer places an offer with escrowed funds.
40// Caller must send ugnot with the transaction; funds are held by this realm.
41func MakeOffer(cur realm, collectionID string, tid grc721.TokenID) {
42 if paused {
43 panic("market paused")
44 }
45 if !unsafe.PreviousRealm().IsUserCall() {
46 panic("must be a direct user call")
47 }
48 buyer := unsafe.PreviousRealm().Address()
49 amt := sumUgnot(unsafe.OriginSend())
50 if amt < MinPrice {
51 panic("offer below minimum")
52 }
53
54 if offers.Size() >= MaxOffers {
55 panic("offer limit reached")
56 }
57 if countOffersByBuyer(buyer) >= MaxOffersPerAddr {
58 panic("buyer offer limit reached")
59 }
60
61 key := offerKey(collectionID, string(tid), buyer)
62 if _, exists := offers.Get(key); exists {
63 panic("offer already exists, cancel first")
64 }
65
66 // Effects
67 offers.Set(key, &Offer{
68 CollectionID: collectionID,
69 TokenID: string(tid),
70 Buyer: buyer,
71 Amount: amt,
72 CreatedBlk: runtime.ChainHeight(),
73 })
74
75 chain.Emit("OfferMade",
76 "collection", collectionID,
77 "tokenId", string(tid),
78 "buyer", buyer.String(),
79 "amount", itoa(amt),
80 )
81}
82
83// CancelOffer allows the offerer to reclaim escrowed funds.
84// Pause-exempt (value-exit). Must wait MinOfferLifetimeBlk to prevent front-run.
85func CancelOffer(cur realm, collectionID string, tid grc721.TokenID) {
86 caller := unsafe.PreviousRealm().Address()
87 key := offerKey(collectionID, string(tid), caller)
88
89 val, exists := offers.Get(key)
90 if !exists {
91 panic("offer not found")
92 }
93 o := val.(*Offer)
94
95 age := runtime.ChainHeight() - o.CreatedBlk
96 if age < MinOfferLifetimeBlk {
97 panic(ufmt.Sprintf("offer too new to cancel: %d blocks remaining", MinOfferLifetimeBlk-age))
98 }
99
100 // Effects
101 amt := o.Amount
102 offers.Remove(key)
103
104 // Interactions
105 refund(cur, caller, amt)
106
107 chain.Emit("OfferCancelled",
108 "collection", collectionID,
109 "tokenId", string(tid),
110 "buyer", caller.String(),
111 "amount", itoa(amt),
112 )
113}
114
115// ClaimExpiredOffer returns escrowed funds when an offer has exceeded OfferTimeoutBlk.
116// Pause-exempt (safety-valve: prevents permanent escrow lock).
117// Anyone can call on behalf of the buyer, but funds always go to the buyer.
118func ClaimExpiredOffer(cur realm, collectionID string, tid grc721.TokenID, buyer address) {
119 key := offerKey(collectionID, string(tid), buyer)
120
121 val, exists := offers.Get(key)
122 if !exists {
123 panic("offer not found")
124 }
125 o := val.(*Offer)
126
127 age := runtime.ChainHeight() - o.CreatedBlk
128 if age < OfferTimeoutBlk {
129 panic(ufmt.Sprintf("offer not yet expired: %d blocks remaining", OfferTimeoutBlk-age))
130 }
131
132 // Effects
133 amt := o.Amount
134 offers.Remove(key)
135
136 // Interactions
137 refund(cur, buyer, amt)
138
139 chain.Emit("OfferExpiredClaimed",
140 "collection", collectionID,
141 "tokenId", string(tid),
142 "buyer", buyer.String(),
143 "amount", itoa(amt),
144 )
145}
146
147// AcceptOffer allows the listing seller to accept a buyer's escrowed offer.
148// Requires an active listing (seller must have listed first).
149// Settles atomically: cross-call MarketTransfer then pay royalty, fee, seller (seller last).
150func AcceptOffer(cur realm, collectionID string, tid grc721.TokenID, buyer address) {
151 if paused {
152 panic("market paused")
153 }
154 seller := unsafe.PreviousRealm().Address()
155
156 listKey := listingKey(collectionID, string(tid))
157 lv, listed := listings.Get(listKey)
158 if !listed {
159 panic("AcceptOffer requires an active listing — list the NFT first")
160 }
161 l := lv.(*Listing)
162 if l.Seller != seller {
163 panic("only the listing seller can accept offers")
164 }
165
166 if buyer == seller {
167 panic("cannot accept own offer")
168 }
169
170 oKey := offerKey(collectionID, string(tid), buyer)
171 ov, exists := offers.Get(oKey)
172 if !exists {
173 panic("offer not found")
174 }
175 o := ov.(*Offer)
176
177 royRecip, royAmt := nft.RoyaltyInfo(collectionID, tid, o.Amount)
178 fee, royalty, sellerAmt := splitProceeds(o.Amount, royAmt)
179
180 // Effects (before cross-call + sends)
181 amt := o.Amount
182 offers.Remove(oKey)
183 listings.Remove(listKey)
184 removeFromOrder(listKey)
185 recordSale(collectionID, string(tid), seller, buyer, amt, fee, royalty)
186 totalVolume += amt
187
188 // Interactions
189 nft.MarketTransfer(cross(cur), collectionID, seller, buyer, tid)
190 bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
191 self := unsafe.CurrentRealm().Address()
192 if royalty > 0 {
193 bnk.SendCoins(self, royRecip, chain.Coins{chain.NewCoin("ugnot", royalty)})
194 }
195 if fee > 0 {
196 bnk.SendCoins(self, feeRecipient, chain.Coins{chain.NewCoin("ugnot", fee)})
197 }
198 bnk.SendCoins(self, seller, chain.Coins{chain.NewCoin("ugnot", sellerAmt)}) // seller last
199
200 // v3 change #3: ONE canonical settlement event (via="offer"). v2 emitted BOTH
201 // OfferAccepted AND TokenSold here, double-counting the sale in indexers.
202 emitSale("offer", collectionID, string(tid), seller, buyer, amt, fee, royalty, royRecip, sellerAmt)
203}
204
205// refund sends escrowed ugnot back to a recipient from this realm's balance.
206func refund(cur realm, to address, amt int64) {
207 bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
208 bnk.SendCoins(unsafe.CurrentRealm().Address(), to, chain.Coins{chain.NewCoin("ugnot", amt)})
209}