- docs/ARCHITECTURE.md: settlement-asset framing (AP3X bonded in escrow, settles on accepted work); remove references to external network adapters that are not part of the project - docs/buyer + docs/supplier READMEs: commission/bond/settle vocabulary - Buyer web UI: "Accept & Settle", escrow-framed labels, gateway-managed wallet wording; gateway landing title punctuation - Delete EXECUTIVE_SUMMARY and local-llm-marketplace docs + exports: M1-era internal design snapshots superseded by ARCHITECTURE.md and the public docs site Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
22 KiB
Local Agents Marketplace — Architecture (v1)
Status: accepted for M0–M1 implementation Dated: 2026-04-24 Scope: MVP validating "can agents drive prompts through a buyer → on-chain escrow → supplier inference path on Vector L2 / Cardano" — starting with a CPU-bound supplier on Hetzner.
1. Goal and scope
What this is. A two-sided marketplace where buyers (agents or humans) submit prompts to suppliers running inference on their own hardware, with each job settled in AP3X through on-chain bonded escrow on Vector L2, and receive signed-receipt responses. The primary validation goal is technical feasibility + lifecycle ("can it be done end-to-end"), not demand modelling or economic stress testing.
What v1 is NOT.
- Not agentic on the supplier side. Suppliers answer single prompts. Buyer agents drive multiple prompts.
- Not LLM-only in design. Capability model is open from day 1 (whisper / kokoro / image generation are planned siblings), but the first supplier is an LLM.
- Not multi-slot. Single-slot per supplier process. Multi-capability operators run multiple processes.
- Not multi-chain. Vector only (testnet and mainnet).
- Not disputed. M1 ships happy-path escrow only. Module-1 disputes wire in M2.
- Not streaming.
stream: trueis rejected by suppliers. - Not tool-calling.
tools/tool_choice/functionsare rejected by suppliers. - Not privacy-preserving end-to-end. TLS to supplier only, SaaS-parity model, documented in supplier ToS.
2. Decisions resolved (Q&A → architecture inputs)
| # | Decision |
|---|---|
| 1 | Settlement asset: AP3X, bonded in escrow; jobs settle in AP3X on accepted work. |
| 2 | v1 scope: Option C — happy-path escrow only, no disputes. Module-1 wiring is milestone 2. |
| 3 | Module-1 contract source: TBD — to be located or restored before M2. Not blocking M0–M1. |
| 4 | Evidence: signed receipt {prompt_hash, response_hash, model, tokens, wallclock} + Ed25519 supplier signature. Hash on-chain, plaintext off-chain. |
| 5 | Capability model: one capability per supplier process, flat-per-prompt pricing. Multi-capability operators run multiple processes. |
| 6 | Supplier status: polled by indexer every ~20 s, staleness visible to buyers. |
| 7 | Supplier registry: separate script hash from Module-1, independently indexed. |
| 8 | First capability: llm.text.generate.v1 — single-shot, no tools, no stream, flat-per-prompt, tiny Ollama (qwen2.5:0.5b) on CPU. |
| 9 | Tests: three-tier (mock / Ogmios read-only / real chain) with an explicit ChainProvider interface from day 1. Real CBOR fixtures. Independent buyer/supplier fixture construction. |
| 10 | Chain follower: extend the apex-dashboard pattern (Node.js + Ogmios + SQLite + REST/SSE). |
| 11 | Supplier process: single-slot. |
| 12 | Pricing race: buyer's escrow datum references the ad UTxO (spec-lock). Supplier honors the price in the referenced ad or lets the escrow time out. |
| 13 | SLA: supplier publishes max_processing_ms in the advertisement. Buyer sets deliver_by = posted_at + max_processing_ms + network_buffer. |
| 14 | Bonds: symmetric 1 AP3X on both sides. Buyer miss → forfeits bond. Supplier miss → forfeits bond. |
| 15 | Endpoint auth: X-Escrow-Ref header. Supplier verifies escrow on-chain before computing. |
| 16 | Privacy: SaaS-parity. TLS to supplier. Documented in ToS. |
3. Component diagram
┌──────────────────┐ ┌────────────────────────┐
│ Buyer app │ HTTP + X-Escrow-Ref │ Supplier node │
│ (web UI + lib │────────────────────────▶│ FastAPI / Node │
│ for agents) │ │ /capability │
│ │ │ /status │
│ optional: │ │ /v1/chat/completions │
│ OpenRouter │ │ │ │
│ client-side │ │ ▼ │
│ fallback │ │ Ollama (local LLM) │
└────────┬─────────┘ └──────────┬─────────────┘
│ │
│ REST / SSE │
▼ │
┌──────────────────────────────┐ │
│ Indexer │◀──── poll /status ────┘
│ (chain follower + status │
│ poller, SQLite + REST/SSE) │
└──────────────┬───────────────┘
│
│ Ogmios (chain-sync + queryLedgerState)
▼
┌──────────────────────────────────────────────────────────────┐
│ Vector L2 (Cardano) │
│ AdvertScript EscrowScript (Module-1, M2+) │
└──────────────────────────────────────────────────────────────┘
Three on-chain scripts: AdvertScript (registry), EscrowScript (happy-path escrow), Module-1 (disputes, deferred). Indexer and Ogmios are shared infra. OpenRouter is a buyer-side client escape hatch — outside the protocol.
4. On-chain datum schemas
4.1 AdvertDatum
AdvertDatum {
supplier_pkh: VerificationKeyHash,
capability_id: ByteArray, // "llm.text.generate.v1"
model: ByteArray, // "qwen2.5:0.5b"
max_output_tokens: Int,
max_processing_ms: Int, // SLA for deliver_by math
price_lovelace: Int, // AP3X lovelace, flat per prompt
supplier_bond_lovelace: Int,
buyer_bond_lovelace: Int,
endpoint_url: ByteArray, // https://...
detail_uri: ByteArray, // off-chain JSON pointer
detail_hash: ByteArray(32), // sha256 of detail JSON
advertised_at: POSIXTime,
status: AdvertStatus // Active | Retired
}
Redeemers: PostAdvert | UpdateAdvert | RetireAdvert
One supplier process = one AdvertDatum UTxO. Update = spend old + post new (spec-lock race resolved by referencing the specific UTxO in escrow).
4.2 EscrowDatum (happy path, v1)
EscrowDatum {
buyer_pkh: VerificationKeyHash,
supplier_pkh: VerificationKeyHash,
advert_ref: OutputReference, // SPEC-LOCK to ad UTxO
capability_id: ByteArray, // duplicated for indexer filter
request_spec_hash: ByteArray(32), // sha256 canonical JSON of request envelope
prompt_hash: ByteArray(32), // sha256 of messages
payment_lovelace: Int,
buyer_bond_lovelace: Int,
supplier_bond_lovelace: Int,
deliver_by: POSIXTime,
posted_at: POSIXTime,
submitted_at: Option<POSIXTime>, // set on Submit
result_receipt_hash: Option<ByteArray>, // set on Submit
state: EscrowState
}
EscrowState = Open | Claimed | Submitted | Accepted | Reclaimed | Released
4.3 Redeemers
| Redeemer | Signer | Precondition | Effect |
|---|---|---|---|
Claim |
supplier | state=Open | Open → Claimed |
Submit |
supplier | state=Claimed | Claimed → Submitted, writes submitted_at + result_receipt_hash |
Accept |
buyer | state=Submitted | Submitted → Accepted; supplier gets payment + own bond; buyer gets own bond |
Reclaim |
buyer | state∈{Open,Claimed} AND now ≥ deliver_by |
→ Reclaimed; buyer takes payment + both bonds |
Release |
supplier | state=Submitted AND now ≥ submitted_at + ACCEPT_WINDOW |
→ Released; supplier gets payment + own bond + buyer bond |
Constants (proposed, v1):
ACCEPT_WINDOW = 10 minnetwork_buffer = 30 s(buyer-side convention, not enforced on-chain)
4.4 State diagram
Reclaim (after deliver_by)
┌──────────────────────┐
▼ │
┌─────────┐ │
PostEscrow ─▶ │ Open │ ─ Claim ─┐ │
└─────────┘ ▼ │
┌─────────┐│
┌────────── │ Claimed │┘
│ └────┬────┘
Reclaim │ Submit
(after deliver_by) ▼
│ ┌───────────┐
▼ │ Submitted │
┌──────────┐ └─────┬─────┘
│Reclaimed │ ┌────────┴────────┐
└──────────┘ │ Accept │ Release
│ (buyer) │ (after ACCEPT_WINDOW)
▼ ▼
┌──────────┐ ┌──────────┐
│ Accepted │ │ Released │
└──────────┘ └──────────┘
Terminal states: Accepted, Reclaimed, Released.
5. HTTP contracts
5.1 Supplier endpoints
GET /capability
→ { capability_id, model, max_output_tokens, max_processing_ms,
price_lovelace, advert_ref, supplier_pkh }
Must match on-chain advertisement. Buyer SHOULD verify.
GET /status
→ { status: "free"|"working"|"offline",
current_escrow_ref?: "txHash#ix",
last_seen: iso8601 }
POST /v1/chat/completions
Headers: X-Escrow-Ref: <txHash>#<ix>
Body: OpenAI-compatible ChatCompletionRequest
Rejects: stream=true, tools[], tool_choice, functions
Rejects: capability mismatch, max_output_tokens > advertised
Flow:
1. Pull escrow UTxO by ref (via Ogmios query)
2. Verify: state=Open, supplier_pkh=me, capability_id matches,
request_spec_hash = sha256(canonical(envelope)),
prompt_hash = sha256(messages)
3. Submit Claim tx (Open → Claimed)
4. Call local LLM
5. Build receipt: { prompt_hash, response_hash, model,
prompt_tokens, completion_tokens, wallclock_ms,
supplier_pkh, escrow_ref }
6. Sign receipt (Ed25519 with supplier key)
7. Submit Submit tx with result_receipt_hash = sha256(canonical(receipt))
8. Return { choices, usage, receipt, receipt_signature }
5.2 Indexer endpoints
GET /suppliers — all Active adverts + cached status
GET /suppliers/{pkh} — single supplier detail + recent jobs
GET /capabilities — distinct capability_id list with counts
GET /capabilities/{id}/suppliers — filtered list, sortable by price/latency
GET /escrows/{ref} — state lookup by OutputReference
GET /escrows?buyer={pkh}|supplier={pkh}
GET /events?stream=1 — SSE: new ads, escrow transitions
GET /health — sync cursor, Ogmios status
Every field traces back to chain via advert_ref / escrow_ref. Indexer serves cached data for speed.
6. Chain-follower extension (from apex-dashboard)
Reused as-is: WsTransport, SqliteCache core, ChainSyncWorker cursor/rollback logic, HTTP+SSE scaffold.
Added for marketplace:
- Config entry
config/marketplace-deployments.jsonwithadvert_script_hash+escrow_script_hash. - Two new address roles:
advert,escrow. - Two new decoders:
decodeAdvertDatum,decodeEscrowDatum. - Two new tables:
advertisements,escrows(keyed by OutputReference). - New event types:
PostAdvert,UpdateAdvert,RetireAdvert,PostEscrow,ClaimEscrow,SubmitEscrow,AcceptEscrow,ReclaimEscrow,ReleaseEscrow. - Status poller: background loop polling each Active supplier's
/statusevery 20 s; writes tosupplier_statustable (off-chain). - Aggregator: per-capability active-supplier counts, p95 wallclock from receipts.
Rollback semantics inherited: soft-delete + UTxO resurrection, per apex-dashboard sqlite-cache.ts:328-360.
7. Test architecture
7.1 Repository layout (target)
packages/
shared/
chain/
ChainProvider.ts # interface: queryUtxo, submitTx, evaluateTx, tip
MockChainProvider.ts # Tier 1 — in-memory, synthetic slots
ReadOnlyOgmiosProvider.ts # Tier 2 — eval+query only, no submit
LiveOgmiosProvider.ts # Tier 3 — full submit
cbor/
AdvertDatum.ts
EscrowDatum.ts
canonical.ts # deterministic JSON (RFC-8785 subset)
receipt/
build.ts sign.ts verify.ts
buyer/
supplier/
indexer/
contracts/
escrow/ advert/
tests/
unit/ # Tier 1, vitest
ogmios/ # Tier 2, read-only
lifecycle/ # Tier 3, docker-composed
fixtures/
buyer-side/ # independent CBOR builders (no shared helper)
supplier-side/ # independent CBOR builders
golden/ # cross-validation CBOR blobs
7.2 Discipline (from apex-dashboard + refinements)
- Real CBOR in fixtures (cbor-x), not JSON stubs.
- Buyer and supplier tests each build datums independently from the spec. No shared helper. Golden files cross-validate.
ChainProvideris DI, not convention — fixes the missing seam in apex-dashboard.- Property/fuzz tests on the escrow state machine (not present in apex-dashboard).
- Tier 3 runs under
SKIP_BEFORE_SLOTfrom day 1 to avoid the glacial-sync pit. - Mainnet kill-switch: env-gate + explicit
MAINNET=1flag, hardcoded wallet allowlist.
7.3 Must-have adversarial cases for v1
Malformed datum · escrow to wrong script · supplier claims escrow addressed to other supplier · request_spec_hash / prompt_hash mismatch · Submit after deliver_by · Accept after ACCEPT_WINDOW · Reclaim before deliver_by · double-claim · double-submit · replay of Accept/Release · two concurrent escrows to same single-slot supplier · supplier status lies (free while actually working) · advert updated mid-flight (spec-lock must hold).
8. Milestones
| M | Goal | Duration | Exit criteria |
|---|---|---|---|
| M0 | Skeleton + ChainProvider seam |
~1 wk | Monorepo + CI + Tier 1 mock escrow lifecycle green |
| M1 | Happy-path end-to-end, Hetzner CPU supplier | 2–3 wks | qwen2.5:0.5b on Hetzner, buyer posts → supplier claims → submits → buyer accepts; full Vector testnet lifecycle; indexer serves discovery |
| M2 | Module-1 dispute wiring | 2–3 wks post-M1 | Contract source located; supplier=claimer / buyer=auditor mapping; Dispute state; evidence URI; mainnet-dry-run lifecycle |
| M3 | Capability expansion + local lab | 2–4 wks (may overlap M2) | speech.transcribe.v1 via Whisper live; local-lab GPU supplier with larger model; heterogeneous discovery validated |
9. Open follow-ups (not blocking M0 / M1)
- Locate Module-1 contract source before M2. Initial grep across local checkouts failed — possibly on another machine or in a private repo.
- Canonical JSON for
request_spec_hash,prompt_hash, receipt. Proposal: RFC-8785 JCS subset (sorted keys, UTF-8 NFC, no whitespace). ACCEPT_WINDOWandnetwork_bufferconstants. Proposed 10 min / 30 s.- Receipt signing key: same wallet key as
supplier_pkh, or derived Ed25519 sub-key? Same-key is simpler; sub-key isolates risk. - Mainnet safety: env-gate + explicit flag + wallet allowlist before any mainnet Tier-3 run.
- Ollama-failure-leaves-Claimed (v1 hazard): if the supplier fails between Claim and Submit, the escrow stays in
Claimedstate on-chain. v1 has no recovery path — buyer must wait fordeliver_byandReclaim. M2+ should add either a supplier-side "abandonment" redeemer or a "failure receipt" path that releases funds without going through the dispute module. - Buyer-side wallet fixture has invalid Ed25519 priv (62 hex chars / 31 bytes;
tests/fixtures/buyer-side/wallet-keys.ts:32). Currently harmless because no test signs with the buyer key, but M1-E will need this fixed via the samepriv → pub → pkh → bech32derivation Catherine used for the supplier in M1-C. - Supplier
index.tsboots withReadOnlyOgmiosProviderwhich throws onsubmitTx. Real-chain Claim/Submit is therefore broken until M1-F adds aLiveOgmiosProvider. Boot script must either swap providers or branch on env. - Buyer SDK uses structural receipt-signature check, not real Ed25519 in Tier-1 unit tests. The supplier-side wallet fixture's
SUPPLIER_PKHis currently a hand-typed placeholder that does NOT equalblake2b224(SUPPLIER_PUB_KEY_HEX), so the SDK can't perform cryptographicverifyReceipt(...)end-to-end against fixtures. M1-F must (a) re-deriveSUPPLIER_PKHfrom the real pubkey and update any hardcoded references, (b) ensure mocks return a realpub_key_hexfrom/capabilitymatching that derivation, (c) replace the structural check (receipt_signatureis 128-char hex and not all zeros) withverifyReceipt({receipt, signature}, pub_key_hex)from@marketplace/shared/receipt. Real-chain Tier-3 lifecycle in M1-F naturally exercises the cryptographic path. - Buyer SDK
httpClient.tscarries anHttpError.isSyncThrowflag and a "record-failure-and-return-sentinel" branch insubmitPrompt. This was Catherine's defensive workaround for a Caroline test fixture that produced invalid hex synchronously (now fixed). Production fetch never throws synchronously; the branch is dead code. M1-F should remove the flag and the sentinel branch. supplier/src/index.tsreadsSUPPLIER_PUB_KEY_HEX/SUPPLIER_PKH/SUPPLIER_ADDRESSfrom env with?? ""fallbacks rather than deriving them from the priv. Inline note in code: "M1-D will plug in the proper Ed25519 + blake2b-224 pipeline." For testnet rollout 2026-04-27 we pass these explicitly via supplier/.env (computed from cardano-cliaddress key-hash+ the .vkey cbor). Proper fix: derive inmain()using@noble/ed25519+blake2b224+bech32(the same pipeline the buyer fixture wallet uses) and stop reading the env vars.- Marketplace tx-builders produce synthetic JSON-in-hex CBOR, not real Cardano tx CBOR.
LiveOgmiosProvider.submitTxwill get HTTP 400 from Ogmios on any real tx. Workaround used 2026-04-27: a one-shotpycardanoscript (/tmp/post-advert.py, modelled on~/.openclaw/workspace-apex/testnet/deploy_agent_registry.py) builds a real Cardano CBOR PostAdvert tx and submits viahttps://submit.vector.testnet.apexfusion.org/api/submit/tx. Proper fix (M1-F-4): rewrite all tx builders inpackages/shared/src/tx/**using@lucid-evolution/lucid(already installed) sopnpm tx:post-advertand the supplier's Claim/Submit paths work without pycardano. Without this, the supplier/v1/chat/completionspath will fail at the Claim stage (same 400 from Ogmios) —/healthzand/capabilitywork today because they're read-only. - Min-UTxO at advert script address requires ≥ ~1.69 AP3X (datum size dependent). The on-chain advert posted 2026-04-27 locks 2 AP3X (rounded up). The Aiken validator does not constrain the locked value, so the operator-side tx builder must pick
max(supplier_bond_lovelace, min_utxo_for_datum)— currently hardcoded asmax(SBOND, 2_000_000)in/tmp/post-advert.py; M1-F-4's lucid-evolution rewrite should compute it dynamically from protocol params. tests/fixtures/supplier-side/wallet-keys.tsSUPPLIER_PKHis a placeholder"abcdef0123…01", NOTblake2b224(SUPPLIER_PUB_KEY_HEX). The correct value is9f356dd4cb466bbdddbfcfa3f4f61aa2a264cdb48707e60d1c829263and addressaddr1vx0n2mw5edrxh0wahl868a8kr232yexdkjrs0esdrjpfyccgazpvh. This was tolerable until M1-F-4 because tests didn't run real lucid UPLC; with M1-F-4 live-CBOR tests, the local validator rejects onsigned_bybecause the wallet's derived signing key produces a vkey witness that doesn't match the placeholder. Production is unaffected — the supplier's/capabilityreturns its real priv-derived pkh, buyer constructs EscrowDatum with that pkh, validator checks pass. Cleanup queued asM1-F-4-fix-fixtures. Blast radius: 17+ files (both goldens regenerate). After cleanup, the test-onlypresetWalletInputs ≥5 ADA paddingworkaround inpackages/shared/src/tx/internal/liveCbor.tsshould also be removed.tests/fixtures/...invalid-checksumaddr_test1wrqq9…script address decode-fails in CML; Catherine workaround: override input UTxO address to blueprint-derived form beforecollectFrom. SameM1-F-4-fix-fixturesround.- lucid-evolution / pnpm-store interaction:
libsodium-wrappers-sumo's wrapper doesimport "./libsodium-sumo.mjs"but pnpm hoists the.mjsto a sibling pkg. Today's runtime workaround: copy the file into the wrapper's dir post-install. Clean fix:pnpm patch libsodium-wrappers-sumowith the import-path change, commit the patch.
10. Reference pointers (external)
- Apex-dashboard chain-follower pattern:
apex-dashboard/server/chain-sync-worker.ts(private project; clone alongside this repo for cross-reference) - Apex-dashboard CBOR decoders:
apex-dashboard/server/cbor-decoder.ts - Apex-dashboard test discipline:
apex-dashboard/tests/ - Prior Python reference (AP3X buyer/supplier):
./docs/buyer/,./docs/supplier/