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}