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}