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.

service_market.gno

32.61 Kb · 917 lines
  1// Realm service_market is a custodial GNOT marketplace for SERVICES:
  2// providers publish a standing offer (title, description, price,
  3// delivery window), a customer purchases it by paying the exact price
  4// into escrow, the provider marks the work delivered, and the escrow is
  5// released to the provider only once the customer accepts — or once the
  6// acceptance window lapses.
  7//
  8// COMPOSITION (per the recorded DISCOVERY / REUSE ANALYSIS in
  9// DISCOVERY.md): balance accounting is feeledger, coin movement is
 10// coinio, free-text render safety is p/nt/markdown/sanitize/v0. This
 11// realm owns only the two-level offer/order state machine, which the
 12// discovery gate found nowhere else: every escrow realm in the searched
 13// sources escrows FUNDER-FIRST (the party who will later decide escrows
 14// first, then solicits work), while a service marketplace must escrow
 15// CUSTOMER-FIRST against a standing offer.
 16//
 17// LIFECYCLE. A service is a reusable offer; an order is one purchase of
 18// it. Order terminal states are frozen and reached exactly once.
 19//
 20//	RegisterService (anyone, no coins) : status Active. The fee bps is
 21//	                                     SNAPSHOTTED under the
 22//	                                     provider's own maxFeeBps
 23//	                                     ceiling.
 24//	RetireService (provider only)      : Active -> Retired. Blocks NEW
 25//	                                     orders only; orders already in
 26//	                                     flight are untouched.
 27//	PurchaseService (EOA + -send)      : order Purchased, amount into
 28//	                                     escrow. Sets the delivery
 29//	                                     deadline.
 30//	MarkDelivered (provider only)      : Purchased -> Delivered. Starts
 31//	                                     the acceptance window.
 32//	AcceptDelivery (customer only)     : Delivered -> Released. THE
 33//	                                     RESOLUTION. Escrow becomes the
 34//	                                     provider's claimable balance
 35//	                                     minus the snapshotted fee.
 36//	ReleaseTimeout (anyone)            : Delivered -> Released after
 37//	                                     AcceptanceBlocks. Deemed
 38//	                                     acceptance.
 39//	DeclineOrder (provider only)       : Purchased -> Refunded, fee-free.
 40//	ClaimRefund (anyone)               : Purchased -> Refunded after the
 41//	                                     delivery deadline, fee-free.
 42//	Claim / ClaimAll (anyone)          : pays out the caller's own
 43//	                                     claimable balance.
 44//
 45// EVERY VALUE EXIT IS A PULL. Release and refund only move numbers
 46// between the escrow pool and a claimable balance; the beneficiary
 47// later calls Claim. Nothing in this realm ever pushes coins to a third
 48// party, which is what lets both timeout valves stay permissionless: a
 49// stranger triggering ReleaseTimeout or ClaimRefund performs no send, so
 50// a beneficiary that cannot receive a send can never brick the valve.
 51//
 52// THE THREE REQUIRED PROTECTIONS.
 53//
 54// Unauthorized settlement: every identity derives from the crossing
 55// entrypoint's cur.Previous().Address(); no function takes a caller
 56// identity as a parameter (the designation-forgery shape that
 57// disqualified most of the escrow realms found in discovery). The payee
 58// is COPIED INTO THE ORDER at purchase, so neither retiring the service
 59// nor any later edit can redirect an in-flight order's payment. There is
 60// no admin path to any order's escrow: the admin sets the fee for FUTURE
 61// registrations and nothing else.
 62//
 63// Double payment: an order's status is checked and driven terminal in
 64// the same transaction that moves its value, and escrowTotal is
 65// decremented in lockstep with the credit. Release and refund both
 66// require a non-terminal status, so at most one of them can ever
 67// succeed for a given order, and neither can succeed twice.
 68//
 69// Stuck funds: both counterparties have a permissionless valve against
 70// the other's inaction. A provider who never delivers loses the escrow
 71// back to the customer at the delivery deadline (ClaimRefund); a
 72// customer who never accepts loses it to the provider at the acceptance
 73// deadline (ReleaseTimeout). Neither valve trusts its caller.
 74//
 75// ACCEPTANCE IS A PROTOCOL CONSTANT, NOT A PROVIDER SETTING. The
 76// provider chooses the delivery window (their own commitment, and their
 77// own risk), but AcceptanceBlocks is fixed. Were it provider-chosen, a
 78// provider would set it to zero, mark work delivered, and auto-release
 79// in the same block — settlement without resolution, wearing the
 80// costume of a timeout.
 81//
 82// MONETARY INVARIANT (conservation). With H = ugnot held at the realm
 83// address, E = escrowTotal (Σ amount over Purchased and Delivered
 84// orders), U = claimable balances, F = the fee pot, S >= 0 out-of-band
 85// surplus:
 86//
 87//	H == E + U + F + S
 88//
 89// PurchaseService raises H and E by exactly the price. Release moves
 90// amount from E to U+F (feeledger guarantees credited + fee == amount);
 91// refund moves amount from E to U at zero fee. Claim*/WithdrawFees debit
 92// the ledger before coinio.Payout moves the identical amount out. Any
 93// panic aborts the whole transaction; this realm never issues or removes
 94// coins. Surplus is recoverable only via SweepDenom (fee recipient),
 95// which reserves Liabilities() = E + U + F.
 96//
 97// LIMITATION — NO DISPUTE ARBITRATION, DELIBERATELY. This realm resolves
 98// on acceptance or on the acceptance timeout. It does NOT adjudicate
 99// whether delivered work was good. A customer who considers the work
