Service marketplace — gno.land/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/service_market
A custodial GNOT marketplace for services rather than goods. Providers publish a standing offer; a customer purchases it by paying the exact price into escrow; the provider marks the work delivered; and the escrow is released to the provider only once the customer accepts — or once the acceptance window lapses.
Built as an application-factory benchmark: an objective rather than a repository was supplied —
"a decentralized service marketplace where providers can register services, customers can purchase them with on-chain payment, completed work can be resolved, and payment is released to the provider only after successful resolution. Include protection against unauthorized settlement, double payment, and stuck funds."
— and the lifecycle was objective → discovery → architecture → reuse → implementation → testing → adversarial audit → remediation → GitHub → pearl-1.
Deployed
| Chain | pearl-1 |
| Height | 628080 |
| Tx | 9f9d9f42b14fae53261f2e81c1862f90783651fbea74cc434702d2be55c37c99 |
| Source commit | c9e0b29014ae1ad797d4d1c471803c88390f8989 |
| Realm address | g1fd72s0yvt0appn4grj4650nmp589lw27mf4me9 |
| Byte match | service_market.gno on-chain identical to the committed file |
| Closure Gate B | EQUIVALENT, 4 compared dependencies, 17 stdlib residual |
Live verification ran 11 transactions across two keys and 19 adversarial probes. Payment moved only on the customer's acceptance; the provider could not settle their own delivery; all three paths that could re-settle a terminal order refused; and the realm address ended holding exactly 0ugnot. Full record, including what was not exercised live: pearl/DEPLOYMENT.md §15.
DISCOVERY / REUSE ANALYSIS (summary; full record in DISCOVERY.md)
Four sources searched: the Cosmic Bull catalog; all 33 .gno files under pearl/p/ and pearl/r/ read as source, not via catalog summaries; an exhaustive enumeration of pearl-1 (500 packages at height 626979 — 167 /p/, 333 /r/, every prefix queried); and the public Gno / GitHub ecosystem. No relevant existing implementation was found in the searched sources — bounded by the confidence limits recorded in DISCOVERY.md §6, which include one unresolved gap (AmozPay/gbnty, GitHub API rate limit exhausted).
Two properties were the decisive filter, because they are the heart of the objective: real coin custody (a chain/banker and an OriginSend envelope, not integers moving in a ledger), and a resolution step gating payout (value immobile across transactions until a separate resolving act releases it).
The delta, stated precisely: every existing Cosmic Bull escrow realm escrows funder-first — the party who will later decide escrows their own money and then solicits work. This objective requires customer-first escrow against a standing provider offer. Four consequences follow, and they are the new work:
- A standing offer that survives N orders.
market's listing is single-shot (Active → Soldis terminal), so one listing is one sale. A service offering must be purchasable repeatedly. - Escrow on the purchase path. No package found has both pay-against-a-pre-existing-offer and hold-pending-resolution.
- A per-order state machine —
Purchased → Delivered → Released, with escrow immobile until the acceptance edge. - Valve polarity inverts. Existing valves refund the funder on inaction. Here, after delivery, customer silence must eventually release to the provider — otherwise a non-responsive customer traps completed work. Copying a sibling valve unchanged would ship a live griefing vector.
Classification of the significant hits:
| Package | Class | Action |
|---|---|---|
p/.../coinio (own, live) | COSMIC BULL EXISTING PRIMITIVE | Reused as deployed |
p/.../feeledger (own, live) | COSMIC BULL EXISTING PRIMITIVE | Reused as deployed |
p/nt/avl/v0 | REUSABLE EXISTING PRIMITIVE | Reused |
p/nt/markdown/sanitize/v0 | REUSABLE EXISTING PRIMITIVE | Reused |
r/market, r/bounty_panel, r/grants, r/bounties (own, live) | RELATED IMPLEMENTATION | Pattern sources, not modified or redeployed |
r/service_registry (own, live) | RELATED (complementary) | Deliberately not used — its own INTEGRATOR CONTRACT forbids routing a payment off one of its resolutions |
r/samcrew/escrow_v3 | RELATED — closest functional neighbour | Rejected, with reasons (below) |
r/samcrew/agent_registry_v2 | RELATED | Rejected: settlement is provider-asserted |
r/g16m0r7…/{escrow,bountyboard,…}, r/g1nyjym92…/escrow | RELATED | Rejected: no custody at all, plus designation forgery |
p/duebook, p/permbook (own, live) | REUSABLE, not needed | Reasons recorded in DISCOVERY.md §4 |
escrow_v3 was the one genuine reuse call that went against reuse, and the reasoning is recorded so a reviewer can disagree on evidence. It is genuinely custodial and resolution-gated — but it is a bilateral contract, not a marketplace: CreateContract(cur realm, freelancer address, …) means the client names the freelancer, so there is no provider registration and no discoverable catalog. Its authority is a hardcoded AdminAddress with no rotation function, and that single key is simultaneously sole dispute arbiter, a unilateral ReleaseFunds path, and a permanent Pause() kill switch over every fund path. It derives identity via unsafe.PreviousRealm() inside crossing functions (security.md Class 2 shape), and hard-imports r/samcrew/memba_market_config, so adopting it means inheriting a third party's live DAO fee spine.
No new /p/ package was created. Composition was sufficient.
Architecture & state ownership
| Concern | Owner |
|---|---|
Services, orders, the two-level state machine, escrowTotal, deadlines, provider counters, roles | this realm (new) |
| Claimable balances (U), fee pot (F), fee arithmetic, overflow checks | feeledger (deployed instance) |
| Receipt guard, payouts, reserve-protected sweeps | coinio (deployed instance) |
Free-text markdown neutralization in Render | p/nt/markdown/sanitize/v0 |
| Ordered iteration | p/nt/avl/v0 |
Two levels. A service is a reusable offer; an order is one purchase of it. Order terminal states are frozen and reached exactly once.
service: Active ──RetireService (provider)──> Retired
(blocks NEW orders only; in-flight orders untouched)
order: Purchased ──MarkDelivered (provider)──> Delivered
──DeclineOrder (provider)──> Refunded fee-free
──ClaimRefund (anyone, after deliveryDeadline)──> Refunded fee-free
Delivered ──AcceptDelivery (customer)────────────> Released THE RESOLUTION
──ReleaseTimeout (anyone, after acceptanceDeadline)─> Released
Every value exit is a PULL. Release and refund only move numbers between the escrow pool and a claimable balance; the beneficiary later calls Claim/ClaimAll. Nothing in this realm ever pushes coins to a third party — which is exactly what lets both timeout valves stay permissionless: a stranger triggering ReleaseTimeout or ClaimRefund performs no send, so a beneficiary that cannot receive a send can never brick the valve.
Acceptance is a protocol constant, not a provider setting. The provider chooses the delivery window — their own commitment, their own risk — but AcceptanceBlocks is fixed at 140,000 (~7 days at pearl's observed ~4.2s blocks). Were it provider-chosen, a provider would set it to zero, mark work delivered, and auto-release in the same block: settlement without resolution, wearing the costume of a timeout.
The payee is copied into the order at purchase. Neither retiring the service nor any later edit can redirect an in-flight order's payment, and the fee that order will pay is snapshotted at the same moment.
Late delivery is allowed while the refund valve is still un-pulled. The delivery deadline exists to protect the customer; destroying late work the customer still wants does not serve them. The valve closes the instant MarkDelivered lands, and reopening it is not possible.
Invariant specification
Monetary (conservation): H == E + U + F + S, S >= 0 — coins held at the realm address equal live escrow + claimable balances + fee pot + out-of-band surplus. Asserted in delta form throughout the suite (the test banker resets per test while realm globals persist).
Application: escrowTotal == Σ amount over {Purchased, Delivered} — re-derived from the orders themselves by appInvariant in every test, because that identity is what SweepDenom's reserve rests on. Terminal statuses are frozen. feeledger guarantees credited + fee == amount.
Fee model: snapshotted into the service at registration and copied into each order at purchase, so an admin fee change reaches neither an existing offer nor an in-flight order. Additionally capped by the provider's own maxFeeBps argument, which closes the registration-time race (the grants audit Y1 pattern, applied at the offer rather than the grant). Compile-time cap MaxFeeBps = 1000 bps = 10%. Refunds are always fee-free — charging a fee on a refund would mean the marketplace profits from provider non-performance.
The three objective-named hazards:
- Unauthorized settlement — every identity comes from
cur.Previous().Address(); no entrypoint accepts a caller identity as a parameter (the designation-forgery shape that disqualified most of the escrow realms found in discovery). There is no admin path to any order's escrow: the admin sets the fee for future registrations, the fee recipient, and their own successor. That is all.SweepDenomreservesLiabilities(), which includes live escrow. - Double payment — an order's status is checked and driven terminal in the same transaction that moves its value, and
escrowTotalis decremented in lockstep with the credit. Release and refund both require a non-terminal status, so at most one of them can ever succeed for a given order and neither can succeed twice. Payout is pull-based and debits the ledger beforecoinio.Payoutmoves the identical amount. - Stuck funds — both counterparties hold a permissionless valve against the other's inaction. A provider who never delivers loses the escrow back to the customer at the delivery deadline; a customer who never accepts loses it to the provider at the acceptance deadline. Neither valve trusts its caller, and neither pays its caller.
Security / audit
Adversarially audited pre-deploy by the gno-auditor agent against the local workspace source plus the two frozen first-party dependencies, with the three required protections named explicitly in the brief.
Verdict: PROCEED WITH CAUTION — 0 RED, 3 YELLOW. All three were remediated, a targeted re-audit of the remediation diff was run, and that re-audit raised one further finding, which was also remediated.
The three required protections — all confirmed GREEN
| Requirement | Basis |
|---|---|
| No unauthorized settlement | Every identity derives from the crossing entrypoint's cur.Previous().Address() — 13 cited sites. No function takes a caller identity as a parameter, so there is no security.md Class 2 designation-forgery shape anywhere. The payee is copied into the order at purchase and never re-read from the service. |
| No double payment | Check-and-write in the same non-reentrant frame at five audited edges. release and refund each drive the order status terminal and decrement escrowTotal in lockstep with the ledger credit; every entry guard requires a non-terminal status. coinio.Payout is reached only on claim paths, after the ledger debit. |
| No stuck funds | Both valves are permissionless and perform ledger moves only, never a send, so an unreceivable beneficiary cannot brick them. The re-audit strengthened this: coinio.Receive's IsUserCall() gate means o.customer is structurally always an EOA, so no order's exit can ever depend on a realm caller. |
Also confirmed: the monetary invariant holds, escrowTotal cannot under-reserve, the fee-snapshot path is sound, all overflow sites were traced, the per-provider counter cannot desynchronise, Render is bounded and sanitized, there is no Class 1a/1b/3/4 surface, and the payment guard is canonical (IsUserCall(), not IsUser()).
Findings and remediations
| # | Severity | Finding | Remediation | Status |
|---|---|---|---|---|
| Y1 | 🟡 (95%) | assertNoSend reads the origin transaction's envelope, not this realm's receipt, so a realm caller is locked out of every non-payable entrypoint whenever the same transaction attached -send anywhere. Fail-closed availability defect; not third-party triggerable; no fund loss. It lands on ClaimRefund/ReleaseTimeout — the two functions the design wants permissionless — so a realm-based keeper bot is silently unusable. | Header caveat narrowed to name the affected calls explicitly and to state why requirement 3 survives. A code fix was considered and rejected, and the re-audit independently agreed: a receipt-scoped check does not exist as a primitive here, and dropping the guard from the valves would trade a fail-closed availability quirk for a fail-open stranding risk. | ✅ doc |
| Y2 | 🟡 (88%) | RegisterService affirmatively invited realm providers, but a realm provider's credit is extractable only if that realm shipped a crossing entrypoint calling Claim, in a -send-free transaction. Realm bytes are immutable after addpkg, so a realm deployed without that path has credits permanently stranded. Documentation defect with a funds consequence. | The obligations are now stated at the RegisterService doc, together with the EOA-only asymmetry on the customer side. Gating RegisterService to EOAs was rejected as changing what the application is. | ✅ doc |
| Y3 | 🟡 (82%) | TransferAdmin accepted an unvalidated address where the adjacent SetFeeRecipient checks !next.IsValid(). Graded on shape presence per the catalog floor, not on exploitability — the two-step handover already defeated the invalid-address case, and the auditor traced every escrowTotal and ledger mutation to confirm the admin has no path to escrow or to any claimable balance. | if next != "" && !next.IsValid() { panic("invalid admin address") }, preserving the zero address as the documented cancel sentinel. Covered by two new assertions in TestAdminHandover. | ✅ code |
| Y-A | 🟡 (92%) | Raised by the re-audit, against the Y1/Y2 remediations themselves. Both new enumerations omitted MarkDelivered — the only transition to OrderDelivered, which both release paths require. A realm provider that ships the documented Claim entrypoint but no MarkDelivered entrypoint never earns a credit to claim at all: every order against it runs to ClaimRefund. | MarkDelivered added to both lists, and the Y2 lead-in corrected to distinguish never earns (obligation 1) from earned but stranded (obligations 2–3). | ✅ doc |
The Y3 fix is the only behavioural change; it adds a panic on a strictly narrower input set than before, reachable only by the admin, before any state write. The re-audit confirmed no new finding was introduced by any edit and that the new test assertions are not vacuous — the cancel assertion in particular fails if the next != "" escape is removed, so it genuinely pins the sentinel.
Recorded for reviewer disagreement — candidates removed in the FP pass
ClaimRefundfront-running. A refund credits the customer, never the caller, so a griefer pays gas for zero gain. Restricting the call would violate requirement 3.SetFeeRecipientto a non-transacting address. Fully recoverable — admin-gated, and touches neitherEnorU.- Naming
DeclineOrderandRetireServicein the Y2 obligations. Held below the re-audit's confidence threshold: both are provider-only andassertNoSend-gated, but neither is required to collect, so a realm provider that omits them loses optional control, not funds.
Audit scope limits, stated honestly
- Not verified against any deployment. Both passes read local workspace source. Nothing in this section attests that these bytes match a live realm; §7a Gate B runs separately, immediately before deploy.
- Dependency closure was not verified by the auditor (no Bash in that tool allowlist). See the Gate A table under Testing.
- Tests were read, not executed, by the auditor. Execution results are reported separately under Testing.
p/nt/avl/v0andp/nt/markdown/sanitize/v0were not read.- Procedural deviation, in both passes. The mandated two-pass false-positive filter dispatches its second pass to a fresh context via the Task tool, which is unavailable inside subagents. Both audits ran the second pass inline instead. That preserves the filter's logic but not its fresh-context anti-anchoring property, so the reported confidence figures carry less independence than the procedure intends.
Testing
25 tests, all passing (gno test . → ok . 0.61s); gno lint . clean and gno fmt -diff . empty, under the chain-matched toolchain pinned to gno c4c72fdd288c. The suite covers the two-level state machine, every authorization boundary, the fee ceiling and both snapshot races, escrow isolation from the admin, terminal-state finality in every combination, double-claim, both stuck-funds arms, the late-delivery race, sweep reserve behaviour, the two-step admin handover, render sanitization and bounding, the stray-send guard on every non-payable entrypoint, int64 overflow at the escrow sum, and a full multi-party lifecycle asserting conservation at every checkpoint.
Dependency-closure Gate A (CLAUDE.md §7a) was run before this test result was believed, with the checker's own suite green first (tools/test_verify_depclosure.sh → 22 passed, 0 failed):
| walk | verdict | compared deps | digests |
|---|---|---|---|
| deployed closure | EQUIVALENT | 4 | f440a3b3… == f440a3b3… |
--include-tests | DIVERGENT | 7 | c786a35b… vs 242d41b5… |
All three --include-tests findings are the already-documented test-only case in docs/DEPENDENCY_CLOSURE.md §5a, not new: the pinned cache holds mainnet's p/nt/uassert/v0/uassert.gno, whose only difference from pearl-1's is an import path (p/onbloc/diff/v0 vs p/onbloc/diff) whose contents are byte-identical either way — which generates the two path-set findings. uassert and diff are compiled into the test binary and never into a deployed realm, so they are outside this realm's deployed closure. 17 stdlib packages execute from the node binary and cannot be compared; equivalence for them rests on the GNOROOT pin.
Harness note, recorded so it is not relearned. testing.SetRealm is keyed by frame index — it applies to the frame that calls it. A helper that sets the realm and returns leaves the test's own frame untouched, and the call then lands as the default test caller rather than the intended one. This suite was first written with a nested as(from) helper and 66 call sites were silently testing the wrong caller; every provider/customer assertion that looked green was green for the wrong reason. All 66 are now staged inline in the calling frame, matching the sibling suites. The two helpers that do stage (registerAs, purchaseAs) are safe only because each makes its entrypoint call from inside its own frame.
Second harness artifact, asserted rather than glossed: an aborted PurchaseService still lands its simulated -send at the realm in the test VM, where on chain the abort would revert it. Those tests assert the amount shows up as out-of-band surplus — which is the point, since it proves the rejected payment reached neither escrow nor any claimable balance.
Gas / storage characteristics
Bounded by construction: MaxServicesPerProvider 20 (namespace monopolization was RED in both permission_registry and service_registry), RenderLimit 20 (unbounded Render was YELLOW in three prior audits), MaxTitleLen 80 with a restricted charset, MaxDescLen 2000, MaxURILen 400, delivery windows in [850, 1_200_000] blocks (~1 hour to ~60 days). The delivery URI is a reference to the deliverable — this realm neither fetches nor interprets it, and storing bulk content would push unbounded cost onto every future reader.
Per-service and per-order state growth is paid for by the party creating it, via the storage deposit. Render is the one surface whose cost lands on third parties, which is why it is the one that paginates.
Deploy payload: 33,389 bytes of realm source + a 90-byte gnomod.toml.
Measured on pearl-1 at deploy (not estimated): gas used 55,446,918 for a 166,342 ugnot gas fee, plus a 5,720,300 ugnot storage deposit — 57,203 bytes at the live 100ugnot/byte, a 1.704× overhead on the 33,562 bytes actually stored. Live calls cost a flat 20,000 ugnot of gas each; state storage dominates it by roughly 4:1. Across the whole live run — one service, two orders, and both settled and drained — storage deposits came to ~967k ugnot against ~220k of gas. Those deposits are a per-key total over several calls and are not cleanly attributable to a single per-service or per-order figure. Full breakdown in the deployment record.
Limitations
- No dispute arbitration, deliberately. This realm resolves on acceptance or on the acceptance timeout. It does not adjudicate whether delivered work was good. A customer who considers the work inadequate has no lever here beyond declining to accept, and the timeout will still pay the provider. That is a real limitation and it is the deliberate price of the property above it: an arbiter empowered to redirect escrow is a party who can seize funds, and the closest realm found in discovery carries exactly that shape. A deployment that needs adjudicated disputes needs that designed, audited and authorized as its own application.
- Quality is unverifiable on-chain. The chain cannot confirm that the delivered work exists, is the provider's own, or matches the offer.
- The two sides are not symmetric in who may participate. A provider may be an EOA or a realm —
RegisterServicederives the provider fromcur.Previous().Address()and imposes no shape. A customer must be an EOA calling directly viaMsgCall, becausecoinio.Receiverejects realm callers andmaketx run: that is the only shape where the chain guarantees the-sendenvelope landed at the realm address before the body ran. A realm therefore cannot buy a service here, only sell one. - Roles are hot testnet agent keys.
- Service and order records are never pruned;
service.ordersis a lifetime counter and is never decremented. - Retirement is terminal — there is no un-retire, so a customer reading "active" cannot have it flicker underneath them.
- Realm-provider caveat.
assertNoSendreads the origin transaction's send envelope, so a realm caller must invoke the non-payable functions in a transaction whose origin carried no-send, or the guard fails closed even though this realm received nothing. Not third-party triggerable; the workaround is a separate transaction. SweepDenomaborts rather than sending zero when there is no surplus (coinio's contract). Escrow is therefore unreachable by revert, not merely by arithmetic — the stronger of the two — but the call is not a no-op.AcceptanceBlocks= 140,000 is a fixed protocol constant; changing it is a redeploy, not a setting.
Dependency graph
service_market ──┬─> coinio ────> (stdlib)
├─> feeledger ─> p/nt/avl/v0
├─> p/nt/avl/v0
└─> p/nt/markdown/sanitize/v0
Deployment record: pearl/DEPLOYMENT.md.