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