100// inadequate has no lever here beyond declining to accept, and the
101// timeout will still pay the provider. That is a real limitation and it
102// is the deliberate price of the property above: an arbiter empowered to
103// redirect escrow is a party who can seize funds, and the closest realm
104// found in discovery (r/samcrew/escrow_v3) carries exactly that shape —
105// a single hardcoded admin key, with no rotation function, that is
106// simultaneously sole arbiter, a unilateral release path, and a
107// permanent pause switch over every fund path. Adding arbitration here
108// would change what this application IS and expand its security model,
109// so it is refused rather than smuggled in. A deployment that needs
110// adjudicated disputes needs that designed, audited and authorized as
111// its own application.
112//
113// REALM-CALLER CAVEAT: assertNoSend reads the ORIGIN transaction's send
114// envelope, not this realm's receipt, so a realm caller is refused by
115// every non-payable function whenever the SAME transaction attached a
116// -send anywhere — even though this realm received nothing. It fails
117// closed, it is not third-party triggerable, and the workaround is to
118// isolate the call in its own -send-free transaction.
119//
120// Named concretely, because the generic phrasing understates where it
121// lands. It applies to every exported function except PurchaseService,
122// the only payable one, but four of them carry the weight:
123// MarkDelivered, without which no order against a realm provider can
124// ever reach a release; ReleaseTimeout and ClaimRefund, the two valves
125// this design deliberately leaves permissionless; and Claim / ClaimAll,
126// the only paths that extract a credited balance. A realm-based keeper
127// bot that batches a valve call into a transaction carrying coins for
128// some other purpose is therefore silently unusable, and a realm
129// provider must budget a dedicated transaction both to deliver and to
130// collect.
131// Requirement 3 (no stuck funds) survives this: both valves are
132// permissionless and any EOA can pull them, so no order depends on a
133// realm caller to unstick.
134package service_market
135
136import (
137	"chain"
138	"chain/runtime"
139	"chain/runtime/unsafe"
140	"strconv"
141
142	"gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/coinio"
143	"gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/feeledger"
144	"gno.land/p/nt/avl/v0"
145	"gno.land/p/nt/markdown/sanitize/v0"
146)
147
148// Denom is the only asset this realm accepts.
149const Denom = "ugnot"
150
151// MaxFeeBps is the hard protocol-fee cap: 1000 bps = 10%.
152const MaxFeeBps = int64(1000)
153
154// AcceptanceBlocks is how long a customer has to accept a delivery
155// before anyone may release it to the provider. It is a PROTOCOL
156// constant on purpose — see the header. ~7 days at pearl's observed
157// ~4.2s blocks.
158const AcceptanceBlocks = int64(140000)
159
160// Service status values.
161const (
162	ServiceActive  = "active"
163	ServiceRetired = "retired"
164)
165
166// Order status values. Released and Refunded are terminal.
167const (
168	OrderPurchased = "purchased"
169	OrderDelivered = "delivered"
170	OrderReleased  = "released"
171	OrderRefunded  = "refunded"
172)
173
174// Input bounds.
175const (
176	MaxTitleLen = 80
177	MaxDescLen  = 2000
178	MaxURILen   = 400
179	MinPrice    = int64(1)
180
181	// MinDeliveryBlocks/MaxDeliveryBlocks bound the provider's own
182	// turnaround commitment: ~1 hour to ~60 days.
183	MinDeliveryBlocks = int64(850)
184	MaxDeliveryBlocks = int64(1200000)
185
186	// MaxServicesPerProvider bounds catalog monopolization by a single
187	// address. The same finding was RED in both permission_registry and
188	// service_registry; it is cheaper to carry the counter from the
189	// start than to discover it in an audit.
190	MaxServicesPerProvider = 20
191
192	// RenderLimit bounds the home page. An unbounded Render was YELLOW
193	// in three prior audits.
194	RenderLimit = 20
195)
196
197type service struct {
198	id             int64
199	provider       address
200	title          string
201	description    string
202	price          int64
203	deliveryBlocks int64
204	feeBps         int64 // snapshotted at registration, charged at release
205	status         string
206	orders         int64 // lifetime count, never decremented
207}
208
209type order struct {
210	id        int64
211	serviceID int64
212	customer  address
213	provider  address // copied at purchase; never re-read from the service
214	amount    int64
215	feeBps    int64 // copied at purchase from the service's snapshot
216	status    string
217
218	deliveryDeadline   int64 // absolute height; meaningful while Purchased
219	acceptanceDeadline int64 // absolute height; set at MarkDelivered
220	deliveryURI        string
221}
222
223var (
224	admin        address // may set fee, fee recipient, stage successor
225	pendingAdmin address // staged by TransferAdmin, completes via AcceptAdmin
226	feeRecipient address // may withdraw fees and sweep surplus
227	feeBps       int64   // fee snapshotted into NEW services
228
229	self        address // this realm's address, captured at deploy
230	nextService int64
231	nextOrder   int64
232	escrowTotal int64 // == Σ amount over Purchased and Delivered orders
233
234	services    = avl.NewTree() // padID(id)   -> *service
235	orders      = avl.NewTree() // padID(id)   -> *order
236	providerNum = avl.NewTree() // address     -> *int64, services per provider
237	ledger      = feeledger.MustNew(MaxFeeBps)
238)
239
240func init() {
241	admin = unsafe.OriginCaller()
242	feeRecipient = admin
243	self = unsafe.CurrentRealm().Address()
244}
245
246// --- provider side ---
247
248// RegisterService publishes a standing offer and returns its id. No
249// coins are accepted; the storage deposit the caller pays is the
250// anti-spam. The current protocol fee is snapshotted into the service
251// and must not exceed maxFeeBps, the ceiling the provider signed for —
252// pass MaxFeeBps to accept any legal fee.
253//
254// Providers may be EOAs or realms, but a realm provider takes on three
255// obligations that this realm cannot check for it. Realm bytes are
256// immutable after addpkg, so a realm deployed without them cannot
257// acquire them later: failing (1) means it never earns, failing (2) or
258// (3) means what it earned is permanently stranded.
259//
260//  1. It must already expose a crossing entrypoint that calls
261//     MarkDelivered. That is the only transition to OrderDelivered, and
262//     both release paths require it, so a provider realm that cannot
263//     call it never earns a credit at all — every order against it runs
264//     to ClaimRefund instead. This obligation binds before the next one.
265//  2. It must already expose a crossing entrypoint that calls Claim or
266//     ClaimAll on this realm. A credited balance is a pull, never a
267//     push; nothing here can reach into a provider realm to deliver it.
268//  3. It must make both calls in transactions whose ORIGIN carried no
269//     -send, per the REALM-CALLER CAVEAT in the package header.
270//
271// The customer side is not symmetric: PurchaseService is payable and
272// routes through coinio.Receive, which requires an EOA calling directly
273// via MsgCall. A realm can therefore sell a service here but cannot buy
274// one — and "can sell" is exactly as strong as the three obligations
275// above, no stronger. A customer is structurally always an EOA, which
276// is why the permissionless valves are always pullable.
277func RegisterService(cur realm, title, description string, price, deliveryBlocks, maxFeeBps int64) int64 {
278	assertNoSend()
279	provider := cur.Previous().Address()
280
281	assertValidTitle(title)
282	if len(description) > MaxDescLen {
283		panic("description must be at most " + strconv.Itoa(MaxDescLen) + " characters")
284	}
285	if price < MinPrice {
286		panic("price must be at least " + itoa(MinPrice) + Denom)
287	}
288	if deliveryBlocks < MinDeliveryBlocks || deliveryBlocks > MaxDeliveryBlocks {
289		panic("deliveryBlocks must be " + itoa(MinDeliveryBlocks) + "-" + itoa(MaxDeliveryBlocks))
290	}
291	if maxFeeBps < 0 || maxFeeBps > MaxFeeBps {
292		panic("maxFeeBps must be 0-" + itoa(MaxFeeBps))
293	}
294	if feeBps > maxFeeBps {
295		panic("current fee " + itoa(feeBps) + " bps exceeds your ceiling of " + itoa(maxFeeBps) + " bps")
296	}
297	if countFor(provider) >= MaxServicesPerProvider {
298		panic("at most " + strconv.Itoa(MaxServicesPerProvider) + " services per provider")
299	}
300
301	id := nextService
302	nextService++
303	services.Set(padID(id), &service{
304		id:             id,
305		provider:       provider,
306		title:          title,
307		description:    description,
308		price:          price,
309		deliveryBlocks: deliveryBlocks,
310		feeBps:         feeBps,
311		status:         ServiceActive,
312	})
313	bumpFor(provider, 1)
314
315	chain.Emit("ServiceRegistered",
316		"id", itoa(id),
317		"provider", provider.String(),
318		"price", itoa(price),
319		"feeBps", itoa(feeBps),
320	)
321	return id
322}
323
324// RetireService stops a service accepting NEW orders. Orders already in
325// flight keep their own copies of provider, amount and fee, and run to
326// their own terminal states unaffected. Provider only, and terminal —
327// there is no un-retire, so a customer reading "active" cannot have it
328// flicker underneath them.
329func RetireService(cur realm, id int64) {
330	assertNoSend()
331	caller := cur.Previous().Address()
332	s := mustGetService(id)
333	if caller != s.provider {
334		panic("only the provider may retire this service")
335	}
336	if s.status != ServiceActive {
337		panic("service is not active")
338	}
339	s.status = ServiceRetired
340	bumpFor(s.provider, -1)
341
342	chain.Emit("ServiceRetired", "id", itoa(id), "provider", s.provider.String())
343}
344
345// --- customer side ---
346
347// PurchaseService escrows EXACTLY the listed price and opens an order.
348// The caller must be an EOA (coinio.Receive is the receipt-guaranteed
349// shape) and must attach exactly the price in ugnot; any mismatch aborts
350// and the coins revert with the transaction. Self-purchase is rejected.
351func PurchaseService(cur realm, serviceID int64) int64 {
352	customer, amount := coinio.Receive(0, cur, Denom)
353	s := mustGetService(serviceID)
354	if s.status != ServiceActive {
355		panic("service is not active")
356	}
357	if customer == s.provider {
358		panic("the provider cannot purchase their own service")
359	}
360	if amount != s.price {
361		panic("send exactly the listed price: " + itoa(s.price) + Denom)
362	}
363
364	newEscrow, ok := checkedAdd(escrowTotal, amount)
365	if !ok {
366		panic("escrow total would overflow")
367	}
368
369	id := nextOrder
370	nextOrder++
371	orders.Set(padID(id), &order{
372		id:               id,
373		serviceID:        serviceID,
374		customer:         customer,
375		provider:         s.provider,
376		amount:           amount,
377		feeBps:           s.feeBps,
378		status:           OrderPurchased,
379		deliveryDeadline: runtime.ChainHeight() + s.deliveryBlocks,
380	})
381	escrowTotal = newEscrow
382	s.orders++
383
384	chain.Emit("ServicePurchased",
385		"orderId", itoa(id),
386		"serviceId", itoa(serviceID),
387		"customer", customer.String(),
388		"provider", s.provider.String(),
389		"amount", itoa(amount),
390	)
391	return id
392}
393
394// MarkDelivered records that the work is done and starts the acceptance
395// window. Provider only. The uri is an opaque reference to the
396// deliverable; this realm neither fetches nor interprets it, and it is
397// sanitized on the way out to Render.
398//
399// Delivering AFTER the delivery deadline is allowed as long as nobody
400// has yet called ClaimRefund — late work the customer still wants should
401// not be destroyed by a deadline that exists to protect the customer.
402// The customer's remedy is unchanged: they simply do not accept, and the
403// refund valve stays open right up until this transition lands.
404func MarkDelivered(cur realm, orderID int64, uri string) {
405	assertNoSend()
406	caller := cur.Previous().Address()
407	o := mustGetOrder(orderID)
408	if caller != o.provider {
409		panic("only the provider may mark this order delivered")
410	}
411	if o.status != OrderPurchased {
412		panic("order is not awaiting delivery")
413	}
414	if len(uri) > MaxURILen {
415		panic("uri must be at most " + strconv.Itoa(MaxURILen) + " characters")
416	}
417
418	o.status = OrderDelivered
419	o.deliveryURI = uri
420	o.acceptanceDeadline = runtime.ChainHeight() + AcceptanceBlocks
421
422	chain.Emit("OrderDelivered",
423		"orderId", itoa(orderID),
424		"provider", o.provider.String(),
425		"acceptanceDeadline", itoa(o.acceptanceDeadline),
426	)
427}
428
429// AcceptDelivery is THE RESOLUTION: the customer accepts the delivered
430// work and the escrow becomes the provider's claimable balance, minus
431// the fee snapshotted into the order at purchase. Customer only.
432func AcceptDelivery(cur realm, orderID int64) {
433	assertNoSend()
434	caller := cur.Previous().Address()
435	o := mustGetOrder(orderID)
436	if caller != o.customer {
437		panic("only the customer may accept this delivery")
438	}
439	if o.status != OrderDelivered {
440		panic("order is not awaiting acceptance")
441	}
442	release(o, "accepted")
443}
444
445// ReleaseTimeout releases a delivered order to the provider once the
446// acceptance window has lapsed. Permissionless: a customer who stops
447// responding must not be able to strand a provider's completed work.
448// This is deemed acceptance, and it is the only path by which escrow
449// reaches a provider without the customer's explicit act.
450func ReleaseTimeout(cur realm, orderID int64) {
451	assertNoSend()
452	o := mustGetOrder(orderID)
453	if o.status != OrderDelivered {
454		panic("order is not awaiting acceptance")
455	}
456	if runtime.ChainHeight() < o.acceptanceDeadline {
457		panic("acceptance window has not lapsed: " +
458			itoa(o.acceptanceDeadline-runtime.ChainHeight()) + " blocks remain")
459	}
460	release(o, "timeout")
461}
462
463// DeclineOrder returns the escrow to the customer before delivery.
464// Provider only, fee-free — a provider who cannot fulfil should be able
465// to hand the money straight back rather than wait out a deadline. Safe
466// to allow unilaterally because it only ever moves value AWAY from the
467// caller.
468func DeclineOrder(cur realm, orderID int64) {
469	assertNoSend()
470	caller := cur.Previous().Address()
471	o := mustGetOrder(orderID)
472	if caller != o.provider {
473		panic("only the provider may decline this order")
474	}
475	if o.status != OrderPurchased {
476		panic("order is not awaiting delivery")
477	}
478	refund(o, "declined")
479}
480
481// ClaimRefund returns the escrow to the customer once the provider has
482// missed the delivery deadline. Permissionless: a provider who abandons
483// an order must not be able to strand the customer's money.
484//
485// It is fee-free. Charging a protocol fee on a refund would mean the
486// marketplace profits from provider non-performance.
487func ClaimRefund(cur realm, orderID int64) {
488	assertNoSend()
489	o := mustGetOrder(orderID)
490	if o.status != OrderPurchased {
491		panic("order is not awaiting delivery")
492	}
493	if runtime.ChainHeight() < o.deliveryDeadline {
494		panic("delivery window has not lapsed: " +
495			itoa(o.deliveryDeadline-runtime.ChainHeight()) + " blocks remain")
496	}
497	refund(o, "expired")
498}
499
500// --- payouts ---
501
502// Claim sends amount ugnot of the caller's claimable balance back to the
503// caller.
504func Claim(cur realm, amount int64) {
505	assertNoSend()
506	caller := cur.Previous().Address()
507	if err := ledger.Withdraw(caller.String(), amount); err != nil {
508		panic(err)
509	}
510	coinio.Payout(0, cur, caller, Denom, amount)
511	chain.Emit("Claim", "to", caller.String(), "amount", itoa(amount))
512}
513
514// ClaimAll sends the caller's entire claimable balance back to the
515// caller. Fails if there is nothing to claim.
516func ClaimAll(cur realm) {
517	assertNoSend()
518	caller := cur.Previous().Address()
519	amount, err := ledger.WithdrawAll(caller.String())
520	if err != nil {
521		panic(err)
522	}
523	if amount == 0 {
524		panic("nothing to claim")
525	}
526	coinio.Payout(0, cur, caller, Denom, amount)
527	chain.Emit("Claim", "to", caller.String(), "amount", itoa(amount))
528}
529
530// WithdrawFees sends the accrued fee pot to the fee recipient. Only the
531// fee recipient may call it.
532func WithdrawFees(cur realm) {
533	assertNoSend()
534	caller := cur.Previous().Address()
535	if caller != feeRecipient {
536		panic("only the fee recipient may withdraw fees")
537	}
538	if ledger.FeesAccrued() == 0 {
539		panic("no fees accrued")
540	}
541	amount := ledger.WithdrawFees()
542	coinio.Payout(0, cur, feeRecipient, Denom, amount)
543	chain.Emit("FeesWithdrawn", "to", feeRecipient.String(), "amount", itoa(amount))
544}
545
546// SweepDenom sends the surplus of a single denomination to the fee
547// recipient. For ugnot only the excess over Liabilities() moves — which
548// reserves live escrow as well as claimable balances and the fee pot —
549// and other denoms move wholly. Only the fee recipient may call it.
550func SweepDenom(cur realm, denom string) {
551	assertNoSend()
552	caller := cur.Previous().Address()
553	if caller != feeRecipient {
554		panic("only the fee recipient may sweep surplus")
555	}
556	reserve := int64(0)
557	if denom == Denom {
558		reserve = Liabilities()
559	}
560	swept := coinio.Sweep(0, cur, feeRecipient, denom, reserve)
561	chain.Emit("SurplusSwept", "to", feeRecipient.String(), "coins", itoa(swept)+denom)
562}
563
564// --- administration ---
565
566// SetFeeBps sets the protocol fee snapshotted into FUTURE services.
567// Existing services, and every order already placed against them, keep
568// the fee they were created under. Admin only; bounded by [0, MaxFeeBps].
569func SetFeeBps(cur realm, bps int64) {
570	assertNoSend()
571	assertAdmin(cur.Previous().Address())
572	if bps < 0 || bps > MaxFeeBps {
573		panic("fee must be 0-" + itoa(MaxFeeBps) + " bps")
574	}
575	feeBps = bps
576	chain.Emit("FeeChanged", "bps", itoa(bps))
577}
578
579// SetFeeRecipient sets the address that may withdraw fees and sweep
580// surplus. Admin only.
581func SetFeeRecipient(cur realm, next address) {
582	assertNoSend()
583	assertAdmin(cur.Previous().Address())
584	if !next.IsValid() {
585		panic("invalid fee recipient address")
586	}
587	feeRecipient = next
588	chain.Emit("FeeRecipientChanged", "to", next.String())
589}
590
591// TransferAdmin stages a successor. Two-step: the successor must call
592// AcceptAdmin, so a typo cannot orphan the realm. Admin only. Passing
593// the zero address cancels a pending nomination; any other value must be
594// a well-formed address, for parity with SetFeeRecipient. The two-step
595// handover already made a malformed nominee harmless — it could never
596// call AcceptAdmin — so this check rejects the typo at the point it is
597// made rather than leaving it staged and readable as a real nomination.
598func TransferAdmin(cur realm, next address) {
599	assertNoSend()
600	assertAdmin(cur.Previous().Address())
601	if next != "" && !next.IsValid() {
602		panic("invalid admin address")
603	}
604	pendingAdmin = next
605	chain.Emit("AdminNominated", "to", next.String())
606}
607
608// AcceptAdmin completes a staged handover. Only the nominee may call it.
609func AcceptAdmin(cur realm) {
610	assertNoSend()
611	caller := cur.Previous().Address()
612	if pendingAdmin == "" || caller != pendingAdmin {
613		panic("only the nominated admin may accept")
614	}
615	admin = caller
616	pendingAdmin = ""
617	chain.Emit("AdminTransferred", "to", admin.String())
618}
619
620// --- views ---
621
622func ServiceInfo(id int64) (provider address, title string, price, deliveryBlocks, feeBps int64, status string, orderCount int64) {
623	s := mustGetService(id)
624	return s.provider, s.title, s.price, s.deliveryBlocks, s.feeBps, s.status, s.orders
625}
626
627func ServiceDescription(id int64) string { return mustGetService(id).description }
628
629func OrderInfo(id int64) (serviceID int64, customer, provider address, amount, feeBps int64, status string) {
630	o := mustGetOrder(id)
631	return o.serviceID, o.customer, o.provider, o.amount, o.feeBps, o.status
632}
633
634// OrderDeadlines returns the two absolute heights that govern an order.
635// acceptanceDeadline is 0 until the order is delivered.
636func OrderDeadlines(id int64) (deliveryDeadline, acceptanceDeadline int64) {
637	o := mustGetOrder(id)
638	return o.deliveryDeadline, o.acceptanceDeadline
639}
640
641func OrderURI(id int64) string { return mustGetOrder(id).deliveryURI }
642
643// Quote reports what a purchase of this service would cost and what the
644// provider would receive on resolution, at the service's snapshotted fee.
645func Quote(id int64) (price, fee, toProvider int64) {
646	s := mustGetService(id)
647	f, err := feeledger.FeeFor(s.price, s.feeBps)
648	if err != nil {
649		panic(err)
650	}
651	return s.price, f, s.price - f
652}
653
654func Admin() address             { return admin }
655func PendingAdmin() address      { return pendingAdmin }
656func FeeRecipient() address      { return feeRecipient }
657func FeeBps() int64              { return feeBps }
658func NumServices() int64         { return nextService }
659func NumOrders() int64           { return nextOrder }
660func ServicesOf(a address) int64 { return countFor(a) }
661
662func BalanceOf(addr address) int64 { return ledger.BalanceOf(addr.String()) }
663func UsersTotal() int64            { return ledger.UsersTotal() }
664func FeesAccrued() int64           { return ledger.FeesAccrued() }
665
666// EscrowTotal is the live escrow pool E: the sum of every order still in
667// Purchased or Delivered.
668func EscrowTotal() int64 { return escrowTotal }
669
670// Liabilities is everything this realm owes: live escrow, claimable
671// balances and the fee pot. SweepDenom reserves exactly this.
672func Liabilities() int64 { return escrowTotal + ledger.Liabilities() }
673
674func Held() int64      { return coinio.HeldAt(self, Denom) }
675func Surplus() int64   { return Held() - Liabilities() }
676func Address() address { return self }
677func Height() int64    { return runtime.ChainHeight() }
678
679// --- render ---
680
681func Render(path string) string {
682	if len(path) > 2 && path[:2] == "s/" {
683		return renderService(path[2:])
684	}
685	if len(path) > 2 && path[:2] == "o/" {
686		return renderOrder(path[2:])
687	}
688	held := Held()
689	liab := Liabilities()
690	status := "OK"
691	if held < liab {
692		status = "VIOLATED"
693	}
694	out := "# Service marketplace\n\n"
695	out += "Providers publish services; customers purchase into escrow; payment is\n"
696	out += "released only on acceptance, or when the acceptance window lapses.\n\n"
697	out += "## Configuration\n\n"
698	out += "- fee for new services: " + itoa(feeBps) + " bps (cap " + itoa(MaxFeeBps) +
699		" bps; each service keeps the fee snapshotted at registration)\n"
700	out += "- acceptance window: " + itoa(AcceptanceBlocks) + " blocks\n"
701	out += "- fee recipient: " + feeRecipient.String() + "\n"
702	out += "- admin: " + admin.String() + "\n\n"
703	out += "## Accounting (H == E + U + F + S)\n\n"
704	out += "- escrow held for open orders (E): " + itoa(escrowTotal) + Denom + "\n"
705	out += "- claimable balances (U): " + itoa(ledger.UsersTotal()) + Denom + "\n"
706	out += "- fees accrued (F): " + itoa(ledger.FeesAccrued()) + Denom + "\n"
707	out += "- held (H): " + itoa(held) + Denom + "\n"
708	out += "- conservation: " + status + "\n\n"
709	out += "## Latest services\n\n"
710	if nextService == 0 {
711		out += "No services yet.\n"
712		return out
713	}
714	shown := 0
715	services.ReverseIterate("", "", func(_ string, v any) bool {
716		s := v.(*service)
717		out += "- [#" + itoa(s.id) + "](" + realmPath + ":s/" + itoa(s.id) + ") [" +
718			s.status + "] " + s.title + " — " + itoa(s.price) + Denom + "\n"
719		shown++
720		return shown >= RenderLimit
721	})
722	return out
723}
724
725func renderService(arg string) string {
726	id, err := strconv.ParseInt(arg, 10, 64)
727	if err != nil {
728		return "> [!WARNING]\n> invalid service id\n"
729	}
730	v := services.Get(padID(id))
731	if v == nil {
732		return "> [!WARNING]\n> unknown service id\n"
733	}
734	s := v.(*service)
735	out := "# Service #" + itoa(s.id) + ": " + s.title + "\n\n"
736	out += "- status: " + s.status + "\n"
737	out += "- provider: " + s.provider.String() + "\n"
738	out += "- price: " + itoa(s.price) + Denom + "\n"
739	out += "- delivery window: " + itoa(s.deliveryBlocks) + " blocks\n"
740	out += "- fee (snapshot): " + itoa(s.feeBps) + " bps\n"
741	out += "- orders placed: " + itoa(s.orders) + "\n"
742	out += "\n## Description\n\n" + sanitize.InlineText(s.description) + "\n"
743	return out
744}
745
746func renderOrder(arg string) string {
747	id, err := strconv.ParseInt(arg, 10, 64)
748	if err != nil {
749		return "> [!WARNING]\n> invalid order id\n"
750	}
751	v := orders.Get(padID(id))
752	if v == nil {
753		return "> [!WARNING]\n> unknown order id\n"
754	}
755	o := v.(*order)
756	out := "# Order #" + itoa(o.id) + "\n\n"
757	out += "- status: " + o.status + "\n"
758	out += "- service: [#" + itoa(o.serviceID) + "](" + realmPath + ":s/" + itoa(o.serviceID) + ")\n"
759	out += "- customer: " + o.customer.String() + "\n"
760	out += "- provider: " + o.provider.String() + "\n"
761	out += "- amount: " + itoa(o.amount) + Denom + "\n"
762	out += "- fee (snapshot): " + itoa(o.feeBps) + " bps\n"
763	out += "- delivery deadline: height " + itoa(o.deliveryDeadline) + "\n"
764	if o.acceptanceDeadline != 0 {
765		out += "- acceptance deadline: height " + itoa(o.acceptanceDeadline) + "\n"
766	}
767	if o.deliveryURI != "" {
768		out += "\n## Delivery\n\n" + sanitize.InlineText(o.deliveryURI) + "\n"
769	}
770	return out
771}
772
773// --- internals ---
774
775const realmPath = "/r/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/service_market"
776
777// release moves an order's escrow into the provider's claimable balance
778// at the order's snapshotted fee, and drives the order terminal. The
779// status write and the escrow decrement happen together with the credit,
780// so no order can be released twice and E stays exactly Σ open amounts.
781func release(o *order, reason string) {
782	credited, fee, err := ledger.Deposit(o.provider.String(), o.amount, o.feeBps)
783	if err != nil {
784		panic(err)
785	}
786	o.status = OrderReleased
787	escrowTotal -= o.amount // >= 0: escrowTotal == Σ open amounts >= o.amount
788
789	chain.Emit("OrderReleased",
790		"orderId", itoa(o.id),
791		"provider", o.provider.String(),
792		"amount", itoa(o.amount),
793		"credited", itoa(credited),
794		"fee", itoa(fee),
795		"reason", reason,
796	)
797}
798
799// refund moves an order's escrow into the customer's claimable balance
800// at ZERO fee and drives the order terminal.
801func refund(o *order, reason string) {
802	credited, _, err := ledger.Deposit(o.customer.String(), o.amount, 0)
803	if err != nil {
804		panic(err)
805	}
806	o.status = OrderRefunded
807	escrowTotal -= o.amount
808
809	chain.Emit("OrderRefunded",
810		"orderId", itoa(o.id),
811		"customer", o.customer.String(),
812		"amount", itoa(credited),
813		"reason", reason,
814	)
815}
816
817// assertNoSend refuses coins on non-payable entrypoints: an accidental
818// -send would otherwise strand at the realm as sweep-only surplus.
819// PurchaseService is the only payable function. NOTE: this reads the
820// ORIGIN envelope — see the realm-provider caveat in the header.
821func assertNoSend() {
822	if len(unsafe.OriginSend()) != 0 {
823		panic("this function does not accept coins")
824	}
825}
826
827func assertAdmin(caller address) {
828	if caller != admin {
829		panic("admin only")
830	}
831}
832
833func mustGetService(id int64) *service {
834	v := services.Get(padID(id))
835	if v == nil {
836		panic("unknown service id")
837	}
838	return v.(*service)
839}
840
841func mustGetOrder(id int64) *order {
842	v := orders.Get(padID(id))
843	if v == nil {
844		panic("unknown order id")
845	}
846	return v.(*order)
847}
848
849// countFor reports how many ACTIVE services an address holds.
850func countFor(a address) int64 {
851	v := providerNum.Get(a.String())
852	if v == nil {
853		return 0
854	}
855	return *(v.(*int64))
856}
857
858// bumpFor adjusts the active-service counter, removing the entry at zero
859// so a provider who retires everything stops paying for the slot.
860func bumpFor(a address, delta int64) {
861	key := a.String()
862	v := providerNum.Get(key)
863	if v == nil {
864		if delta <= 0 {
865			return
866		}
867		n := delta
868		providerNum.Set(key, &n)
869		return
870	}
871	p := v.(*int64)
872	*p += delta
873	if *p <= 0 {
874		providerNum.Remove(key)
875	}
876}
877
878// assertValidTitle bounds length and restricts the charset so titles are
879// list-safe in Render without escaping.
880func assertValidTitle(title string) {
881	if len(title) == 0 || len(title) > MaxTitleLen {
882		panic("title must be 1-" + strconv.Itoa(MaxTitleLen) + " characters")
883	}
884	for i := 0; i < len(title); i++ {
885		c := title[i]
886		switch {
887		case c >= 'a' && c <= 'z':
888		case c >= 'A' && c <= 'Z':
889		case c >= '0' && c <= '9':
890		case c == ' ' || c == '_' || c == '-':
891		default:
892			panic("title may only contain letters, digits, space, _ and -")
893		}
894	}
895}
896
897// padID renders an id as a fixed-width key so avl iteration order is
898// numeric order.
899func padID(id int64) string {
900	s := strconv.FormatInt(id, 10)
901	for len(s) < 12 {
902		s = "0" + s
903	}
904	return s
905}
906
907func checkedAdd(a, b int64) (int64, bool) {
908	c := a + b
909	if (b > 0 && c < a) || (b < 0 && c > a) {
910		return 0, false
911	}
912	return c, true
913}
914
915func itoa(n int64) string {
916	return strconv.FormatInt(n, 10)
917}