Search Apps Documentation Source Content File Folder Download Copy Actions Download

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}