offers.gno
6.44 Kb · 212 lines
1package memba_nft_market_v3_1
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
76 chain.Emit("OfferMade",
77 "collection", collectionID,
78 "tokenId", string(tid),
79 "buyer", buyer.String(),
80 "amount", itoa(amt),
81 )
82}
83
84// CancelOffer allows the offerer to reclaim escrowed funds.
85// Pause-exempt (value-exit). Must wait MinOfferLifetimeBlk to prevent front-run.
86func CancelOffer(cur realm, collectionID string, tid grc721.TokenID) {
87 caller := unsafe.PreviousRealm().Address()
88 key := offerKey(collectionID, string(tid), caller)
89
90 val, exists := offers.Get(key)
91 if !exists {
92 panic("offer not found")
93 }
94 o := val.(*Offer)
95
96 age := runtime.ChainHeight() - o.CreatedBlk
97 if age < MinOfferLifetimeBlk {
98 panic(ufmt.Sprintf("offer too new to cancel: %d blocks remaining", MinOfferLifetimeBlk-age))
99 }
100
101 // Effects
102 amt := o.Amount
103 offers.Remove(key)
104
105 // Interactions
106 refund(cur, caller, amt)
107
108 chain.Emit("OfferCancelled",
109 "collection", collectionID,
110 "tokenId", string(tid),
111 "buyer", caller.String(),
112 "amount", itoa(amt),
113 )
114}
115
116// ClaimExpiredOffer returns escrowed funds when an offer has exceeded OfferTimeoutBlk.
117// Pause-exempt (safety-valve: prevents permanent escrow lock).
118// Anyone can call on behalf of the buyer, but funds always go to the buyer.
119func ClaimExpiredOffer(cur realm, collectionID string, tid grc721.TokenID, buyer address) {
120 key := offerKey(collectionID, string(tid), buyer)
121
122 val, exists := offers.Get(key)
123 if !exists {
124 panic("offer not found")
125 }
126 o := val.(*Offer)
127
128 age := runtime.ChainHeight() - o.CreatedBlk
129 if age < OfferTimeoutBlk {
130 panic(ufmt.Sprintf("offer not yet expired: %d blocks remaining", OfferTimeoutBlk-age))
131 }
132
133 // Effects
134 amt := o.Amount
135 offers.Remove(key)
136
137 // Interactions
138 refund(cur, buyer, amt)
139
140 chain.Emit("OfferExpiredClaimed",
141 "collection", collectionID,
142 "tokenId", string(tid),
143 "buyer", buyer.String(),
144 "amount", itoa(amt),
145 )
146}
147
148// AcceptOffer allows the listing seller to accept a buyer's escrowed offer.
149// Requires an active listing (seller must have listed first).
150// Settles atomically: cross-call MarketTransfer then pay royalty, fee, seller (seller last).
151func AcceptOffer(cur realm, collectionID string, tid grc721.TokenID, buyer address) {
152 if paused {
153 panic("market paused")
154 }
155 seller := unsafe.PreviousRealm().Address()
156
157 listKey := listingKey(collectionID, string(tid))
158 lv, listed := listings.Get(listKey)
159 if !listed {
160 panic("AcceptOffer requires an active listing — list the NFT first")
161 }
162 l := lv.(*Listing)
163 if l.Seller != seller {
164 panic("only the listing seller can accept offers")
165 }
166
167 if buyer == seller {
168 panic("cannot accept own offer")
169 }
170
171 oKey := offerKey(collectionID, string(tid), buyer)
172 ov, exists := offers.Get(oKey)
173 if !exists {
174 panic("offer not found")
175 }
176 o := ov.(*Offer)
177
178 royRecip, royAmt := nft.RoyaltyInfo(collectionID, tid, o.Amount)
179 // v3.1: per-lane fee + treasury from the DAO config spine (fail-safe), shared math.
180 feeBps, treasury := resolveFee()
181 fee, royalty, sellerAmt := core.SplitProceedsBPS(o.Amount, feeBps, royAmt)
182
183 // Effects (before cross-call + sends)
184 amt := o.Amount
185 offers.Remove(oKey)
186 listings.Remove(listKey)
187 removeFromOrder(listKey)
188 recordSale(collectionID, string(tid), seller, buyer, amt, fee, royalty)
189 totalVolume += amt
190
191 // Interactions
192 nft.MarketTransfer(cross(cur), collectionID, seller, buyer, tid)
193 bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
194 self := unsafe.CurrentRealm().Address()
195 if royalty > 0 {
196 bnk.SendCoins(self, royRecip, chain.Coins{chain.NewCoin("ugnot", royalty)})
197 }
198 if fee > 0 {
199 bnk.SendCoins(self, treasury, chain.Coins{chain.NewCoin("ugnot", fee)})
200 }
201 bnk.SendCoins(self, seller, chain.Coins{chain.NewCoin("ugnot", sellerAmt)}) // seller last
202
203 // v3 change #3: ONE canonical settlement event (via="offer"). v2 emitted BOTH
204 // OfferAccepted AND TokenSold here, double-counting the sale in indexers.
205 emitSale("offer", collectionID, string(tid), seller, buyer, amt, fee, royalty, royRecip, sellerAmt, feeBps, treasury)
206}
207
208// refund sends escrowed ugnot back to a recipient from this realm's balance.
209func refund(cur realm, to address, amt int64) {
210 bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
211 bnk.SendCoins(unsafe.CurrentRealm().Address(), to, chain.Coins{chain.NewCoin("ugnot", amt)})
212}