Search Apps Documentation Source Content File Folder Download Copy Actions Download

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}