Cosmic Bull

Rendered from pearl/r/service_market/README.md at commit f191063fd89d in the project repository. The committed file is the source of truth; this page is a rendering of it.

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

Chainpearl-1
Height628080
Tx9f9d9f42b14fae53261f2e81c1862f90783651fbea74cc434702d2be55c37c99
Source commitc9e0b29014ae1ad797d4d1c471803c88390f8989
Realm addressg1fd72s0yvt0appn4grj4650nmp589lw27mf4me9
Byte matchservice_market.gno on-chain identical to the committed file
Closure Gate BEQUIVALENT, 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:

  1. A standing offer that survives N orders. market's listing is single-shot (Active → Sold is terminal), so one listing is one sale. A service offering must be purchasable repeatedly.
  2. Escrow on the purchase path. No package found has both pay-against-a-pre-existing-offer and hold-pending-resolution.
  3. A per-order state machinePurchased → Delivered → Released, with escrow immobile until the acceptance edge.
  4. 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:

PackageClassAction
p/.../coinio (own, live)COSMIC BULL EXISTING PRIMITIVEReused as deployed
p/.../feeledger (own, live)COSMIC BULL EXISTING PRIMITIVEReused as deployed
p/nt/avl/v0REUSABLE EXISTING PRIMITIVEReused
p/nt/markdown/sanitize/v0REUSABLE EXISTING PRIMITIVEReused
r/market, r/bounty_panel, r/grants, r/bounties (own, live)RELATED IMPLEMENTATIONPattern 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_v3RELATED — closest functional neighbourRejected, with reasons (below)
r/samcrew/agent_registry_v2RELATEDRejected: settlement is provider-asserted
r/g16m0r7…/{escrow,bountyboard,…}, r/g1nyjym92…/escrowRELATEDRejected: no custody at all, plus designation forgery
p/duebook, p/permbook (own, live)REUSABLE, not neededReasons 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

ConcernOwner
Services, orders, the two-level state machine, escrowTotal, deadlines, provider counters, rolesthis realm (new)
Claimable balances (U), fee pot (F), fee arithmetic, overflow checksfeeledger (deployed instance)
Receipt guard, payouts, reserve-protected sweepscoinio (deployed instance)
Free-text markdown neutralization in Renderp/nt/markdown/sanitize/v0
Ordered iterationp/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:

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

RequirementBasis
No unauthorized settlementEvery 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 paymentCheck-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 fundsBoth 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

#SeverityFindingRemediationStatus
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

Audit scope limits, stated honestly

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):

walkverdictcompared depsdigests
deployed closureEQUIVALENT4f440a3b3… == f440a3b3…
--include-testsDIVERGENT7c786a35b… 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

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.