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

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/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/coinio"
143	"gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/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/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/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}