Search Apps Documentation Source Content File Folder Download Copy Actions Download

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}