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