Search Apps Documentation Source Content File Folder Download Copy Actions Download

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}