market.gno
18.04 Kb · 540 lines
1// Realm market is a custodial GNOT marketplace for goods listings:
2// sellers list (title, description, price), a buyer purchases by
3// paying the exact price, the marketplace holds the value until the
4// seller claims their proceeds (price minus a transparent, snapshotted
5// protocol fee).
6//
7// COMPOSITION (per the recorded DISCOVERY / REUSE ANALYSIS): balance
8// accounting is feeledger, coin movement is coinio, free-text render
9// safety is the ecosystem sanitizer p/nt/markdown/sanitize/v0. This
10// realm owns only the listing state machine. Two patterns are adopted
11// from inspected ecosystem marketplaces: non-payable entrypoints
12// REFUSE accidental -send instead of stranding it as surplus
13// (nsmarket/v4's assertNoSend), and admin handoff is 2-STEP
14// (memba_appstore_v2's TransferOwnership/AcceptOwnership), closing the
15// one-step-transfer trade-off carried by earlier realms.
16//
17// LIFECYCLE (terminal states are frozen; one transition per listing):
18//
19// CreateListing (anyone, no coins) : status Active; the fee bps is
20// SNAPSHOTTED, subject to the
21// seller's own maxFeeBps ceiling
22// Buy (EOA + -send == price) : Active -> Sold, atomically:
23// proceeds (price - snapshot fee)
24// credit the seller's claimable
25// balance, the fee accrues to the
26// pot, the buyer is recorded
27// CancelListing (seller only) : Active -> Cancelled (no funds
28// are involved; listings hold no
29// value)
30// Claim / ClaimAll (anyone) : pays out the caller's own
31// claimable proceeds
32// WithdrawFees (fee recipient) : pays out the fee pot
33//
34// LISTINGS ARE IMMUTABLE: there is no price update — cancel and relist
35// (new id). Together with Buy's EXACT-envelope rule this closes the
36// listing-manipulation race twice over: a cancelled/relisted listing
37// fails Buy's status check, and any price change fails the envelope
38// check — either way the buyer's coins revert with the transaction.
39// Buyers are structurally indifferent to fee changes: they pay the
40// listed price; the fee comes out of the seller's proceeds at the bps
41// snapshotted when the SELLER listed (with the seller's own ceiling —
42// the creation-time fee race is closed the same way grants closes it).
43//
44// AUTHORIZATION: every identity derives from the crossing entrypoint's
45// cur.Previous().Address(); no function takes a caller identity as a
46// parameter. Sellers may be EOAs or realms (they claim under their own
47// address); buyers must be EOAs (coinio.Receive is the
48// receipt-guaranteed shape). Self-purchase is rejected.
49//
50// REALM-SELLER CAVEAT (audit Y1): assertNoSend reads the ORIGIN
51// transaction's send envelope, so a realm seller must call
52// CreateListing/CancelListing/Claim* in a transaction whose origin
53// carried no -send — otherwise the guard fails closed even though this
54// realm received nothing. Not third-party triggerable (nobody can
55// attach a send to someone else's transaction); the workaround is a
56// separate transaction.
57//
58// MONETARY INVARIANT (conservation): listings hold NO value, so with
59// H = ugnot held at the realm address, U = claimable seller proceeds,
60// F = the fee pot, S >= 0 out-of-band surplus:
61//
62// H == U + F + S
63//
64// Buy raises H by exactly price and U+F by exactly price (feeledger
65// guarantees credited + fee == amount); Claim*/WithdrawFees debit the
66// ledger before coinio.Payout moves the identical amount out; any
67// panic aborts the whole transaction; this realm never issues or
68// removes coins. Surplus is recoverable only via SweepDenom (fee
69// recipient), which reserves Liabilities() = U + F.
70//
71// APPLICATION INVARIANT: status transitions Active -> {Sold,
72// Cancelled} exactly once; Sold if and only if a buyer is recorded;
73// for every sold listing, proceeds + fee == price at the snapshotted
74// bps.
75package market
76
77import (
78 "chain"
79 "chain/runtime/unsafe"
80 "strconv"
81
82 "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/coinio"
83 "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/feeledger"
84 "gno.land/p/nt/avl/v0"
85 "gno.land/p/nt/markdown/sanitize/v0"
86)
87
88// Denom is the only asset this realm accepts.
89const Denom = "ugnot"
90
91// MaxFeeBps is the hard protocol-fee cap: 1000 bps = 10%.
92const MaxFeeBps = int64(1000)
93
94// Listing status values.
95const (
96 StatusActive = "active"
97 StatusSold = "sold"
98 StatusCancelled = "cancelled"
99)
100
101// Input bounds.
102const (
103 MaxTitleLen = 80
104 MaxDescLen = 2000
105 MinPrice = int64(1)
106)
107
108type listing struct {
109 id int64
110 seller address
111 title string
112 description string
113 price int64
114 feeBps int64 // snapshotted at creation, charged at Buy
115 status string
116 buyer address // set iff status == StatusSold
117}
118
119var (
120 admin address // may set fee, fee recipient, stage successor
121 pendingAdmin address // staged by TransferAdmin, completes via AcceptAdmin
122 feeRecipient address // may withdraw fees and sweep surplus
123 feeBps int64 // fee snapshotted into NEW listings
124
125 self address // this realm's address, captured at deploy
126 nextID int64
127
128 listings = avl.NewTree() // padID(id) -> *listing
129 ledger = feeledger.MustNew(MaxFeeBps)
130)
131
132func init() {
133 admin = unsafe.OriginCaller()
134 feeRecipient = admin
135 self = unsafe.CurrentRealm().Address()
136}
137
138// CreateListing publishes an immutable listing and returns its id. No
139// coins are accepted (the storage deposit the caller pays is the
140// anti-spam). The current protocol fee is snapshotted into the listing
141// and must not exceed maxFeeBps, the ceiling the seller signed for;
142// pass MaxFeeBps to accept any legal fee. Sellers may be EOAs or
143// realms.
144func CreateListing(cur realm, title, description string, price, maxFeeBps int64) int64 {
145 assertNoSend()
146 seller := cur.Previous().Address()
147 if feeBps > maxFeeBps {
148 panic("current fee " + itoa(feeBps) + " bps exceeds the seller's maximum " + itoa(maxFeeBps))
149 }
150 assertValidTitle(title)
151 if len(description) == 0 || len(description) > MaxDescLen {
152 panic("description must be 1-" + strconv.Itoa(MaxDescLen) + " bytes")
153 }
154 if price < MinPrice {
155 panic("price must be at least " + itoa(MinPrice) + Denom)
156 }
157
158 nextID++
159 l := &listing{
160 id: nextID,
161 seller: seller,
162 title: title,
163 description: description,
164 price: price,
165 feeBps: feeBps,
166 status: StatusActive,
167 }
168 listings.Set(padID(l.id), l)
169
170 chain.Emit("ListingCreated",
171 "id", itoa(l.id),
172 "seller", seller.String(),
173 "price", itoa(price),
174 "feeBps", itoa(l.feeBps),
175 )
176 return l.id
177}
178
179// Buy purchases an active listing. The buyer must be a direct EOA
180// caller and attach EXACTLY the listed price in ugnot — any mismatch
181// (including a price the seller changed by cancel-and-relist) aborts
182// and the coins revert with the transaction. Settlement is atomic:
183// the seller's proceeds (price minus the snapshotted fee) become
184// claimable, the fee accrues to the pot, and the buyer is recorded.
185// Terminal.
186func Buy(cur realm, id int64) {
187 buyer, amount := coinio.Receive(0, cur, Denom)
188 l := mustGetListing(id)
189 if l.status != StatusActive {
190 panic("listing is not active")
191 }
192 if buyer == l.seller {
193 panic("the seller cannot buy their own listing")
194 }
195 if amount != l.price {
196 panic("send exactly the listed price: " + itoa(l.price) + Denom)
197 }
198
199 // Move the purchase into ledger liabilities in one transaction, at
200 // the fee snapshotted when the seller listed. feeledger validates
201 // before mutating; an error aborts everything and the listing
202 // stays Active.
203 proceeds, fee, err := ledger.Deposit(l.seller.String(), l.price, l.feeBps)
204 if err != nil {
205 panic(err)
206 }
207 l.status = StatusSold
208 l.buyer = buyer
209
210 chain.Emit("Sold",
211 "id", itoa(id),
212 "seller", l.seller.String(),
213 "buyer", buyer.String(),
214 "price", itoa(l.price),
215 "proceeds", itoa(proceeds),
216 "fee", itoa(fee),
217 )
218}
219
220// CancelListing withdraws an active listing. Only the seller may
221// cancel; no funds are involved. Terminal.
222func CancelListing(cur realm, id int64) {
223 assertNoSend()
224 caller := cur.Previous().Address()
225 l := mustGetListing(id)
226 if caller != l.seller {
227 panic("only the seller may cancel")
228 }
229 if l.status != StatusActive {
230 panic("listing is not active")
231 }
232 l.status = StatusCancelled
233
234 chain.Emit("ListingCancelled", "id", itoa(id), "seller", l.seller.String())
235}
236
237// Claim sends amount ugnot of the caller's claimable proceeds back to
238// the caller.
239func Claim(cur realm, amount int64) {
240 assertNoSend()
241 caller := cur.Previous().Address()
242 if err := ledger.Withdraw(caller.String(), amount); err != nil {
243 panic(err)
244 }
245 coinio.Payout(0, cur, caller, Denom, amount)
246 chain.Emit("Claim", "to", caller.String(), "amount", itoa(amount))
247}
248
249// ClaimAll sends the caller's entire claimable proceeds back to the
250// caller. Fails if there is nothing to claim.
251func ClaimAll(cur realm) {
252 assertNoSend()
253 caller := cur.Previous().Address()
254 amount, err := ledger.WithdrawAll(caller.String())
255 if err != nil {
256 panic(err)
257 }
258 if amount == 0 {
259 panic("nothing to claim")
260 }
261 coinio.Payout(0, cur, caller, Denom, amount)
262 chain.Emit("Claim", "to", caller.String(), "amount", itoa(amount))
263}
264
265// WithdrawFees sends the accrued fee pot to the fee recipient. Only
266// the fee recipient may call it.
267func WithdrawFees(cur realm) {
268 assertNoSend()
269 caller := cur.Previous().Address()
270 if caller != feeRecipient {
271 panic("only the fee recipient may withdraw fees")
272 }
273 if ledger.FeesAccrued() == 0 {
274 panic("no fees accrued")
275 }
276 amount := ledger.WithdrawFees()
277 coinio.Payout(0, cur, feeRecipient, Denom, amount)
278 chain.Emit("FeesWithdrawn", "to", feeRecipient.String(), "amount", itoa(amount))
279}
280
281// SweepDenom sends the surplus of a single denomination to the fee
282// recipient. For ugnot only the excess over Liabilities() moves; other
283// denoms move wholly. Only the fee recipient may call it.
284func SweepDenom(cur realm, denom string) {
285 assertNoSend()
286 caller := cur.Previous().Address()
287 if caller != feeRecipient {
288 panic("only the fee recipient may sweep surplus")
289 }
290 reserve := int64(0)
291 if denom == Denom {
292 reserve = Liabilities()
293 }
294 swept := coinio.Sweep(0, cur, feeRecipient, denom, reserve)
295 chain.Emit("SurplusSwept", "to", feeRecipient.String(), "coins", itoa(swept)+denom)
296}
297
298// SetFeeBps sets the protocol fee snapshotted into FUTURE listings.
299// Existing listings keep the fee they were created under. Admin only;
300// bounded by [0, MaxFeeBps].
301func SetFeeBps(cur realm, bps int64) {
302 assertNoSend()
303 assertAdmin(cur.Previous().Address())
304 if bps < 0 || bps > MaxFeeBps {
305 panic("fee bps out of range [0, " + itoa(MaxFeeBps) + "]")
306 }
307 old := feeBps
308 feeBps = bps
309 chain.Emit("FeeBpsChanged", "old", itoa(old), "new", itoa(bps))
310}
311
312// SetFeeRecipient re-points the fee/surplus role, including the pot
313// accrued so far. Admin only; zero address rejected.
314func SetFeeRecipient(cur realm, next address) {
315 assertNoSend()
316 assertAdmin(cur.Previous().Address())
317 var zero address
318 if next == zero {
319 panic("empty fee recipient")
320 }
321 old := feeRecipient
322 feeRecipient = next
323 chain.Emit("FeeRecipientChanged", "old", old.String(), "new", next.String())
324}
325
326// TransferAdmin STAGES a successor admin; the handoff completes only
327// when that address calls AcceptAdmin (2-step, so a typo cannot brick
328// administration — pattern adopted from memba_appstore_v2). Admin
329// only; zero address rejected. Re-staging overwrites a previous stage.
330func TransferAdmin(cur realm, next address) {
331 assertNoSend()
332 assertAdmin(cur.Previous().Address())
333 var zero address
334 if next == zero {
335 panic("empty admin address")
336 }
337 pendingAdmin = next
338 chain.Emit("AdminTransferStaged", "pending", next.String())
339}
340
341// AcceptAdmin completes a staged admin handoff. Only the staged
342// address may call it.
343func AcceptAdmin(cur realm) {
344 assertNoSend()
345 caller := cur.Previous().Address()
346 var zero address
347 if pendingAdmin == zero || caller != pendingAdmin {
348 panic("caller is not the staged admin")
349 }
350 admin = pendingAdmin
351 pendingAdmin = zero
352 chain.Emit("AdminTransferred", "newAdmin", admin.String())
353}
354
355// --- read-only views ---
356
357// ListingInfo returns a listing's fields by value: seller, title,
358// price, snapshotted fee bps, status, and buyer (zero unless sold).
359func ListingInfo(id int64) (seller address, title string, price, feeBps int64, status string, buyer address) {
360 l := mustGetListing(id)
361 return l.seller, l.title, l.price, l.feeBps, l.status, l.buyer
362}
363
364// Description returns a listing's raw description text.
365func Description(id int64) string { return mustGetListing(id).description }
366
367// Quote returns what a buyer pays and what the seller would receive
368// for a listing, at its SNAPSHOTTED fee — the same arithmetic Buy
369// performs (pattern from nsmarket/v4: a fee discovered after signing
370// is a fee the seller was not told about).
371func Quote(id int64) (price, fee, toSeller int64) {
372 l := mustGetListing(id)
373 f, err := feeledger.FeeFor(l.price, l.feeBps)
374 if err != nil {
375 panic(err)
376 }
377 return l.price, f, l.price - f
378}
379
380// Admin returns the current admin.
381func Admin() address { return admin }
382
383// PendingAdmin returns the staged successor (zero when none).
384func PendingAdmin() address { return pendingAdmin }
385
386// FeeRecipient returns who may withdraw fees and sweep surplus.
387func FeeRecipient() address { return feeRecipient }
388
389// FeeBps returns the fee that will be snapshotted into newly created
390// listings (existing listings keep their own snapshot).
391func FeeBps() int64 { return feeBps }
392
393// NumListings returns how many listings have ever been created.
394func NumListings() int64 { return nextID }
395
396// BalanceOf returns addr's claimable proceeds.
397func BalanceOf(addr address) int64 { return ledger.BalanceOf(addr.String()) }
398
399// UsersTotal returns the sum of all claimable proceeds (the U term).
400func UsersTotal() int64 { return ledger.UsersTotal() }
401
402// FeesAccrued returns the fee pot (the F term).
403func FeesAccrued() int64 { return ledger.FeesAccrued() }
404
405// Liabilities returns everything this realm owes: UsersTotal +
406// FeesAccrued (listings hold no value by construction).
407func Liabilities() int64 { return ledger.Liabilities() }
408
409// Held returns the ugnot actually held at the realm address (H).
410func Held() int64 { return coinio.HeldAt(self, Denom) }
411
412// Surplus returns Held() - Liabilities() (the S term).
413func Surplus() int64 { return Held() - ledger.Liabilities() }
414
415// Address returns this realm's address.
416func Address() address { return self }
417
418// Render shows the market at "" and a listing detail at "<id>".
419// Titles are charset-restricted; descriptions pass through the
420// ecosystem sanitizer.
421func Render(path string) string {
422 if path != "" {
423 return renderListing(path)
424 }
425 held := Held()
426 liab := ledger.Liabilities()
427 status := "OK"
428 if held < liab {
429 status = "VIOLATED"
430 }
431 out := "# Marketplace\n\n"
432 out += "Custodial GNOT marketplace; accounting via feeledger, coin I/O via coinio.\n\n"
433 out += "## Configuration\n\n"
434 out += "- fee for new listings: " + itoa(feeBps) + " bps (cap " + itoa(MaxFeeBps) +
435 " bps; each listing keeps the fee snapshotted at its creation)\n"
436 out += "- fee recipient: " + feeRecipient.String() + "\n"
437 out += "- admin: " + admin.String() + "\n\n"
438 out += "## Accounting (H == U + F + S; listings hold no value)\n\n"
439 out += "- claimable proceeds (U): " + itoa(ledger.UsersTotal()) + Denom + "\n"
440 out += "- fees accrued (F): " + itoa(ledger.FeesAccrued()) + Denom + "\n"
441 out += "- held (H): " + itoa(held) + Denom + "\n"
442 out += "- conservation: " + status + "\n\n"
443 out += "## Latest listings\n\n"
444 if nextID == 0 {
445 out += "No listings yet.\n"
446 return out
447 }
448 shown := 0
449 listings.ReverseIterate("", "", func(_ string, v any) bool {
450 l := v.(*listing)
451 out += "- [#" + itoa(l.id) + "](" + "/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/market:" + itoa(l.id) + ") [" +
452 l.status + "] " + l.title + " — " + itoa(l.price) + Denom + "\n"
453 shown++
454 return shown >= 20
455 })
456 return out
457}
458
459func renderListing(path string) string {
460 id, err := strconv.ParseInt(path, 10, 64)
461 if err != nil {
462 return "> [!WARNING]\n> invalid listing id\n"
463 }
464 v := listings.Get(padID(id))
465 if v == nil {
466 return "> [!WARNING]\n> unknown listing id\n"
467 }
468 l := v.(*listing)
469 out := "# Listing #" + itoa(l.id) + ": " + l.title + "\n\n"
470 out += "- status: " + l.status + "\n"
471 out += "- seller: " + l.seller.String() + "\n"
472 out += "- price: " + itoa(l.price) + Denom + "\n"
473 out += "- fee (snapshot): " + itoa(l.feeBps) + " bps\n"
474 if l.status == StatusSold {
475 out += "- buyer: " + l.buyer.String() + "\n"
476 }
477 out += "\n## Description\n\n" + sanitize.InlineText(l.description) + "\n"
478 return out
479}
480
481// --- internals ---
482
483// assertNoSend refuses coins on non-payable entrypoints: an accidental
484// -send would otherwise strand at the realm as sweep-only surplus
485// (pattern from nsmarket/v4). Buy is the only payable function.
486// NOTE: this reads the ORIGIN envelope — for EOA callers that is
487// exactly "coins that landed here"; for realm callers it fails closed
488// whenever the origin tx carried any -send (see the header caveat).
489func assertNoSend() {
490 if len(unsafe.OriginSend()) != 0 {
491 panic("this function does not accept coins")
492 }
493}
494
495func assertAdmin(caller address) {
496 if caller != admin {
497 panic("admin only")
498 }
499}
500
501func mustGetListing(id int64) *listing {
502 v := listings.Get(padID(id))
503 if v == nil {
504 panic("unknown listing id")
505 }
506 return v.(*listing)
507}
508
509// assertValidTitle bounds length and restricts the charset so titles
510// are list-safe in Render without escaping.
511func assertValidTitle(title string) {
512 if len(title) == 0 || len(title) > MaxTitleLen {
513 panic("title must be 1-" + strconv.Itoa(MaxTitleLen) + " characters")
514 }
515 for i := 0; i < len(title); i++ {
516 c := title[i]
517 switch {
518 case c >= 'a' && c <= 'z':
519 case c >= 'A' && c <= 'Z':
520 case c >= '0' && c <= '9':
521 case c == ' ' || c == '_' || c == '-':
522 default:
523 panic("title may only contain letters, digits, space, _ and -")
524 }
525 }
526}
527
528// padID renders an id as a fixed-width key so avl iteration order is
529// numeric order.
530func padID(id int64) string {
531 s := strconv.FormatInt(id, 10)
532 for len(s) < 12 {
533 s = "0" + s
534 }
535 return s
536}
537
538func itoa(n int64) string {
539 return strconv.FormatInt(n, 10)
540}