Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

market.gno

9.66 Kb · 274 lines
  1package nsmarket
  2
  3import (
  4	"strings"
  5	"time"
  6
  7	"chain"
  8	"chain/banker"
  9	"chain/runtime/unsafe"
 10
 11	"gno.land/p/nt/ufmt/v0"
 12	"gno.land/r/nym-mnsrehearsal001/nsdata/v1"
 13	"gno.land/r/nym-mnsrehearsal001/nsnft/v1"
 14)
 15
 16// List puts a name up for sale at a price in ugnot.
 17//
 18// It does NOT take the name. The seller keeps it, keeps using it, and
 19// keeps the right to change their mind — revoking the approval stops the
 20// listing from completing without needing this realm's cooperation.
 21// (It does not DELETE the listing: this realm is not told. Unlist, or
 22// anyone's Prune, clears it. See staleReason.)
 23//
 24// The approval is required BEFORE listing rather than checked only at
 25// sale time, so a listing that could never complete cannot exist. A
 26// buyer finding out at the checkout that the seller never approved
 27// anything is the worst moment to find out.
 28func List(cur realm, tid string, price int64) {
 29	assertNoSend()
 30	seller := cur.Previous().Address()
 31	marketAddr = cur.Address()
 32	label, domainLabel := splitTokenID(tid)
 33	if label == "" || domainLabel == "" {
 34		panic("nsmarket: a token id looks like label*domain")
 35	}
 36	if strings.HasPrefix(tid, "*") {
 37		panic("nsmarket: domains are project-owned and not for sale")
 38	}
 39	if price < minPrice || price > maxPrice {
 40		panic("nsmarket: price out of range")
 41	}
 42	// A frozen name should not sit on the marketplace advertising itself.
 43	// nsnft would refuse the transfer anyway; a buyer should not find out
 44	// by paying.
 45	if !nsdata.NameExists(label, domainLabel) {
 46		panic("nsmarket: no such name")
 47	}
 48	_, registered, _, _, frozen, _ := nsdata.GetNameInfo(label, domainLabel)
 49	if frozen {
 50		panic("nsmarket: this name is frozen and cannot be listed or sold")
 51	}
 52	if nsdata.GetNameOwner(label, domainLabel) != seller {
 53		panic("nsmarket: only the owner may list a name")
 54	}
 55	// An expired name must not be sold here. Saying so at listing time
 56	// rather than letting somebody list it and take an offer.
 57	if !nsdata.IsNameValid(label, domainLabel) {
 58		panic("nsmarket: this name has expired — renew it before selling it")
 59	}
 60	if !nsdata.IsNamePaid(label, domainLabel) {
 61		panic("nsmarket: a free-tier name cannot be sold — upgrade it first")
 62	}
 63	// A hold for any other reason — frozen is the one today — stops the
 64	// listing here rather than at checkout.
 65	if nsnft.IsLocked(tid) {
 66		panic("nsmarket: this name is held by the registry and cannot be sold")
 67	}
 68	if !approvedTo(cur.Address(), seller, tid) {
 69		panic("nsmarket: approve the marketplace for this name first, so a sale can complete in one transaction")
 70	}
 71
 72	listings.Set(tid, &Listing{seller: seller, price: price,
 73		listed: time.Now().Unix(), registered: registered, epoch: nsnft.Epoch(tid)})
 74	chain.Emit("Listed", "tid", tid, "price", ufmt.Sprintf("%d", price), "seller", seller.String())
 75}
 76
 77// Unlist withdraws a listing. The seller, or the admin for a name that
 78// should not be on sale at all — the same manual lever the takedown
 79// process already relies on elsewhere.
 80func Unlist(cur realm, tid string) {
 81	assertNoSend()
 82	caller := cur.Previous().Address()
 83	l := get(tid)
 84	if l == nil {
 85		panic("nsmarket: not listed")
 86	}
 87	if caller != l.seller && caller != nsdata.GetAdmin() {
 88		panic("nsmarket: only the seller may withdraw a listing")
 89	}
 90	listings.Remove(tid)
 91	chain.Emit("Unlisted", "tid", tid)
 92}
 93
 94/*
 95Prune clears a listing that can no longer complete. Anyone may call it.
 96
 97WHY ANYONE. A listing goes stale without this realm hearing about it: the
 98name is transferred elsewhere, lapses, is frozen, or the seller revokes
 99the approval. Buy refuses such a listing — but a refusal is a panic, and a
100panic reverts everything, including any attempt to delete the listing on
101the way out. Found on Pearl, 2026-09-10: three refused buys, three
102listings still stored, each message saying "the listing is gone".
103
104A stale listing is not harmless while it sits there. Revoking the approval
105is how a seller would naturally "cancel"; if that seller later grants the
106marketplace an operator approval to list something else, the old listing
107completes again, at the old price. Clearing it is the fix, and nobody
108should have to wait for the seller to think of it.
109
110The caller gains nothing but the storage deposit the listing held, which
111the chain refunds to whoever frees storage. That is the right incentive:
112the only thing Prune can do is remove a listing that is already dead —
113staleReason must name a reason, or it refuses.
114*/
115func Prune(cur realm, tid string) {
116	assertNoSend()
117	l := get(tid)
118	if l == nil {
119		panic("nsmarket: not listed")
120	}
121	why := staleReason(cur.Address(), tid, l)
122	if why == "" {
123		panic("nsmarket: this listing can still complete — only the seller may withdraw it")
124	}
125	listings.Remove(tid)
126	chain.Emit("Pruned", "tid", tid, "reason", why)
127}
128
129// Buy completes a sale in the buyer's own transaction.
130//
131// Order matters here. Everything that can refuse the sale is checked
132// before a single coin moves, and the name moves before the money does,
133// so there is no arrangement of failures that leaves the seller paid and
134// the buyer empty-handed. A panic anywhere reverts all of it.
135func Buy(cur realm, tid string) {
136	if !cur.Previous().IsUserCall() {
137		panic("nsmarket: only direct wallet calls may buy")
138	}
139	buyer := cur.Previous().Address()
140	l := get(tid)
141	if l == nil {
142		panic("nsmarket: not listed")
143	}
144	// The world can have moved since the listing was written. The refusal
145	// cannot also delete the listing — see Prune for why.
146	if why := staleReason(cur.Address(), tid, l); why != "" {
147		panic("nsmarket: " + why + " — this listing can no longer complete (anyone may clear it with Prune)")
148	}
149	if buyer == l.seller {
150		panic("nsmarket: you already own this")
151	}
152
153	sent := unsafe.OriginSend()
154	for _, c := range sent {
155		if c.Denom != ugnot {
156			panic("nsmarket: only ugnot is accepted, got: " + c.Denom)
157		}
158	}
159	paid := sent.AmountOf(ugnot)
160	if paid < l.price {
161		panic(ufmt.Sprintf("nsmarket: %d ugnot short of the asking price", l.price-paid))
162	}
163
164	// The name first. If this fails nothing has been paid out yet, and
165	// the revert takes the buyer's coins back with it.
166	//
167	// ON nsnft, AS THE APPROVED OPERATOR — exactly the call any external
168	// marketplace makes. This realm has no privileged transfer path, which
169	// is the point: the sale is only possible because the seller approved
170	// this realm on the ledger.
171	nsnft.TransferFrom(cross(cur), l.seller, buyer, tid)
172
173	fee, toSeller := FeeFor(l.price)
174	b := banker.NewBanker(banker.BankerTypeRealmSend, cur)
175	self := cur.Address()
176	if toSeller > 0 {
177		b.SendCoins(self, l.seller, chain.Coins{chain.NewCoin(ugnot, toSeller)})
178	}
179	if fee > 0 {
180		b.SendCoins(self, nsdata.GetTreasury(), chain.Coins{chain.NewCoin(ugnot, fee)})
181	}
182	if excess := paid - l.price; excess > 0 {
183		b.SendCoins(self, buyer, chain.Coins{chain.NewCoin(ugnot, excess)})
184	}
185
186	listings.Remove(tid)
187	sold++
188	chain.Emit("Sold", "tid", tid, "price", ufmt.Sprintf("%d", l.price),
189		"seller", l.seller.String(), "buyer", buyer.String(),
190		"fee", ufmt.Sprintf("%d", fee))
191}
192
193// -- helpers --
194
195func get(tid string) *Listing {
196	v := listings.Get(tid)
197	if v == nil {
198		return nil
199	}
200	return v.(*Listing)
201}
202
203/*
204staleReason says why a listing can no longer complete, or "" when it can.
205
206ONE ANSWER FOR THREE CALLERS. Buy refuses on it, Prune clears on it, and
207the reads report live=false on it. They used to be three separate lists of
208checks, and the reads' list had no approval check: a listing whose seller
209had revoked permission showed as live, with an enabled Buy button, and
210failed at the checkout.
211
212Each of these is a real thing that happens, not a defensive flourish:
213  - changed hands: the seller transferred the name elsewhere
214  - lapsed and re-registered: owner and validity match again, but it is
215    not the asset the listing was written against (Listing.registered)
216  - sold and bought back: same owner, same registration, but a different
217    ownership epoch — an operator grant survives transfers (Listing.epoch)
218  - withdrawn permission: the seller revoked the approval
219*/
220func staleReason(self address, tid string, l *Listing) string {
221	label, domainLabel := splitTokenID(tid)
222	if !nsdata.NameExists(label, domainLabel) {
223		return "this name no longer exists"
224	}
225	_, registered, _, _, frozen, _ := nsdata.GetNameInfo(label, domainLabel)
226	if frozen || nsnft.IsLocked(tid) {
227		return "this name is frozen"
228	}
229	if nsdata.GetNameOwner(label, domainLabel) != l.seller {
230		return "this name has changed hands since it was listed"
231	}
232	if !nsdata.IsNameValid(label, domainLabel) {
233		return "this name has expired"
234	}
235	// Zero means written before the field existed: unpinned, not mismatched.
236	if l.registered != 0 && registered != l.registered {
237		return "this name lapsed and was registered again since it was listed"
238	}
239	if l.epoch != 0 && l.epoch != nsnft.Epoch(tid) {
240		return "this name has changed hands since it was listed"
241	}
242	if self == "" || !approvedTo(self, l.seller, tid) {
243		return "the seller has withdrawn permission"
244	}
245	return ""
246}
247
248// Whether this realm may move that token on the seller's behalf. Either
249// form of GRC-721 permission counts: approved for the one token, or an
250// operator for everything the seller holds.
251//
252// `self` is threaded in from the caller rather than looked up, because a
253// realm can only learn its own address from its own `cur`.
254func approvedTo(self, seller address, tid string) bool {
255	if nsdata.IsApprovedForAll(seller, self) {
256		return true
257	}
258	return nsdata.GetApproved(tid) == self
259}
260
261func splitTokenID(tid string) (label, domainLabel string) {
262	at := strings.Index(tid, "*")
263	if at < 1 || at == len(tid)-1 {
264		return "", ""
265	}
266	return tid[:at], tid[at+1:]
267}
268
269// This realm takes no payment on anything except Buy.
270func assertNoSend() {
271	if !unsafe.OriginSend().IsZero() {
272		panic("nsmarket: this function takes no payment; do not attach coins")
273	}
274}