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

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}