M1-F: end-to-end LLM marketplace lifecycle on Vector testnet

Bonded-escrow marketplace for prompt → response between buyers and
local-LLM suppliers, settling on Vector (UTxO) via Aiken Plutus V3
validators. Lifecycle PostEscrow → Claim → Submit → Accept proven on
Vector testnet 2026-05-06; AcceptEscrow tx 125e3cfe…7dc100d distributes
payment + bonds per the validator's distribution rule.

Workspace layout (pnpm + TypeScript):
  contracts/marketplace/      Aiken validators (advert + escrow) + plutus.json
  packages/shared/            Tx-builders (lucid-evolution), CBOR codecs,
                              chain providers (mock / read-only / live Ogmios)
  supplier/                   OpenAI-compatible HTTP node (Ollama by default;
                              vLLM / llama.cpp / OpenRouter / OpenClaw all
                              swap-in via OLLAMA_URL); 202+poll chat handler
  buyer/                      Express + React SPA; server-side runs the full
                              chain lifecycle via /v1/submit-prompt and
                              /v1/accept; SPA never holds the buyer key
  indexer/                    Chain-follower (extends apex-dashboard pattern),
                              SSE event stream + REST queries
  indexer-ui/                 Operator dashboard (events + suppliers + escrow
                              lookup)
  tests/                      1098 vitest unit + integration tests (10 skipped
                              under M1-F-D-defer for UPLC test plumbing)
  deploy/testnet/             Per-service docker-compose for the Hetzner stack
  docs/EXECUTIVE_SUMMARY.*    Marp deck (PDF + PPTX) — 11 slides, mermaid
                              diagrams pre-rendered for portable export
  docs/CLOUDFLARE_WAF_BYPASS.md  CF Skip-rule runbook for /v1/* paths
  docs/ARCHITECTURE.md        canonical spec

Live demo (testnet, 2026-05-06):
  https://mp-buyers.vector.testnet.apexfusion.org      (buyer SPA)
  https://mp-suppliers.vector.testnet.apexfusion.org   (supplier API)
  https://mp-indexer.vector.testnet.apexfusion.org     (indexer + UI)
This commit is contained in:
jelisaveta-talbot
2026-05-06 13:27:48 +02:00
commit cbab81efbd
323 changed files with 49725 additions and 0 deletions

314
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,314 @@
# 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, pay with AP3X via 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 testnet first, mainnet later. Masumi / Sokosumi / USDM is target #2 (adapter, not fork).
- Not disputed. M1 ships happy-path escrow only. Module-1 disputes wire in M2.
- Not streaming. `stream: true` is rejected by suppliers.
- Not tool-calling. `tools` / `tool_choice` / `functions` are 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 | Payment token: **AP3X** (Vector testnet, later mainnet). Masumi = USDM, target #2. |
| 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 min`
- `network_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.json` with `advert_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 `/status` every 20 s; writes to `supplier_status` table (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.
- `ChainProvider` is 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_SLOT` from day 1 to avoid the glacial-sync pit.
- Mainnet kill-switch: env-gate + explicit `MAINNET=1` flag, 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 |
| **M4** | Masumi / Sokosumi adapter | later | MIP-003 HTTP shim; MIP-002 NFT registration; USDM payment path; Sokosumi listing |
## 9. Open follow-ups (not blocking M0 / M1)
1. **Locate Module-1 contract source** before M2. Initial grep across local checkouts failed — possibly on another machine or in a private repo.
2. **Canonical JSON** for `request_spec_hash`, `prompt_hash`, receipt. Proposal: RFC-8785 JCS subset (sorted keys, UTF-8 NFC, no whitespace).
3. **`ACCEPT_WINDOW` and `network_buffer` constants**. Proposed 10 min / 30 s.
4. **Receipt signing key**: same wallet key as `supplier_pkh`, or derived Ed25519 sub-key? Same-key is simpler; sub-key isolates risk.
5. **Mainnet safety**: env-gate + explicit flag + wallet allowlist before any mainnet Tier-3 run.
6. **Ollama-failure-leaves-Claimed (v1 hazard)**: if the supplier fails between Claim and Submit, the escrow stays in `Claimed` state on-chain. v1 has no recovery path — buyer must wait for `deliver_by` and `Reclaim`. M2+ should add either a supplier-side "abandonment" redeemer or a "failure receipt" path that releases funds without going through the dispute module.
7. **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 same `priv → pub → pkh → bech32` derivation Catherine used for the supplier in M1-C.
8. **Supplier `index.ts` boots with `ReadOnlyOgmiosProvider`** which throws on `submitTx`. Real-chain Claim/Submit is therefore broken until M1-F adds a `LiveOgmiosProvider`. Boot script must either swap providers or branch on env.
9. **Buyer SDK uses structural receipt-signature check, not real Ed25519** in Tier-1 unit tests. The supplier-side wallet fixture's `SUPPLIER_PKH` is currently a hand-typed placeholder that does NOT equal `blake2b224(SUPPLIER_PUB_KEY_HEX)`, so the SDK can't perform cryptographic `verifyReceipt(...)` end-to-end against fixtures. M1-F must (a) re-derive `SUPPLIER_PKH` from the real pubkey and update any hardcoded references, (b) ensure mocks return a real `pub_key_hex` from `/capability` matching that derivation, (c) replace the structural check (`receipt_signature` is 128-char hex and not all zeros) with `verifyReceipt({receipt, signature}, pub_key_hex)` from `@marketplace/shared/receipt`. Real-chain Tier-3 lifecycle in M1-F naturally exercises the cryptographic path.
10. **Buyer SDK `httpClient.ts` carries an `HttpError.isSyncThrow` flag** and a "record-failure-and-return-sentinel" branch in `submitPrompt`. 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.
11. **`supplier/src/index.ts` reads `SUPPLIER_PUB_KEY_HEX`/`SUPPLIER_PKH`/`SUPPLIER_ADDRESS` from 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-cli `address key-hash` + the .vkey cbor). Proper fix: derive in `main()` using `@noble/ed25519` + `blake2b224` + `bech32` (the same pipeline the buyer fixture wallet uses) and stop reading the env vars.
12. **Marketplace tx-builders produce synthetic JSON-in-hex CBOR**, not real Cardano tx CBOR. `LiveOgmiosProvider.submitTx` will get HTTP 400 from Ogmios on any real tx. Workaround used 2026-04-27: a one-shot `pycardano` script (`/tmp/post-advert.py`, modelled on `~/.openclaw/workspace-apex/testnet/deploy_agent_registry.py`) builds a real Cardano CBOR PostAdvert tx and submits via `https://submit.vector.testnet.apexfusion.org/api/submit/tx`. Proper fix (M1-F-4): rewrite all tx builders in `packages/shared/src/tx/**` using `@lucid-evolution/lucid` (already installed) so `pnpm tx:post-advert` and the supplier's Claim/Submit paths work without pycardano. Without this, the supplier `/v1/chat/completions` path will fail at the Claim stage (same 400 from Ogmios) — `/healthz` and `/capability` work today because they're read-only.
13. **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 as `max(SBOND, 2_000_000)` in `/tmp/post-advert.py`; M1-F-4's lucid-evolution rewrite should compute it dynamically from protocol params.
14. **`tests/fixtures/supplier-side/wallet-keys.ts` `SUPPLIER_PKH` is a placeholder `"abcdef0123…01"`**, NOT `blake2b224(SUPPLIER_PUB_KEY_HEX)`. The correct value is `9f356dd4cb466bbdddbfcfa3f4f61aa2a264cdb48707e60d1c829263` and address `addr1vx0n2mw5edrxh0wahl868a8kr232yexdkjrs0esdrjpfyccgazpvh`. 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 on `signed_by` because the wallet's derived signing key produces a vkey witness that doesn't match the placeholder. **Production is unaffected** — the supplier's `/capability` returns its real priv-derived pkh, buyer constructs EscrowDatum with that pkh, validator checks pass. Cleanup queued as `M1-F-4-fix-fixtures`. Blast radius: 17+ files (both goldens regenerate). After cleanup, the test-only `presetWalletInputs ≥5 ADA padding` workaround in `packages/shared/src/tx/internal/liveCbor.ts` should also be removed.
15. **`tests/fixtures/...` invalid-checksum `addr_test1wrqq9…` script address** decode-fails in CML; Catherine workaround: override input UTxO address to blueprint-derived form before `collectFrom`. Same `M1-F-4-fix-fixtures` round.
16. **lucid-evolution / pnpm-store interaction**: `libsodium-wrappers-sumo`'s wrapper does `import "./libsodium-sumo.mjs"` but pnpm hoists the `.mjs` to a sibling pkg. Today's runtime workaround: copy the file into the wrapper's dir post-install. Clean fix: `pnpm patch libsodium-wrappers-sumo` with 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/`
- Masumi docs: https://docs.masumi.network/
- MIP-003 spec: https://docs.masumi.network/mips/_mip-003

412
docs/EXECUTIVE_SUMMARY.md Normal file
View File

@@ -0,0 +1,412 @@
---
marp: true
paginate: true
size: 16:9
---
<!--
EXECUTIVE_SUMMARY.md — slide-style overview of the Local Agents Marketplace.
Build PDF + PPTX with: bash docs/build-deck.sh
Edit this file; mermaid blocks are pre-rendered to docs/img/diagram-*.png.
-->
<style>
/* ── Local Agents Marketplace — unified dark theme ────────────────────
* Every slide uses the same navy→indigo gradient as the title slide so
* the deck reads as one continuous document. Title and closing slides
* keep the centered hero treatment; content slides stay top-aligned but
* share the same palette and accent rules. */
:root {
--ink: #f1f5f9; /* primary text on dark gradient */
--muted: #94a3b8;
--accent: #818cf8; /* indigo-400, readable on the dark gradient */
--accent-deep: #4f46e5; /* used for solid accents (h2 bar, table head) */
--accent-soft: rgba(129,140,248,0.12);
--gradient: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #312e81 100%);
--row-even: rgba(255,255,255,0.04);
--row-odd: rgba(255,255,255,0.02);
--border: rgba(255,255,255,0.10);
}
section {
background: var(--gradient);
color: var(--ink);
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
font-size: 22px;
padding: 40px 60px;
display: flex;
flex-direction: column;
justify-content: flex-start;
}
section::after {
content: attr(data-marpit-pagination) ' / ' attr(data-marpit-pagination-total);
color: var(--muted);
font-size: 13px;
}
h1 {
font-size: 48px;
font-weight: 700;
color: #ffffff;
border-bottom: 4px solid var(--accent);
padding-bottom: 10px;
margin: 0 0 18px 0;
}
h2 {
font-size: 34px;
font-weight: 700;
color: #ffffff;
margin: 0 0 14px 0;
}
h2::before {
content: '';
display: inline-block;
width: 6px;
height: 28px;
background: var(--accent);
margin-right: 14px;
vertical-align: middle;
border-radius: 2px;
}
h3 { color: #c7d2fe; font-weight: 600; font-size: 24px; margin: 8px 0; }
p, li { margin: 6px 0; line-height: 1.45; }
ul, ol { margin: 8px 0 12px 0; padding-left: 28px; }
strong { color: #ffffff; }
a { color: #a5b4fc; }
blockquote {
border-left: 4px solid var(--accent);
background: rgba(255,255,255,0.06);
padding: 10px 18px;
color: #e0e7ff;
border-radius: 0 8px 8px 0;
margin: 10px 0;
}
code {
background: rgba(255,255,255,0.10);
color: #e0e7ff;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
}
/* Constrain mermaid images so they always fit on a 720px-tall slide
* (16:9 @ 1280×720 default). Soft white card behind the diagram so the
* mermaid SVG (which renders on a transparent canvas with dark ink)
* stays readable on the navy gradient. */
img {
display: block;
margin: 8px auto;
max-width: 92%;
max-height: 420px;
object-fit: contain;
background: #ffffff;
border-radius: 10px;
padding: 10px;
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
}
/* Tile grid for the local-agent versatility slide. */
.backends {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
margin: 14px 0 12px 0;
}
.tile {
background: var(--accent-soft);
border: 2px solid var(--accent);
border-radius: 10px;
padding: 14px 16px;
text-align: center;
font-weight: 600;
color: #ffffff;
}
.tile small {
display: block;
margin-top: 4px;
font-weight: 400;
color: var(--muted);
font-size: 0.8em;
}
.tile.dots {
background: rgba(255,255,255,0.04);
border-style: dashed;
color: var(--muted);
font-size: 28px;
}
.pipeline {
text-align: center;
background: rgba(255,255,255,0.08);
border: 1px solid var(--border);
color: #f8fafc;
padding: 12px 18px;
border-radius: 10px;
margin: 6px auto 14px auto;
max-width: 760px;
font-weight: 600;
}
.pipeline span {
display: inline-block;
margin: 0 10px;
}
/* Tables sit on a solid white card so cell text reads cleanly against
* the dark gradient backdrop. Rounded outer corners + shadow match the
* mermaid card styling. `display:table` + `table-layout:fixed` are
* needed so the table actually consumes the full slide width — Marp's
* default theme leaves tables at content-natural width, which left
* blank gutters on either side. */
table {
display: table !important;
width: 100% !important;
table-layout: fixed;
border-collapse: collapse;
margin: 12px 0;
font-size: 0.82em;
color: #0f172a;
background: #ffffff;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
}
td, th { word-wrap: break-word; overflow-wrap: anywhere; }
th {
background: var(--accent-deep);
color: #ffffff;
text-align: left;
padding: 9px 12px;
font-weight: 700;
}
td {
padding: 7px 12px;
border-bottom: 1px solid #e2e8f0;
color: #0f172a;
}
tr:nth-child(even) td { background: #ffffff; }
tr:nth-child(odd) td { background: #f8fafc; }
/* code spans inside table cells need a light pill, not the dark one
* defined globally — otherwise they'd disappear into the white row. */
td code {
background: #eef2ff;
color: #1e293b;
}
/* Bold/strong inside cells must stay dark — the global rule paints
* <strong> white for the dark-gradient body text, which would render
* invisible against the white table card. */
td strong, th strong {
color: #0f172a;
}
th strong {
color: #ffffff;
}
/* Title slide — centered hero treatment on the same gradient. */
section.title {
text-align: center;
padding: 100px 80px;
justify-content: center;
}
section.title h1 {
display: inline-block;
font-size: 64px;
margin: 0 auto 20px auto;
}
section.title blockquote {
margin: 28px auto;
max-width: 900px;
}
/* Closing slide — same centered treatment. */
section.closing {
text-align: center;
padding: 100px 80px;
justify-content: center;
}
section.closing h2::before { display: none; }
section.closing blockquote { font-size: 26px; max-width: 900px; }
</style>
<!-- _class: title -->
# Local Agents Marketplace
### A trust-minimised, on-chain marketplace for LLM inference
Buy and sell prompt → response with **bonded escrow** on **Vector** (UTxO).
No platform fee. No central rate-limiter. Verifiable receipts on chain.
> *Status: M1 lifecycle proven end-to-end on Vector testnet — 2026-05-05.*
---
## The problem
* AI inference is **centralised** — a handful of providers, opaque pricing, account-level rate limits.
* Local-AI hardware is **idle most of the time** — GPUs and CPUs sit unused between user prompts.
* Buyers and sellers can't transact directly without a **trusted intermediary** taking a cut.
* "Verifiable" claims about which model ran are usually nothing more than the provider's word.
We need a **two-sided marketplace** where:
1. Sellers monetise spare compute,
2. Buyers get a **cryptographic receipt** of what was actually computed,
3. **Neither side has to trust the other** — bonds + on-chain settlement enforce honesty.
---
## How it works — three actors, one chain
```mermaid
flowchart LR
subgraph CHAIN[Vector — UTxO]
ADV[Advert UTxO<br/>capability, price, bonds]
ESC[Escrow UTxO<br/>locked funds + state]
end
SUP[🧠 Supplier<br/>local LLM node]
BUY[💼 Buyer<br/>app or agent]
IDX[🔭 Indexer<br/>read-only bulletin board]
SUP -->|publish advert| ADV
BUY -->|discover| IDX
IDX -.->|index events| CHAIN
BUY -->|post escrow| ESC
BUY -->|prompt + escrow_ref| SUP
SUP -->|signed receipt| BUY
SUP -->|claim & submit| ESC
BUY -->|accept & pay| ESC
```
* **Supplier** runs the inference and publishes a price.
* **Buyer** posts the work as a bonded escrow, sends the prompt, accepts the receipt.
* **Indexer** is a public mirror of chain state — anyone can run one. No platform middleman.
---
## The bonded-escrow lifecycle
```mermaid
sequenceDiagram
autonumber
participant B as Buyer
participant Chain as Vector
participant S as Supplier
participant LLM as Local LLM<br/>(OpenAI-compatible)
S->>Chain: PostAdvert (locks supplier_bond)
B->>Chain: PostEscrow → Open<br/>(locks payment + bonds)
B->>S: POST /v1/chat/completions
S->>Chain: ClaimEscrow → Claimed
S->>LLM: run inference
LLM-->>S: assistant message
S->>Chain: SubmitEscrow → Submitted
S-->>B: 200 OK + signed receipt
B->>Chain: AcceptEscrow — supplier paid
```
Every transition is on-chain, validated by an Aiken Plutus V3 script. The receipt is signed by the supplier's advert key; prompt and response hashes are bound on chain.
---
## What if someone misbehaves?
```mermaid
flowchart LR
OPEN[Open]:::norm --> CLAIMED[Claimed]:::norm --> SUBMITTED[Submitted]:::norm --> ACCEPTED[Accepted ✓]:::good
OPEN -->|deliver_by passed| RECLAIM[Reclaimed<br/>buyer recovers funds]:::warn
CLAIMED -->|deliver_by passed| RECLAIM
SUBMITTED -->|accept_window passed| RELEASE[Released<br/>supplier collects]:::warn
classDef norm fill:#e0e7ff,stroke:#4f46e5
classDef good fill:#dcfce7,stroke:#16a34a
classDef warn fill:#fef3c7,stroke:#d97706
```
| Failure mode | Recovery | Loss bearer |
|---|---|---|
| Supplier never claims/submits | Buyer **Reclaim** after `deliver_by` | Supplier — forfeits supplier_bond |
| Buyer disappears after Submit | Supplier **Release** after `submitted_at + 10 min` | Buyer — forfeits buyer_bond |
Bonds make honesty cheaper than running away. Both sides have skin in the game; no third-party arbiter is needed for the M1 happy/timeout paths.
---
## Local-agent versatility — any OpenAI-compatible engine
<div class="pipeline">
<span>💼 Buyer</span> →
<span>🧠 Supplier (OpenAI-compatible HTTP wrapper)</span> →
<span>backend ↓</span>
</div>
<div class="backends">
<div class="tile">Ollama<br/><small>local model runner</small></div>
<div class="tile">vLLM<br/><small>high-throughput GPU</small></div>
<div class="tile">llama.cpp<br/><small>CPU · Metal · ROCm</small></div>
<div class="tile">OpenRouter<br/><small>cloud API mux</small></div>
<div class="tile">openclaw / custom<br/><small>arbitrary stack</small></div>
<div class="tile dots">…<br/><small>any OpenAI-shaped API</small></div>
</div>
The supplier service is a thin HTTP wrapper — it forwards `/v1/chat/completions` to whatever backend is configured. Each supplier picks its own model, hardware, and engine, then advertises a `capability_id`. Buyers shop on capability + model + price.
---
## Capability + model = task selection
| Capability ID | Example models | Use case |
|---|---|---|
| `llm.text.generate.v1` | Llama 3 · Qwen 2.5 · Mistral · GPT-4 | Q&A, drafts, summarisation |
| `code.completion.v1` | Codestral · DeepSeek-Coder · StarCoder | IDE tab-complete, refactor |
| `vision.describe.v1` | LLaVA · Qwen-VL · Pixtral | Image captioning, OCR |
| `embedding.text.v1` | nomic-embed · BGE · ada-002 | RAG indexing |
| `audio.transcribe.v1` | whisper-large-v3 · distil-whisper | Voice-to-text |
| `…` | … | … |
* New capabilities = **just publish a new advert**. No marketplace governance gate.
* Today's deploy uses Ollama + Qwen 2.5; switching to vLLM with Llama 3 70B is a config change, not a code change.
---
## OpenClaw — agentic teams on your existing OpenAI seat
<div class="pipeline">
<span>🧠 Supplier wrapper</span> →
<span>🦾 OpenClaw</span> →
<span>🪪 OpenAI ChatGPT seat (flat-rate)</span>
</div>
* **Cost-efficient by default.** OpenClaw exposes your existing **OpenAI ChatGPT subscription** as an OpenAI-compatible API. The marketplace supplier dispatches its inference against it, so an entire **agentic team** (coders, QA automation testers, release curators…) draws from the **flat-rate seat you already pay for** — no per-token metering, no surprise invoices.
* **Reliability layer (optional, marginal cost).** OpenClaw can fall back to the **Anthropic API** or **OpenRouter** model pool when the primary seat is rate-limited, the prompt requires a different model, or you want a **second-layer hardening** of responses (e.g. cross-provider consensus on critical outputs). You only pay metered tokens for the requests that actually fall through.
---
## Proven on Vector testnet — 2026-05-05
| Step | Tx hash |
|---|---|
| Buyer posts escrow | `af762561…c2f7bc5` |
| Supplier claims | (lifecycle tx) |
| Supplier submits receipt | (lifecycle tx) |
| Buyer accepts | `125e3cfe…7dc100d` |
Funds flowed correctly: supplier received `payment + supplier_bond` (3 ADA); buyer got `buyer_bond` back.
* **Browser demo:** https://mp-buyers.vector.testnet.apexfusion.org
* **Live event stream:** https://mp-indexer.vector.testnet.apexfusion.org
* **Test suite:** 1098 unit tests passing, full lifecycle exercised in CI.
---
## Why UTxO + Vector
* **Bonded escrow is native** — Plutus validators enforce the state machine without a custom token or oracle.
* **Vector settles in seconds** — the buyer→claim→submit→accept loop fits inside a single chat-completion's wall clock.
* **AP3X token** for fees and bonds — fungible, transparent, on-chain.
* **No custom rollup, no bridge** — runs on a production chain today.
---
<!-- _class: closing -->
## The pitch in one sentence
> Local Agents Marketplace turns any OpenAI-compatible inference endpoint into a **trustlessly billable service** — buyer and supplier exchange prompt, response, and payment with on-chain proof that what was paid for is what was delivered.
### Try it
* **Indexer (live event stream):** https://mp-indexer.vector.testnet.apexfusion.org
* **Buyer:** https://mp-buyers.vector.testnet.apexfusion.org
* **Supplier:** https://mp-suppliers.vector.testnet.apexfusion.org/capability

BIN
docs/EXECUTIVE_SUMMARY.pdf Normal file

Binary file not shown.

BIN
docs/EXECUTIVE_SUMMARY.pptx Normal file

Binary file not shown.

78
docs/build-deck.sh Executable file
View File

@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# build-deck.sh — render docs/EXECUTIVE_SUMMARY.md to PDF + PPTX with
# inline mermaid diagrams rasterised to PNG (Marp's PDF/PPTX exporters
# don't render mermaid blocks themselves, so we pre-render them with
# mermaid-cli and substitute image references into a copy of the markdown).
#
# Outputs:
# docs/EXECUTIVE_SUMMARY.pdf
# docs/EXECUTIVE_SUMMARY.pptx
# docs/img/diagram-*.png (intermediate)
# docs/EXECUTIVE_SUMMARY.rendered.md (intermediate; mermaid → !(img))
set -euo pipefail
cd "$(dirname "$0")/.."
SRC=docs/EXECUTIVE_SUMMARY.md
TMP=docs/EXECUTIVE_SUMMARY.rendered.md
IMGDIR=docs/img
PDF=docs/EXECUTIVE_SUMMARY.pdf
PPTX=docs/EXECUTIVE_SUMMARY.pptx
mkdir -p "$IMGDIR"
# Clear both .mmd and .png so removing a mermaid block from the source
# also removes its image artifacts (otherwise stale diagrams would linger
# in docs/img/ and might be picked up by an external viewer).
rm -f "$IMGDIR"/diagram-*.mmd "$IMGDIR"/diagram-*.png
# ── 1. Extract each ```mermaid``` block → diagram-NNN.mmd → diagram-NNN.png
node - <<'JS'
const fs = require('fs');
const md = fs.readFileSync('docs/EXECUTIVE_SUMMARY.md', 'utf8');
const re = /```mermaid\n([\s\S]*?)```/g;
let m, n = 0;
const blocks = [];
while ((m = re.exec(md)) !== null) {
n++;
const file = `docs/img/diagram-${String(n).padStart(3,'0')}.mmd`;
fs.writeFileSync(file, m[1]);
blocks.push({ file, png: file.replace(/\.mmd$/, '.png') });
}
fs.writeFileSync('docs/img/.blocks.json', JSON.stringify(blocks));
console.log(`extracted ${n} mermaid blocks`);
JS
# ── 2. Render each .mmd → .png with mermaid-cli
for f in "$IMGDIR"/diagram-*.mmd; do
png="${f%.mmd}.png"
echo "rendering $f → $png"
npx --yes @mermaid-js/mermaid-cli@latest -i "$f" -o "$png" \
-t default -b transparent --width 1600 --height 900 --scale 2 \
--puppeteerConfigFile docs/puppeteer.json
done
# ── 3. Build the rendered markdown — mermaid blocks → image references
node - <<'JS'
const fs = require('fs');
const md = fs.readFileSync('docs/EXECUTIVE_SUMMARY.md', 'utf8');
let n = 0;
const out = md.replace(/```mermaid\n([\s\S]*?)```/g, () => {
n++;
const png = `img/diagram-${String(n).padStart(3,'0')}.png`;
return `![${png}](${png})`;
});
fs.writeFileSync('docs/EXECUTIVE_SUMMARY.rendered.md', out);
console.log(`substituted ${n} blocks`);
JS
# ── 4. Render PDF + PPTX with Marp CLI
echo "→ PDF"
npx --yes @marp-team/marp-cli@latest --pdf --allow-local-files \
-o "$PDF" "$TMP"
echo "→ PPTX"
npx --yes @marp-team/marp-cli@latest --pptx --allow-local-files \
-o "$PPTX" "$TMP"
echo "done. ls -la docs/EXECUTIVE_SUMMARY.*"
ls -la docs/EXECUTIVE_SUMMARY.*

13
docs/buyer/.env.example Normal file
View File

@@ -0,0 +1,13 @@
# Chain
VECTOR_OGMIOS_URL=https://ogmios.vector.testnet.apexfusion.org
VECTOR_SUBMIT_URL=https://submit.vector.apexfusion.org/api/submit/tx
# Wallet
BUYER_MNEMONIC=your fifteen word mnemonic here
# Contract
ESCROW_SCRIPT_HASH=74d7f72f7ef0e2e79290ccf8d2eeda672b43379f037def441709395d
# Resolver
RESOLVER_ADDRESS=addr1qxql380vv0up49vyw6ey8rsgut7saqval7fc40e9hvy9t60xw4lf7cz8rxkq9fmes4yf72tvjcjuh0tweghyrcpnl9vs6h5gjs
# Known suppliers (comma-separated URLs)
SUPPLIER_URLS=http://localhost:9000
# Server
BUYER_PORT=8080

220
docs/buyer/README.md Normal file
View File

@@ -0,0 +1,220 @@
# Vector Buyer App
A self-contained app for purchasing AI compute on the Vector L2 testnet (Cardano). You submit prompts, pay with AP3X tokens via on-chain escrow, and receive LLM responses from supplier nodes.
## How It Works
1. You type a **prompt** and set a **payment** amount in the web UI
2. The app posts an **escrow transaction** on-chain, locking your payment + bond
3. The app sends the task to a **supplier node** via HTTP
4. The supplier calls an LLM, **claims** the escrow, and **submits** the result hash on-chain
5. You see the response in the UI and can **accept** to release payment, or **dispute**
All on-chain settlement happens automatically. You just interact with the web UI.
## Quick Start
### 1. Install Python
You need Python 3.9 or newer. Download from https://www.python.org/downloads/
### 2. Create a virtual environment
```bash
cd buyer
python -m venv .venv
# Windows:
.venv\Scripts\activate
# macOS/Linux:
source .venv/bin/activate
```
### 3. Install dependencies
```bash
pip install -r requirements.txt
```
### 4. Configure your environment
Copy the example file and fill in your values:
```bash
copy .env.example .env
```
Edit `.env` with your settings:
```
# Your wallet seed phrase (15 words)
BUYER_MNEMONIC=word1 word2 word3 ... word15
# Supplier URLs (comma-separated)
SUPPLIER_URLS=http://SUPPLIER_IP:9000
# Port to run on (default 8080)
BUYER_PORT=8080
```
The other values (VECTOR_OGMIOS_URL, VECTOR_SUBMIT_URL, ESCROW_SCRIPT_HASH, RESOLVER_ADDRESS) are pre-configured for the Vector testnet and should not need changing.
### 5. Fund your wallet
Your buyer wallet needs AP3X to pay for compute tasks and escrow bonds. You need at least **10 AP3X** to get started.
When you start the app, the UI header shows your wallet address and balance. Send AP3X to that address.
### 6. Start the app
```bash
python main.py
```
You will see:
```
Starting Vector Buyer App on port 8080
UI: http://localhost:8080
```
### 7. Open the web UI
Go to **http://localhost:8080** in your browser.
## Using the Web UI
### Submitting a Task
1. Type your prompt in the text area
2. Set the payment amount with the slider (in AP3X)
3. Select a supplier from the dropdown
4. Click **Submit Task**
5. Wait for the response (typically 15-40 seconds)
The app will:
- Post the escrow on-chain (locks payment + 1 AP3X bond)
- Send the task to the supplier
- Display the response when ready
### Accepting a Result
After receiving a response, click **Accept & Pay** to release payment to the supplier. This completes the on-chain escrow lifecycle.
If you do not accept:
- Your payment stays locked in escrow
- After the dispute window (10 minutes), the supplier can collect payment automatically
- You lose your bond (1 AP3X) as a penalty for not responding
### Task History
Click **Task History** in the nav bar to see all past tasks. Click any task to expand it and see full details including TX hashes.
## Connecting to a Supplier
You need at least one supplier URL. The supplier operator will give you their URL.
**Same machine (testing):**
```
SUPPLIER_URLS=http://localhost:9000
```
**Local network:**
```
SUPPLIER_URLS=http://192.168.1.50:9000
```
**Remote / internet:**
```
SUPPLIER_URLS=http://supplier.example.com:9000
```
**Multiple suppliers:**
```
SUPPLIER_URLS=http://supplier1.com:9000,http://supplier2.com:9000
```
The app auto-discovers which suppliers are online and shows them in the dropdown.
## File Structure
```
buyer/
main.py Entry point - starts the web server
config.py Loads settings from .env
wallet.py Initializes blockchain wallet
api.py HTTP API endpoints
task_manager.py Orchestrates the full buy flow
escrow_ops.py On-chain escrow transactions
supplier_client.py HTTP client for talking to suppliers
discovery.py Auto-discovers online suppliers
storage.py Task history (JSON file)
.env Your configuration (secret - do not share)
.env.example Template for .env
requirements.txt Python dependencies
static/
app.html Web UI
lib/ Blockchain utilities
constants.py Protocol constants
chain.py Chain query helpers
datum.py CBOR datum builders
escrow_tx.py Transaction builder
blueprint.py Smart contract loader
protocol.py API data models
contracts/
escrow/
plutus.json Compiled smart contract
data/
tasks.json Task history (auto-created)
```
## Configuration Reference
| Variable | Description | Default |
|----------|-------------|---------|
| VECTOR_OGMIOS_URL | Blockchain node URL | Vector testnet |
| VECTOR_SUBMIT_URL | TX submission URL | Vector testnet |
| BUYER_MNEMONIC | Your 15-word wallet seed phrase | (required) |
| ESCROW_SCRIPT_HASH | Escrow contract hash | Pre-configured |
| RESOLVER_ADDRESS | Dispute resolver address | Pre-configured |
| SUPPLIER_URLS | Comma-separated supplier URLs | (required) |
| BUYER_PORT | HTTP port for the web UI | 8080 |
## Cost Breakdown
Each task costs:
- **Payment**: The amount you set (e.g. 5 AP3X) - goes to the supplier
- **Buyer bond**: 1 AP3X - returned to you when you accept
- **TX fees**: ~0.2-0.5 AP3X per transaction (escrow post + accept = 2 TXs)
If you accept the result, you get your 1 AP3X bond back. Total cost = payment + fees.
## Security Notes
- **Never share your .env file** - it contains your wallet seed phrase
- **Never share your data/ folder** - it contains task history
- The seed phrase controls your wallet funds. Back it up securely.
- Escrow protects you: the supplier cannot take your funds without delivering a result
- You have a 10-minute dispute window after receiving a result
## Troubleshooting
**"No module named 'vector_agent'"**
- Make sure you installed dependencies: `pip install -r requirements.txt`
**"ogmios_url required"**
- Make sure your .env file exists and is in the buyer/ folder
**"No supplier URL provided"**
- Add at least one supplier URL to SUPPLIER_URLS in your .env
**"0 suppliers available" in the UI**
- The supplier node may be offline. Check with the supplier operator.
- Verify the URL is correct and reachable from your machine.
**Balance shows 0 or very low**
- Fund your wallet by sending AP3X to the address shown in the UI header.
**Task fails with timeout**
- The supplier may be overloaded. Try again or use a different supplier.
- Check your internet connection to both the supplier and the Vector testnet.

0
docs/buyer/__init__.py Normal file
View File

88
docs/buyer/api.py Normal file
View File

@@ -0,0 +1,88 @@
"""FastAPI router -- buyer API endpoints."""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from config import get_config
from wallet import get_buyer_agent
from task_manager import submit_task, accept_task
from discovery import discover_suppliers
import storage
router = APIRouter()
class SubmitRequest(BaseModel):
prompt: str
payment_apex: float = 5.0
supplier_url: str = ""
@router.post("/api/submit")
async def api_submit(req: SubmitRequest):
"""Submit a new task: post escrow, send to supplier, return result."""
config = get_config()
buyer_agent = get_buyer_agent()
supplier_url = req.supplier_url or (config.supplier_urls[0] if config.supplier_urls else "")
if not supplier_url:
raise HTTPException(status_code=400, detail="No supplier URL provided")
try:
result = await submit_task(
prompt=req.prompt,
payment_apex=req.payment_apex,
supplier_url=supplier_url,
buyer_agent=buyer_agent,
config=config,
)
storage.save_task(result)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/accept/{task_id}")
async def api_accept(task_id: str):
"""Accept a completed task result and release payment."""
config = get_config()
buyer_agent = get_buyer_agent()
try:
accept_tx = await accept_task(task_id, buyer_agent, config)
storage.update_task(task_id, {"accept_tx": accept_tx, "status": "accepted"})
return {"accept_tx": accept_tx}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/tasks")
async def api_tasks():
"""Return task history from local storage."""
tasks = storage.load_tasks()
return {"tasks": tasks, "total": len(tasks)}
@router.get("/api/suppliers")
async def api_suppliers():
"""Discover reachable suppliers and return their info."""
config = get_config()
suppliers = await discover_suppliers(config)
return {
"suppliers": [s.model_dump() for s in suppliers],
"total": len(suppliers),
}
@router.get("/api/balance")
async def api_balance():
"""Return the buyer wallet balance."""
try:
buyer_agent = get_buyer_agent()
balance = await buyer_agent.get_balance()
return {
"address": balance.address,
"ada": balance.ada,
"lovelace": balance.lovelace,
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

35
docs/buyer/config.py Normal file
View File

@@ -0,0 +1,35 @@
"""Buyer configuration -- loads .env and exposes typed settings."""
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import List
from dotenv import load_dotenv
load_dotenv(Path(__file__).parent / ".env")
@dataclass
class BuyerConfig:
ogmios_url: str
submit_url: str
buyer_mnemonic: str
escrow_script_hash: str
resolver_address: str
supplier_urls: List[str] = field(default_factory=list)
port: int = 8080
def get_config() -> BuyerConfig:
raw_urls = os.getenv("SUPPLIER_URLS", "http://localhost:9000")
supplier_urls = [u.strip() for u in raw_urls.split(",") if u.strip()]
return BuyerConfig(
ogmios_url=os.getenv("VECTOR_OGMIOS_URL", "https://ogmios.vector.testnet.apexfusion.org"),
submit_url=os.getenv("VECTOR_SUBMIT_URL", "https://submit.vector.apexfusion.org/api/submit/tx"),
buyer_mnemonic=os.getenv("BUYER_MNEMONIC", ""),
escrow_script_hash=os.getenv("ESCROW_SCRIPT_HASH", ""),
resolver_address=os.getenv("RESOLVER_ADDRESS", ""),
supplier_urls=supplier_urls,
port=int(os.getenv("BUYER_PORT", "8080")),
)

View File

@@ -0,0 +1,257 @@
{
"preamble": {
"title": "vector/escrow",
"description": "Vector AI Compute Network — Optimistic Escrow Contract",
"version": "0.1.0",
"plutusVersion": "v3",
"compiler": {
"name": "Aiken",
"version": "v1.1.21+unknown"
},
"license": "Apache-2.0"
},
"validators": [
{
"title": "escrow.escrow.spend",
"datum": {
"title": "datum",
"schema": {
"$ref": "#/definitions/escrow~1EscrowDatum"
}
},
"redeemer": {
"title": "action",
"schema": {
"$ref": "#/definitions/escrow~1EscrowAction"
}
},
"compiledCode": "59090801010029800aba2aba1aba0aab9faab9eaab9dab9a488888896600264653001300800198041804800cdc3a400530080024888966002600460106ea800e3300130093754007370e90004dc3a4009370e90034c020dd50022444453001301100598089809002c88c966002600e0031323259800980b80140122c80a0dd7180a80098089baa0038acc004c02800626464b300130170028024590141bae30150013011375400716403c8078c03cdd5001244464b300130080058992cc004c05c006266006602c00200516405060246ea80362b3001300b005899192cc004c06000a0071640546eb8c058004c048dd5006c566002600e00b15980098091baa00d800c590134566002600c00b15980098091baa00d800c59013456600266e1d20080058992cc004c05c006266006602c00200516405060246ea80362b30013370e9005002c56600260246ea803600316404d15980099b87480300162b30013012375401b0018b20268b202040408081010202040408080566002600e60206ea800a330013014301137540052301530163016301630163016301630160019180a980b180b180b180b180b180b180b180b000c8c054c058c058c058c05800660206ea80324602a602c0032301530163016001911919800800801912cc0040062980103d87a80008992cc004c010006266e952000330180014bd7044cc00c00cc068009014180c000a02c9180a980b180b180b180b180b180b000a4444444446530012232598009809980e9baa0018992cc004c054c078dd5000c4c8c8c8c8c8c8c8c8c8ca60026058003375c6058015375a6058011375a605800f302c0069bad302c0059bad302c0049bad302c00398160012444444444b3001303600a899811181a809099912cc004c0a8006264b30013039001899812981c000801c59036181a1baa0078acc004c0b40062b30013034375400f0028b206a8b206440c826644b3001302a0018acc004c0d0dd5001c00a2c81aa2b3001302d0018acc004c0d0dd5001c00a2c81aa2b300130290018991919912cc004c0f000e00d1640e46eb4c0e4004dd7181c801181c800981a1baa0038acc004c0a00062b3001303437540070028b206a8b206440c88191032099811004880a18189baa0013031375400b1640cc302c001302b001302a001302900130280013027001302600130250013024001301f37540031640746042603c6ea80062c80e0c018c074dd519801801000c8966002602660386ea800626464660020026eb0c030c07cdd5002112cc00400629422b30013371e6eb8c08c00400e2946266004004604800280f10211bae3020301d375400314a080da64b30013015301b37540031375a603e60386ea8006290002034301e301b3754603c60366ea8c024c06cdd5009a444b300130140098992cc004cdd79806180f9baa00e4c0103d87980008acc004cdc40011bad3006301f375401d1325980099baf300d3020375400298103d87a80008acc004cdd7980618101baa0013374a90001981100125eb822b30013371e6eb8c08cc080dd50009bae30233020375401f15980099baf300a30203754002601460406ea803e2b30013370e6eb4c024c080dd50009bad30093020375401f15980099b893370066e00dd6980498101baa00f375a604660486048604860406ea803cdd698119812181218121812181218101baa00f323322330020020012259800800c00e2646644b3001337229101000028acc004cdc7a441000028800c01902444cc014014c0a80110241bae3023001375a6048002604c0028120c8c8cc004004dd5980698119baa3300901b0152259800800c00e2646644b3001337229101000028acc004cdc7a441000028800c01902544cc014014c0ac0110251bae30240013756604a002604e002812852f5bded8c02900044cc01006000a2c80f22c80f22c80f22c80f22c80f22c80f0cc01005c0462c80ea2c80e8c084c078dd500cc566002602e01315980099baf300b301e375401a98103d87a80008992cc004c054c078dd5000c56600266e20008dd69803180f9baa00e8992cc004cdd7980698101baa0013374a900219811181198101baa01b33022375000697ae08acc004cdc79bae3023302037540026eb8c08cc080dd5007c56600266ebcc028c080dd5000980518101baa00f8acc004cdd7980618101baa001300c3020375401f1330040183023302037540051640791640791640791640786600802e0231640751640746014603c6ea80362c80e22b300130130098acc004c04cc074dd51805980f1baa00d89980100b1804180f1baa00d8b20388acc004c048026264b30013014301e37540031598009980180b9804980f9baa00e8acc004cdc480119b80375a60446046603e6ea8004dd69806980f9baa00e8992cc004cdd7980698101baa0014c0103d87c80008acc004cdc79bae3023302037540026eb8c08cc080dd5007c56600266ebcc028c080dd5000980518101baa00f8acc004cdd7980618101baa001300c3020375401f14a31640791640791640791640786600802e0231640751640751640746016603c6ea80362b30013370e9004004c4c96600266ebcc030c07cdd5007260103d87c80008acc004cc00c05cc088c08cc08cc08cc08cc08cc08cc08cc08cc08cc07cdd500744c966002602c603e6ea80062b30015980099baf002300a3020375401f14a313375e004604660406ea800501e4528c5901e45901e1805980f9baa00e8b203a8b203a3021301e375403315980099b87480280262b3001330020163008301e375401b13259800980a800c4cdc41bad3006301f375401c0051598009809800c4cdc419b80375a600c603e6ea8038dd69806980f9baa00e0028a50407480e8c074dd51805980f1baa00d8b20388992cc004c050c078dd5000c4c966002602c603e6ea80062b30013300401830233020375400313371066e00dd69811981218101baa002375a601c60406ea803c00e2c80f22c80f0c02cc07cdd500745901d1805980f1baa00d407080e101c2038407080e0446464b30013014301d375400313021301e37540031640706600a6eb0c018c074dd5001919baf3021301e375400200464b30013013301c375400313020301d3754600e603a6ea8c080c074dd5000c5901b198021bac301f301c3754004466ebcc080c074dd500080122c80788b200e180400098019baa0088a4d1365640041",
"hash": "74d7f72f7ef0e2e79290ccf8d2eeda672b43379f037def441709395d"
},
{
"title": "escrow.escrow.else",
"redeemer": {
"schema": {}
},
"compiledCode": "59090801010029800aba2aba1aba0aab9faab9eaab9dab9a488888896600264653001300800198041804800cdc3a400530080024888966002600460106ea800e3300130093754007370e90004dc3a4009370e90034c020dd50022444453001301100598089809002c88c966002600e0031323259800980b80140122c80a0dd7180a80098089baa0038acc004c02800626464b300130170028024590141bae30150013011375400716403c8078c03cdd5001244464b300130080058992cc004c05c006266006602c00200516405060246ea80362b3001300b005899192cc004c06000a0071640546eb8c058004c048dd5006c566002600e00b15980098091baa00d800c590134566002600c00b15980098091baa00d800c59013456600266e1d20080058992cc004c05c006266006602c00200516405060246ea80362b30013370e9005002c56600260246ea803600316404d15980099b87480300162b30013012375401b0018b20268b202040408081010202040408080566002600e60206ea800a330013014301137540052301530163016301630163016301630160019180a980b180b180b180b180b180b180b180b000c8c054c058c058c058c05800660206ea80324602a602c0032301530163016001911919800800801912cc0040062980103d87a80008992cc004c010006266e952000330180014bd7044cc00c00cc068009014180c000a02c9180a980b180b180b180b180b180b000a4444444446530012232598009809980e9baa0018992cc004c054c078dd5000c4c8c8c8c8c8c8c8c8c8ca60026058003375c6058015375a6058011375a605800f302c0069bad302c0059bad302c0049bad302c00398160012444444444b3001303600a899811181a809099912cc004c0a8006264b30013039001899812981c000801c59036181a1baa0078acc004c0b40062b30013034375400f0028b206a8b206440c826644b3001302a0018acc004c0d0dd5001c00a2c81aa2b3001302d0018acc004c0d0dd5001c00a2c81aa2b300130290018991919912cc004c0f000e00d1640e46eb4c0e4004dd7181c801181c800981a1baa0038acc004c0a00062b3001303437540070028b206a8b206440c88191032099811004880a18189baa0013031375400b1640cc302c001302b001302a001302900130280013027001302600130250013024001301f37540031640746042603c6ea80062c80e0c018c074dd519801801000c8966002602660386ea800626464660020026eb0c030c07cdd5002112cc00400629422b30013371e6eb8c08c00400e2946266004004604800280f10211bae3020301d375400314a080da64b30013015301b37540031375a603e60386ea8006290002034301e301b3754603c60366ea8c024c06cdd5009a444b300130140098992cc004cdd79806180f9baa00e4c0103d87980008acc004cdc40011bad3006301f375401d1325980099baf300d3020375400298103d87a80008acc004cdd7980618101baa0013374a90001981100125eb822b30013371e6eb8c08cc080dd50009bae30233020375401f15980099baf300a30203754002601460406ea803e2b30013370e6eb4c024c080dd50009bad30093020375401f15980099b893370066e00dd6980498101baa00f375a604660486048604860406ea803cdd698119812181218121812181218101baa00f323322330020020012259800800c00e2646644b3001337229101000028acc004cdc7a441000028800c01902444cc014014c0a80110241bae3023001375a6048002604c0028120c8c8cc004004dd5980698119baa3300901b0152259800800c00e2646644b3001337229101000028acc004cdc7a441000028800c01902544cc014014c0ac0110251bae30240013756604a002604e002812852f5bded8c02900044cc01006000a2c80f22c80f22c80f22c80f22c80f22c80f0cc01005c0462c80ea2c80e8c084c078dd500cc566002602e01315980099baf300b301e375401a98103d87a80008992cc004c054c078dd5000c56600266e20008dd69803180f9baa00e8992cc004cdd7980698101baa0013374a900219811181198101baa01b33022375000697ae08acc004cdc79bae3023302037540026eb8c08cc080dd5007c56600266ebcc028c080dd5000980518101baa00f8acc004cdd7980618101baa001300c3020375401f1330040183023302037540051640791640791640791640786600802e0231640751640746014603c6ea80362c80e22b300130130098acc004c04cc074dd51805980f1baa00d89980100b1804180f1baa00d8b20388acc004c048026264b30013014301e37540031598009980180b9804980f9baa00e8acc004cdc480119b80375a60446046603e6ea8004dd69806980f9baa00e8992cc004cdd7980698101baa0014c0103d87c80008acc004cdc79bae3023302037540026eb8c08cc080dd5007c56600266ebcc028c080dd5000980518101baa00f8acc004cdd7980618101baa001300c3020375401f14a31640791640791640791640786600802e0231640751640751640746016603c6ea80362b30013370e9004004c4c96600266ebcc030c07cdd5007260103d87c80008acc004cc00c05cc088c08cc08cc08cc08cc08cc08cc08cc08cc08cc07cdd500744c966002602c603e6ea80062b30015980099baf002300a3020375401f14a313375e004604660406ea800501e4528c5901e45901e1805980f9baa00e8b203a8b203a3021301e375403315980099b87480280262b3001330020163008301e375401b13259800980a800c4cdc41bad3006301f375401c0051598009809800c4cdc419b80375a600c603e6ea8038dd69806980f9baa00e0028a50407480e8c074dd51805980f1baa00d8b20388992cc004c050c078dd5000c4c966002602c603e6ea80062b30013300401830233020375400313371066e00dd69811981218101baa002375a601c60406ea803c00e2c80f22c80f0c02cc07cdd500745901d1805980f1baa00d407080e101c2038407080e0446464b30013014301d375400313021301e37540031640706600a6eb0c018c074dd5001919baf3021301e375400200464b30013013301c375400313020301d3754600e603a6ea8c080c074dd5000c5901b198021bac301f301c3754004466ebcc080c074dd500080122c80788b200e180400098019baa0088a4d1365640041",
"hash": "74d7f72f7ef0e2e79290ccf8d2eeda672b43379f037def441709395d"
}
],
"definitions": {
"ByteArray": {
"dataType": "bytes"
},
"Int": {
"dataType": "integer"
},
"Option<cardano/address/Credential>": {
"title": "Option",
"anyOf": [
{
"title": "Some",
"description": "An optional value.",
"dataType": "constructor",
"index": 0,
"fields": [
{
"$ref": "#/definitions/cardano~1address~1Credential"
}
]
},
{
"title": "None",
"description": "Nothing.",
"dataType": "constructor",
"index": 1,
"fields": []
}
]
},
"aiken/crypto/ScriptHash": {
"title": "ScriptHash",
"dataType": "bytes"
},
"aiken/crypto/VerificationKeyHash": {
"title": "VerificationKeyHash",
"dataType": "bytes"
},
"cardano/address/Credential": {
"title": "Credential",
"description": "A general structure for representing an on-chain `Credential`.\n\n Credentials are always one of two kinds: a direct public/private key\n pair, or a script (native or Plutus).",
"anyOf": [
{
"title": "VerificationKey",
"dataType": "constructor",
"index": 0,
"fields": [
{
"$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash"
}
]
},
{
"title": "Script",
"dataType": "constructor",
"index": 1,
"fields": [
{
"$ref": "#/definitions/aiken~1crypto~1ScriptHash"
}
]
}
]
},
"escrow/EscrowAction": {
"title": "EscrowAction",
"anyOf": [
{
"title": "Claim",
"dataType": "constructor",
"index": 0,
"fields": [
{
"title": "supplier_cred",
"$ref": "#/definitions/cardano~1address~1Credential"
}
]
},
{
"title": "Submit",
"dataType": "constructor",
"index": 1,
"fields": [
{
"title": "result_hash",
"$ref": "#/definitions/ByteArray"
}
]
},
{
"title": "Accept",
"dataType": "constructor",
"index": 2,
"fields": []
},
{
"title": "Dispute",
"dataType": "constructor",
"index": 3,
"fields": []
},
{
"title": "Resolve",
"dataType": "constructor",
"index": 4,
"fields": [
{
"title": "winner",
"$ref": "#/definitions/cardano~1address~1Credential"
}
]
},
{
"title": "Reclaim",
"dataType": "constructor",
"index": 5,
"fields": []
},
{
"title": "Collect",
"dataType": "constructor",
"index": 6,
"fields": []
}
]
},
"escrow/EscrowDatum": {
"title": "EscrowDatum",
"anyOf": [
{
"title": "EscrowDatum",
"dataType": "constructor",
"index": 0,
"fields": [
{
"title": "task_hash",
"$ref": "#/definitions/ByteArray"
},
{
"title": "buyer",
"$ref": "#/definitions/cardano~1address~1Credential"
},
{
"title": "payment_dfm",
"$ref": "#/definitions/Int"
},
{
"title": "buyer_bond_dfm",
"$ref": "#/definitions/Int"
},
{
"title": "supplier",
"$ref": "#/definitions/Option<cardano~1address~1Credential>"
},
{
"title": "supplier_bond_dfm",
"$ref": "#/definitions/Int"
},
{
"title": "deadline_slot",
"$ref": "#/definitions/Int"
},
{
"title": "dispute_window",
"$ref": "#/definitions/Int"
},
{
"title": "state",
"$ref": "#/definitions/escrow~1EscrowState"
},
{
"title": "resolver",
"$ref": "#/definitions/cardano~1address~1Credential"
}
]
}
]
},
"escrow/EscrowState": {
"title": "EscrowState",
"anyOf": [
{
"title": "Open",
"dataType": "constructor",
"index": 0,
"fields": []
},
{
"title": "Claimed",
"dataType": "constructor",
"index": 1,
"fields": []
},
{
"title": "Submitted",
"dataType": "constructor",
"index": 2,
"fields": [
{
"title": "result_hash",
"$ref": "#/definitions/ByteArray"
},
{
"title": "submitted_slot",
"$ref": "#/definitions/Int"
}
]
},
{
"title": "Disputed",
"dataType": "constructor",
"index": 3,
"fields": []
}
]
}
}
}

29
docs/buyer/discovery.py Normal file
View File

@@ -0,0 +1,29 @@
"""Simple supplier discovery -- checks health of known supplier URLs."""
import asyncio
from typing import List
from lib.protocol import SupplierInfo
from supplier_client import check_supplier_health
async def discover_suppliers(config) -> List[SupplierInfo]:
"""Check health of each URL in config.supplier_urls and return reachable suppliers.
Args:
config: BuyerConfig with supplier_urls list.
Returns:
List of SupplierInfo for reachable suppliers.
"""
tasks = [check_supplier_health(url) for url in config.supplier_urls]
results = await asyncio.gather(*tasks, return_exceptions=True)
suppliers = []
for i, result in enumerate(results):
if isinstance(result, SupplierInfo):
# Ensure the endpoint field reflects the URL we queried
if not result.endpoint:
result.endpoint = config.supplier_urls[i]
suppliers.append(result)
return suppliers

140
docs/buyer/escrow_ops.py Normal file
View File

@@ -0,0 +1,140 @@
"""Buyer-side escrow operations -- post, accept, and reclaim."""
from cbor2 import CBORTag
from pycardano import (
Address,
RawPlutusData,
Transaction,
TransactionBuilder,
TransactionOutput,
VerificationKeyWitness,
)
from pycardano.hash import ScriptHash
from pycardano.transaction import TransactionWitnessSet
from lib.datum import build_open_datum
from lib.escrow_tx import build_and_submit_script_tx
from lib.constants import BUYER_BOND, SUPPLIER_BOND, DISPUTE_WINDOW_MS
async def post_escrow(
context,
signer,
script_hash,
task_hash,
payment_dfm,
deadline_posix_ms,
resolver_cred,
) -> str:
"""Post escrow on-chain in the Open state.
Locks payment + buyer_bond + 2M lovelace at the script address.
Uses manual signing (NOT build_and_sign) to avoid extended key issues.
Returns the transaction hash as a string.
"""
script_hash_obj = ScriptHash.from_primitive(script_hash)
script_address = Address(script_hash_obj, network=context.network)
buyer_vkh = bytes(signer._wallet.payment_verification_key.hash())
datum = build_open_datum(
task_hash_hex=task_hash,
buyer_vkh=buyer_vkh,
payment_dfm=payment_dfm,
buyer_bond=BUYER_BOND,
supplier_bond=SUPPLIER_BOND,
deadline_posix_ms=deadline_posix_ms,
dispute_window_ms=DISPUTE_WINDOW_MS,
resolver_cred=bytes.fromhex(resolver_cred),
)
total_lovelace = payment_dfm + BUYER_BOND + 2_000_000
datum_obj = RawPlutusData(datum)
await context.async_protocol_param()
builder = TransactionBuilder(context)
builder.add_input_address(signer._wallet.payment_address)
builder.add_output(
TransactionOutput(script_address, total_lovelace, datum=datum_obj)
)
tx_body = builder.build(change_address=signer._wallet.payment_address)
sig = signer._wallet.payment_signing_key.sign(tx_body.hash())
vk_witness = VerificationKeyWitness(signer._wallet.payment_verification_key, sig)
witness_set = TransactionWitnessSet(vkey_witnesses=[vk_witness])
tx = Transaction(tx_body, witness_set)
tx_cbor = tx.to_cbor()
if isinstance(tx_cbor, bytes):
tx_cbor = tx_cbor.hex()
await context.async_submit_tx_cbor(tx_cbor)
return str(tx.id)
async def accept_result(
context,
signer,
utxo,
script,
script_address,
supplier_address,
payment_dfm,
) -> str:
"""Accept a submitted result -- Accept redeemer (CBORTag 123, []).
Pays the supplier: payment + supplier_bond + buyer_bond.
The buyer gets back the remaining lovelace (min UTxO overhead) as change.
Returns the transaction hash as a string.
"""
from pycardano import Address as PycAddress
supplier_addr = PycAddress.from_primitive(supplier_address)
supplier_payout = payment_dfm + SUPPLIER_BOND + BUYER_BOND
redeemer_obj = CBORTag(123, [])
tx_hash = await build_and_submit_script_tx(
context=context,
signer=signer,
utxo=utxo,
script=script,
script_address=script_address,
redeemer_obj=redeemer_obj,
output_value=0,
output_datum_obj=None,
task_id="accept",
step_name="Accept",
extra_outputs=[
TransactionOutput(supplier_addr, supplier_payout),
],
)
return tx_hash
async def reclaim_escrow(
context,
signer,
utxo,
script,
script_address,
) -> str:
"""Reclaim escrow funds -- Reclaim redeemer (CBORTag 126, []).
Returns the transaction hash as a string.
"""
redeemer_obj = CBORTag(126, [])
tx_hash = await build_and_submit_script_tx(
context=context,
signer=signer,
utxo=utxo,
script=script,
script_address=script_address,
redeemer_obj=redeemer_obj,
output_value=0,
output_datum_obj=None,
task_id="reclaim",
step_name="Reclaim",
)
return tx_hash

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,18 @@
import json
from pathlib import Path
from pycardano import PlutusV3Script, plutus_script_hash
def load_escrow_script():
"""Load the escrow PlutusV3 script from the bundled blueprint."""
# Look in supplier/contracts/escrow/plutus.json
blueprint_path = Path(__file__).resolve().parent.parent / "contracts" / "escrow" / "plutus.json"
with open(blueprint_path, "r") as f:
blueprint = json.load(f)
compiled_code = blueprint["validators"][0]["compiledCode"]
script = PlutusV3Script(bytes.fromhex(compiled_code))
script_hash = str(plutus_script_hash(script))
return script, script_hash

33
docs/buyer/lib/chain.py Normal file
View File

@@ -0,0 +1,33 @@
import asyncio
from .constants import MAX_UTXO_WAIT
def slot_to_posix_ms(context, slot):
gp = context.genesis_param
return (gp.system_start + slot * gp.slot_length) * 1000
async def wait_for_utxo(context, script_address, tx_hash, output_index=0):
elapsed = 0
while elapsed < MAX_UTXO_WAIT:
utxos = await context.async_utxos(str(script_address))
for utxo in utxos:
if (
str(utxo.input.transaction_id) == str(tx_hash)
and utxo.input.index == output_index
):
return utxo
await asyncio.sleep(4)
elapsed += 4
raise TimeoutError(
f"UTxO {tx_hash}#{output_index} not found at {script_address} "
f"after {MAX_UTXO_WAIT}s"
)
async def find_script_utxo(context, script_address, tx_hash):
utxos = await context.async_utxos(str(script_address))
for utxo in utxos:
if str(utxo.input.transaction_id) == str(tx_hash):
return utxo
return None

View File

@@ -0,0 +1,6 @@
BUYER_BOND = 1_000_000
SUPPLIER_BOND = 1_000_000
DEFAULT_DEADLINE_OFFSET_MS = 1_800_000 # 30 minutes
DISPUTE_WINDOW_MS = 600_000 # 10 minutes
TX_PROPAGATION_DELAY = 12 # seconds
MAX_UTXO_WAIT = 60 # seconds

63
docs/buyer/lib/datum.py Normal file
View File

@@ -0,0 +1,63 @@
from cbor2 import CBORTag
def _build_datum_fields(
task_hash_hex, buyer_vkh, supplier_vkh, payment_dfm,
buyer_bond, supplier_bond, deadline_posix_ms, dispute_window_ms,
state_field, resolver_cred,
):
if supplier_vkh is None:
supplier_field = CBORTag(122, [])
else:
supplier_field = CBORTag(121, [CBORTag(121, [supplier_vkh])])
return CBORTag(121, [
bytes.fromhex(task_hash_hex),
CBORTag(121, [buyer_vkh]),
payment_dfm,
buyer_bond,
supplier_field,
supplier_bond,
deadline_posix_ms,
dispute_window_ms,
state_field,
CBORTag(121, [resolver_cred]),
])
def build_open_datum(task_hash_hex, buyer_vkh, payment_dfm, buyer_bond,
supplier_bond, deadline_posix_ms, dispute_window_ms, resolver_cred):
state_field = CBORTag(121, [])
return _build_datum_fields(
task_hash_hex=task_hash_hex, buyer_vkh=buyer_vkh, supplier_vkh=None,
payment_dfm=payment_dfm, buyer_bond=buyer_bond, supplier_bond=supplier_bond,
deadline_posix_ms=deadline_posix_ms, dispute_window_ms=dispute_window_ms,
state_field=state_field, resolver_cred=resolver_cred,
)
def build_claimed_datum(task_hash_hex, buyer_vkh, supplier_vkh, payment_dfm,
buyer_bond, supplier_bond, deadline_posix_ms, dispute_window_ms,
resolver_cred):
state_field = CBORTag(122, [])
return _build_datum_fields(
task_hash_hex=task_hash_hex, buyer_vkh=buyer_vkh, supplier_vkh=supplier_vkh,
payment_dfm=payment_dfm, buyer_bond=buyer_bond, supplier_bond=supplier_bond,
deadline_posix_ms=deadline_posix_ms, dispute_window_ms=dispute_window_ms,
state_field=state_field, resolver_cred=resolver_cred,
)
def build_submitted_datum(task_hash_hex, buyer_vkh, supplier_vkh, payment_dfm,
buyer_bond, supplier_bond, deadline_posix_ms, dispute_window_ms,
result_hash_hex, submitted_posix_ms, resolver_cred):
state_field = CBORTag(123, [
bytes.fromhex(result_hash_hex),
submitted_posix_ms,
])
return _build_datum_fields(
task_hash_hex=task_hash_hex, buyer_vkh=buyer_vkh, supplier_vkh=supplier_vkh,
payment_dfm=payment_dfm, buyer_bond=buyer_bond, supplier_bond=supplier_bond,
deadline_posix_ms=deadline_posix_ms, dispute_window_ms=dispute_window_ms,
state_field=state_field, resolver_cred=resolver_cred,
)

197
docs/buyer/lib/escrow_tx.py Normal file
View File

@@ -0,0 +1,197 @@
"""
Build, evaluate, and submit script transactions for escrow state transitions.
"""
import json
import os
import httpx
from pycardano import (
Redeemer,
Transaction,
TransactionBuilder,
TransactionOutput,
VerificationKeyWitness,
)
from pycardano.plutus import ExecutionUnits, RawPlutusData
async def build_and_submit_script_tx(
context, signer, utxo, script, script_address,
redeemer_obj, output_value, output_datum_obj,
task_id, step_name, validity_start_slot=None,
extra_outputs=None,
) -> str:
await context.async_protocol_param()
change_address = signer._wallet.payment_address
try:
tx_hash = await _build_direct(
context, signer, utxo, script, script_address,
redeemer_obj, output_value, output_datum_obj,
change_address, validity_start_slot, extra_outputs,
)
print(f"[{step_name}] TX submitted (direct): {tx_hash}")
return tx_hash
except ValueError as e:
if "collateral" not in str(e).lower():
raise
print(f"[{step_name}] Collateral check failed, falling back to Ogmios evaluate...")
tx_hash = await _build_with_ogmios_evaluate(
context, signer, utxo, script, script_address,
redeemer_obj, output_value, output_datum_obj,
change_address, validity_start_slot, step_name, extra_outputs,
)
print(f"[{step_name}] TX submitted (ogmios): {tx_hash}")
return tx_hash
def _make_builder(
context, signer, utxo, script, script_address,
redeemer_obj, output_value, output_datum_obj,
validity_start_slot, ex_units=None, skip_estimate=False,
extra_outputs=None,
):
builder = TransactionBuilder(context)
builder.add_input_address(signer._wallet.payment_address)
builder.fee_buffer = 10_000
if skip_estimate:
builder._should_estimate_execution_units = False
redeemer = Redeemer(RawPlutusData(redeemer_obj))
if ex_units:
redeemer.ex_units = ex_units
builder.add_script_input(utxo, script=script, datum=None, redeemer=redeemer)
if output_datum_obj is not None and output_value > 0:
builder.add_output(
TransactionOutput(script_address, output_value, datum=RawPlutusData(output_datum_obj))
)
# Add extra outputs (e.g. supplier payment in Accept TX)
if extra_outputs:
for out in extra_outputs:
builder.add_output(out)
builder.required_signers = [signer._wallet.payment_verification_key.hash()]
if validity_start_slot is not None:
builder.validity_start = validity_start_slot
return builder
def _sign_tx(signer, tx_body, witness_set):
sig = signer._wallet.payment_signing_key.sign(tx_body.hash())
vk_witness = VerificationKeyWitness(
signer._wallet.payment_verification_key, sig
)
witness_set.vkey_witnesses = [vk_witness]
return Transaction(tx_body, witness_set)
async def _build_direct(
context, signer, utxo, script, script_address,
redeemer_obj, output_value, output_datum_obj,
change_address, validity_start_slot, extra_outputs=None,
):
builder = _make_builder(
context, signer, utxo, script, script_address,
redeemer_obj, output_value, output_datum_obj,
validity_start_slot, extra_outputs=extra_outputs,
)
tx_body = builder.build(change_address=change_address)
witness_set = builder.build_witness_set(True)
tx = _sign_tx(signer, tx_body, witness_set)
tx_cbor = tx.to_cbor()
if isinstance(tx_cbor, bytes):
tx_cbor = tx_cbor.hex()
await context.async_submit_tx_cbor(tx_cbor)
return str(tx.id)
async def _build_with_ogmios_evaluate(
context, signer, utxo, script, script_address,
redeemer_obj, output_value, output_datum_obj,
change_address, validity_start_slot, step_name, extra_outputs=None,
):
ogmios_url = os.getenv("VECTOR_OGMIOS_URL")
if not ogmios_url:
raise RuntimeError("VECTOR_OGMIOS_URL not set")
dummy_ex_units = ExecutionUnits(500_000, 200_000_000)
builder = _make_builder(
context, signer, utxo, script, script_address,
redeemer_obj, output_value, output_datum_obj,
validity_start_slot, ex_units=dummy_ex_units, skip_estimate=True,
extra_outputs=extra_outputs,
)
orig_set = builder._set_collateral_return
def _patched(*a, **k):
try:
return orig_set(*a, **k)
except ValueError:
return
builder._set_collateral_return = _patched
tx_body = builder.build(change_address=change_address)
witness_set = builder.build_witness_set(True)
dummy_tx = _sign_tx(signer, tx_body, witness_set)
tx_cbor = dummy_tx.to_cbor()
if isinstance(tx_cbor, bytes):
tx_cbor = tx_cbor.hex()
evaluate_payload = {
"jsonrpc": "2.0",
"method": "evaluateTransaction",
"params": {"transaction": {"cbor": tx_cbor}},
"id": 1,
}
async with httpx.AsyncClient() as client:
resp = await client.post(ogmios_url, json=evaluate_payload, timeout=30)
result = resp.json()
if "error" in result:
err_data = result["error"]
traces = ""
if isinstance(err_data.get("data"), list):
for item in err_data["data"]:
if "error" in item and "data" in item["error"]:
traces = str(item["error"]["data"].get("traces", ""))
raise RuntimeError(
f"[{step_name}] Validator failed: {err_data.get('message', '')} traces={traces}"
)
budget = result["result"][0]["budget"]
real_mem = int(budget["memory"] * 1.2)
real_cpu = int(budget["cpu"] * 1.2)
print(f"[{step_name}] Ogmios ex_units: mem={real_mem}, cpu={real_cpu}")
builder2 = _make_builder(
context, signer, utxo, script, script_address,
redeemer_obj, output_value, output_datum_obj,
validity_start_slot, ex_units=ExecutionUnits(real_mem, real_cpu),
skip_estimate=True, extra_outputs=extra_outputs,
)
orig_set2 = builder2._set_collateral_return
def _patched2(*a, **k):
try:
return orig_set2(*a, **k)
except ValueError:
return
builder2._set_collateral_return = _patched2
tx_body2 = builder2.build(change_address=change_address)
witness_set2 = builder2.build_witness_set(True)
real_tx = _sign_tx(signer, tx_body2, witness_set2)
real_cbor = real_tx.to_cbor()
if isinstance(real_cbor, bytes):
real_cbor = real_cbor.hex()
await context.async_submit_tx_cbor(real_cbor)
return str(real_tx.id)

View File

@@ -0,0 +1,38 @@
from typing import List, Optional
from pydantic import BaseModel
class TaskRequest(BaseModel):
task_id: str
prompt: str
escrow_tx: str
escrow_script_hash: str
payment_dfm: int
task_hash: str
buyer_address: str
model_preference: str = "nemotron"
max_tokens: int = 2048
temperature: float = 0.7
deadline_posix_ms: int = 9_999_999_999_999
dispute_window_ms: int = 600_000
class TaskResponse(BaseModel):
task_id: str
status: str
response: str
result_hash: str
model_used: str
input_tokens: int
output_tokens: int
claim_tx: str
submit_tx: str
error: str = ""
class SupplierInfo(BaseModel):
name: str
address: str
endpoint: str
models: List[str]
min_payment_dfm: int = 1_000_000

37
docs/buyer/main.py Normal file
View File

@@ -0,0 +1,37 @@
"""
Buyer app entry point.
Serves the API and the web UI.
"""
import sys
import os
# Make the buyer folder the root for imports
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from dotenv import load_dotenv
from pathlib import Path
load_dotenv(Path(__file__).parent / ".env")
import uvicorn
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from api import router
from config import get_config
app = FastAPI(title="Vector Buyer App")
app.include_router(router)
app.mount("/static", StaticFiles(directory=str(Path(__file__).parent / "static")), name="static")
@app.get("/")
async def index():
return FileResponse(str(Path(__file__).parent / "static" / "app.html"))
if __name__ == "__main__":
config = get_config()
print(f"Starting Vector Buyer App on port {config.port}")
print(f"UI: http://localhost:{config.port}")
uvicorn.run(app, host="0.0.0.0", port=config.port)

View File

@@ -0,0 +1,9 @@
fastapi>=0.104.0
uvicorn>=0.24.0
httpx>=0.27.0
python-dotenv>=1.0.0
pydantic>=2.0.0
aiofiles>=23.0.0
pycardano>=0.11.0
cbor2>=5.4.0
vector-agent>=0.1.0

502
docs/buyer/static/app.html Normal file
View File

@@ -0,0 +1,502 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vector AI Compute Network -- Buyer</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: linear-gradient(135deg, #0a0b0f 0%, #12141c 100%);
color: #e0e0e0;
min-height: 100vh;
}
header {
display: flex; justify-content: space-between; align-items: center;
padding: 16px 32px; border-bottom: 1px solid #1e2030;
background: rgba(10,11,15,0.95); backdrop-filter: blur(12px);
position: sticky; top: 0; z-index: 100;
}
.brand { display: flex; align-items: center; gap: 12px; }
.logo {
width: 40px; height: 40px; border-radius: 10px;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
display: flex; align-items: center; justify-content: center;
font-weight: 800; font-size: 20px; color: #fff; line-height: 40px; text-align: center;
}
.brand h1 { font-size: 18px; font-weight: 700; color: #f0f0f0; }
.brand p { font-size: 12px; color: #6b7280; }
.header-right { display: flex; align-items: center; gap: 16px; }
.balance-display {
padding: 6px 14px; border-radius: 8px; background: #1e2030;
font-size: 13px; color: #10b981; font-weight: 600; font-family: monospace;
}
nav { display: flex; gap: 4px; }
.nav-btn {
padding: 8px 16px; border-radius: 8px; border: none;
background: transparent; color: #9ca3af; cursor: pointer;
font-size: 14px; font-weight: 500;
}
.nav-btn.active { background: #1e2030; color: #e0e0e0; }
main { padding: 24px 32px; max-width: 1200px; margin: 0 auto; }
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.section-title { font-size: 20px; font-weight: 700; color: #f0f0f0; }
.badge { padding: 4px 12px; border-radius: 12px; background: #1e2030; color: #9ca3af; font-size: 13px; }
/* Tab content */
.tab { display: none; }
.tab.active { display: block; }
/* Form */
.card { background: #14161e; border-radius: 12px; border: 1px solid #1e2030; padding: 24px; }
.field-label {
display: block; font-size: 12px; font-weight: 600; color: #9ca3af;
text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px;
}
textarea {
width: 100%; padding: 12px 14px; border-radius: 8px;
border: 1px solid #2a2d3e; background: #1a1c28; color: #e0e0e0;
font-size: 14px; line-height: 1.6; font-family: inherit;
resize: vertical; outline: none;
}
textarea:focus { border-color: #6366f1; }
.controls { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; margin: 16px 0 20px; }
.payment-val { color: #10b981; font-weight: 700; font-family: monospace; }
input[type="range"] { width: 100%; accent-color: #6366f1; cursor: pointer; }
select {
padding: 8px 10px; border-radius: 6px; border: 1px solid #2a2d3e;
background: #1a1c28; color: #d1d5db; font-size: 13px; cursor: pointer; outline: none; width: 100%;
}
.submit-btn {
width: 100%; padding: 14px; border-radius: 10px; border: none;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: #fff; font-size: 15px; font-weight: 700; cursor: pointer;
}
.submit-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.accept-btn {
width: 100%; padding: 12px; border-radius: 10px; border: none;
background: linear-gradient(135deg, #10b981, #059669);
color: #fff; font-size: 14px; font-weight: 700; cursor: pointer;
margin-top: 12px;
}
.accept-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.loading-bar { margin-top: 12px; height: 3px; border-radius: 2px; background: #1e2030; overflow: hidden; }
.loading-fill {
width: 40%; height: 100%; border-radius: 2px;
background: linear-gradient(90deg, #6366f1, #8b5cf6);
animation: slide 1.5s ease-in-out infinite alternate;
}
@keyframes slide { from { margin-left: 0; } to { margin-left: 60%; } }
/* Result */
.result-card { background: #14161e; border-radius: 12px; border: 1px solid #10b98140; padding: 24px; }
.result-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.success-badge {
padding: 4px 12px; border-radius: 6px; background: #10b98120;
color: #10b981; font-size: 13px; font-weight: 600; border: 1px solid #10b98140;
}
.accepted-badge {
padding: 4px 12px; border-radius: 6px; background: #6366f120;
color: #a5b4fc; font-size: 13px; font-weight: 600; border: 1px solid #6366f140;
}
.result-cost { font-size: 18px; font-weight: 700; color: #10b981; }
.role-label {
font-size: 11px; font-weight: 600; text-transform: uppercase;
color: #6366f1; letter-spacing: 0.5px; margin-bottom: 4px;
}
.prompt-box { background: #1a1c28; border-radius: 8px; padding: 12px 14px; border-left: 3px solid #f59e0b; margin-bottom: 14px; }
.response-box { background: #1a1c28; border-radius: 8px; padding: 12px 14px; border-left: 3px solid #10b981; margin-bottom: 16px; }
.content-text { font-size: 14px; color: #d1d5db; line-height: 1.6; white-space: pre-wrap; }
.meta-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 10px; border-top: 1px solid #1e2030; padding-top: 14px; margin-bottom: 16px;
}
.meta-label { font-size: 11px; color: #6b7280; text-transform: uppercase; letter-spacing: 0.5px; }
.meta-value { font-size: 13px; color: #d1d5db; font-family: monospace; margin-top: 2px; }
.new-btn {
width: 100%; padding: 12px; border-radius: 10px; border: 1px solid #6366f1;
background: transparent; color: #a5b4fc; font-size: 14px; font-weight: 600; cursor: pointer;
}
/* Error */
.error-box {
background: #ef444420; border: 1px solid #ef4444; border-radius: 8px;
padding: 14px 18px; color: #fca5a5; font-size: 14px; margin-top: 16px;
display: flex; justify-content: space-between; align-items: center;
}
.dismiss-btn {
background: transparent; border: 1px solid #ef4444; color: #ef4444;
border-radius: 6px; padding: 4px 12px; cursor: pointer; font-size: 12px;
}
/* History */
.history-list { display: flex; flex-direction: column; gap: 12px; }
.history-card {
background: #14161e; border-radius: 12px; border: 1px solid #1e2030;
padding: 16px 20px; cursor: pointer; transition: border-color 0.2s;
}
.history-card:hover { border-color: #6366f140; }
.history-card.expanded { border-color: #6366f1; background: #161825; }
.history-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.history-left { display: flex; align-items: center; gap: 8px; }
.history-right { display: flex; align-items: center; gap: 12px; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: #10b981; }
.dot.accepted { background: #6366f1; }
.model-badge {
padding: 2px 8px; border-radius: 4px; background: #8b5cf620;
color: #a78bfa; font-size: 11px; font-weight: 600; font-family: monospace;
}
.time-badge { font-size: 11px; color: #6b7280; }
.token-info { font-size: 12px; color: #6b7280; font-family: monospace; }
.payment { font-size: 14px; font-weight: 700; color: #10b981; }
.empty { text-align: center; padding: 80px 20px; background: #12141c; border-radius: 12px; border: 1px dashed #1e2030; }
.empty p:first-child { color: #9ca3af; font-size: 16px; margin-bottom: 8px; }
.empty p:last-child { color: #4b5563; font-size: 13px; }
.expand-hint { text-align: center; font-size: 11px; color: #4b5563; margin-top: 6px; }
/* Supplier list */
.supplier-item {
display: flex; justify-content: space-between; align-items: center;
padding: 10px 14px; background: #1a1c28; border-radius: 8px; margin-bottom: 6px;
}
.supplier-name { font-weight: 600; color: #e0e0e0; font-size: 14px; }
.supplier-models { font-size: 12px; color: #a78bfa; font-family: monospace; }
.supplier-url { font-size: 11px; color: #6b7280; font-family: monospace; }
</style>
</head>
<body>
<header>
<div class="brand">
<div class="logo">V</div>
<div>
<h1>Vector AI Compute Network</h1>
<p>Buyer Interface (v2)</p>
</div>
</div>
<div class="header-right">
<div class="balance-display" id="wallet-balance">Balance: loading...</div>
<nav>
<button class="nav-btn active" onclick="switchTab('buy', this)">Buy Compute</button>
<button class="nav-btn" onclick="switchTab('history', this)">Task History</button>
</nav>
</div>
</header>
<main>
<!-- Buy Compute Tab -->
<div id="tab-buy" class="tab active">
<div class="section-header">
<h2 class="section-title">Buy Compute</h2>
<span class="badge" id="supplier-count">Loading...</span>
</div>
<!-- Input Form -->
<div id="form-area" class="card">
<label class="field-label">Your Prompt</label>
<textarea id="prompt" rows="4" placeholder="Enter your prompt here... e.g. 'Explain the eUTXO model in simple terms'"></textarea>
<div class="controls">
<div>
<label class="field-label">Payment: <span class="payment-val" id="payment-display">5.0 AP3X</span></label>
<input type="range" id="payment" min="1" max="20" step="0.5" value="5" oninput="document.getElementById('payment-display').textContent = this.value + ' AP3X'">
</div>
<div>
<label class="field-label">Supplier</label>
<select id="supplier"></select>
</div>
</div>
<button class="submit-btn" id="submit-btn" onclick="submitTask()">Submit Task</button>
<div id="loading-bar" class="loading-bar" style="display:none"><div class="loading-fill"></div></div>
</div>
<!-- Error -->
<div id="error-area" class="error-box" style="display:none">
<span id="error-msg"></span>
<button class="dismiss-btn" onclick="document.getElementById('error-area').style.display='none'">Dismiss</button>
</div>
<!-- Result -->
<div id="result-area" class="result-card" style="display:none">
<div class="result-header">
<span class="success-badge" id="r-status-badge">Completed</span>
<span class="result-cost" id="r-cost"></span>
</div>
<div class="role-label">YOUR PROMPT</div>
<div class="prompt-box"><p class="content-text" id="r-prompt"></p></div>
<div class="role-label" id="r-supplier-label">RESPONSE</div>
<div class="response-box"><p class="content-text" id="r-response"></p></div>
<div class="meta-grid">
<div><div class="meta-label">Model</div><div class="meta-value" id="r-model"></div></div>
<div><div class="meta-label">Tokens</div><div class="meta-value" id="r-tokens"></div></div>
<div><div class="meta-label">Latency</div><div class="meta-value" id="r-latency"></div></div>
<div><div class="meta-label">Task ID</div><div class="meta-value" id="r-taskid"></div></div>
<div><div class="meta-label">Escrow TX</div><div class="meta-value" id="r-escrow"></div></div>
<div><div class="meta-label">Supplier</div><div class="meta-value" id="r-supplier"></div></div>
</div>
<button class="accept-btn" id="accept-btn" onclick="acceptTask()">Accept & Pay</button>
<div id="accept-status" style="margin-top: 8px; font-size: 13px; color: #6b7280;"></div>
<button class="new-btn" onclick="resetForm()" style="margin-top: 8px;">New Task</button>
</div>
</div>
<!-- History Tab -->
<div id="tab-history" class="tab">
<div class="section-header">
<h2 class="section-title">Task History</h2>
<span class="badge" id="history-count">Loading...</span>
</div>
<div id="history-list" class="history-list"></div>
</div>
</main>
<script>
var API = '';
var suppliers = [];
var expandedId = null;
var currentResult = null;
// --- Tab switching ---
function switchTab(tab, btn) {
document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });
document.querySelectorAll('.nav-btn').forEach(function(b) { b.classList.remove('active'); });
document.getElementById('tab-' + tab).classList.add('active');
btn.classList.add('active');
if (tab === 'history') loadHistory();
}
// --- Load balance ---
async function loadBalance() {
try {
var resp = await fetch(API + '/api/balance');
var data = await resp.json();
document.getElementById('wallet-balance').textContent = 'Balance: ' + parseFloat(data.ada).toFixed(2) + ' AP3X';
} catch(e) {
document.getElementById('wallet-balance').textContent = 'Balance: unavailable';
}
}
// --- Load suppliers ---
async function loadSuppliers() {
try {
var resp = await fetch(API + '/api/suppliers');
var data = await resp.json();
suppliers = data.suppliers || [];
document.getElementById('supplier-count').textContent = suppliers.length + ' supplier' + (suppliers.length !== 1 ? 's' : '') + ' available';
var sel = document.getElementById('supplier');
if (suppliers.length === 0) {
sel.innerHTML = '<option value="">No suppliers found</option>';
} else {
sel.innerHTML = suppliers.map(function(s) {
var label = s.name + ' [' + s.models.join(', ') + '] - ' + s.endpoint;
return '<option value="' + esc(s.endpoint) + '">' + esc(label) + '</option>';
}).join('');
}
} catch(e) {
document.getElementById('supplier-count').textContent = 'Could not reach API';
}
}
// --- Submit task ---
async function submitTask() {
var prompt = document.getElementById('prompt').value.trim();
if (!prompt) return;
var supplierUrl = document.getElementById('supplier').value;
if (!supplierUrl) {
document.getElementById('error-msg').textContent = 'No supplier selected';
document.getElementById('error-area').style.display = 'flex';
return;
}
var btn = document.getElementById('submit-btn');
btn.disabled = true;
btn.textContent = 'Posting escrow on-chain...';
document.getElementById('loading-bar').style.display = 'block';
document.getElementById('error-area').style.display = 'none';
document.getElementById('result-area').style.display = 'none';
setTimeout(function() { if (btn.disabled) btn.textContent = 'Waiting for LLM response...'; }, 4000);
try {
var resp = await fetch(API + '/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: prompt,
payment_apex: parseFloat(document.getElementById('payment').value),
supplier_url: supplierUrl
})
});
if (!resp.ok) {
var err = null;
try { err = await resp.json(); } catch(x) {}
throw new Error((err && err.detail) ? err.detail : 'HTTP ' + resp.status);
}
var r = await resp.json();
currentResult = r;
showResult(prompt, r);
loadBalance();
} catch(e) {
document.getElementById('error-msg').textContent = e.message;
document.getElementById('error-area').style.display = 'flex';
} finally {
btn.disabled = false;
btn.textContent = 'Submit Task';
document.getElementById('loading-bar').style.display = 'none';
}
}
function showResult(prompt, r) {
document.getElementById('form-area').style.display = 'none';
document.getElementById('result-area').style.display = 'block';
document.getElementById('r-cost').textContent = r.payment_apex.toFixed(1) + ' AP3X';
document.getElementById('r-prompt').textContent = prompt;
document.getElementById('r-supplier-label').textContent = 'RESPONSE -- ' + (r.supplier_url || '');
document.getElementById('r-response').textContent = r.response;
document.getElementById('r-model').textContent = r.model_used || 'unknown';
document.getElementById('r-tokens').textContent = (r.input_tokens || 0) + ' in / ' + (r.output_tokens || 0) + ' out';
document.getElementById('r-latency').textContent = ((r.latency_ms || 0) / 1000).toFixed(1) + 's';
document.getElementById('r-taskid').textContent = r.task_id;
document.getElementById('r-escrow').textContent = r.escrow_tx ? r.escrow_tx.substring(0, 32) + '...' : '-';
document.getElementById('r-supplier').textContent = r.supplier_url || '-';
// Show or hide accept button
var acceptBtn = document.getElementById('accept-btn');
var acceptStatus = document.getElementById('accept-status');
if (r.status === 'accepted' || r.accept_tx) {
acceptBtn.style.display = 'none';
acceptStatus.textContent = 'Accepted (TX: ' + (r.accept_tx || '').substring(0, 32) + '...)';
acceptStatus.style.color = '#10b981';
document.getElementById('r-status-badge').textContent = 'Accepted';
document.getElementById('r-status-badge').className = 'accepted-badge';
} else {
acceptBtn.style.display = 'block';
acceptBtn.disabled = false;
acceptBtn.textContent = 'Accept & Pay';
acceptStatus.textContent = '';
document.getElementById('r-status-badge').textContent = 'Completed';
document.getElementById('r-status-badge').className = 'success-badge';
}
}
// --- Accept task ---
async function acceptTask() {
if (!currentResult || !currentResult.task_id) return;
var btn = document.getElementById('accept-btn');
var statusEl = document.getElementById('accept-status');
btn.disabled = true;
btn.textContent = 'Accepting on-chain...';
statusEl.textContent = '';
try {
var resp = await fetch(API + '/api/accept/' + currentResult.task_id, { method: 'POST' });
if (!resp.ok) {
var err = null;
try { err = await resp.json(); } catch(x) {}
throw new Error((err && err.detail) ? err.detail : 'HTTP ' + resp.status);
}
var data = await resp.json();
btn.style.display = 'none';
statusEl.textContent = 'Accepted! TX: ' + data.accept_tx.substring(0, 32) + '...';
statusEl.style.color = '#10b981';
currentResult.accept_tx = data.accept_tx;
currentResult.status = 'accepted';
document.getElementById('r-status-badge').textContent = 'Accepted';
document.getElementById('r-status-badge').className = 'accepted-badge';
loadBalance();
} catch(e) {
statusEl.textContent = 'Accept failed: ' + e.message;
statusEl.style.color = '#ef4444';
btn.disabled = false;
btn.textContent = 'Accept & Pay';
}
}
function resetForm() {
document.getElementById('form-area').style.display = 'block';
document.getElementById('result-area').style.display = 'none';
document.getElementById('prompt').value = '';
currentResult = null;
}
// --- History ---
function timeAgo(ts) {
var diff = Date.now() / 1000 - ts;
if (diff < 60) return Math.floor(diff) + 's ago';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
return Math.floor(diff / 86400) + 'd ago';
}
function truncate(s, len) { return s && s.length > len ? s.substring(0, len) + '...' : (s || '-'); }
async function loadHistory() {
try {
var resp = await fetch(API + '/api/tasks');
var data = await resp.json();
var tasks = data.tasks || [];
document.getElementById('history-count').textContent = data.total + ' tasks';
if (tasks.length === 0) {
document.getElementById('history-list').innerHTML = '<div class="empty"><p>No tasks yet</p><p>Tasks will appear here once you submit prompts</p></div>';
return;
}
// Show most recent first
tasks = tasks.slice().reverse();
document.getElementById('history-list').innerHTML = tasks.map(function(t) {
var isExp = expandedId === t.task_id;
var promptText = isExp ? t.prompt : (t.prompt || '').substring(0, 150) + ((t.prompt || '').length > 150 ? '...' : '');
var respText = isExp ? t.response : (t.response || '').substring(0, 200) + ((t.response || '').length > 200 ? '...' : '');
var isAccepted = t.status === 'accepted' || t.accept_tx;
var dotClass = isAccepted ? 'dot accepted' : 'dot';
var html = '<div class="history-card' + (isExp ? ' expanded' : '') + '" onclick="toggleHistory(\'' + t.task_id + '\')">'
+ '<div class="history-header"><div class="history-left">'
+ '<span class="' + dotClass + '"></span><span class="model-badge">' + esc(t.model_used || 'unknown') + '</span>'
+ '<span class="time-badge">' + (t.completed_at ? timeAgo(t.completed_at) : '-') + '</span></div>'
+ '<div class="history-right"><span class="token-info">' + ((t.input_tokens||0) + (t.output_tokens||0)) + ' tokens</span>'
+ '<span class="payment">' + ((t.payment_dfm||0) / 1000000).toFixed(1) + ' AP3X</span></div></div>'
+ '<div class="role-label">PROMPT</div>'
+ '<div class="prompt-box"><p class="content-text">' + esc(promptText) + '</p></div>'
+ '<div class="role-label">RESPONSE</div>'
+ '<div class="response-box"><p class="content-text">' + esc(respText) + '</p></div>';
if (isExp) {
html += '<div class="meta-grid">'
+ '<div><div class="meta-label">Task ID</div><div class="meta-value">' + esc(t.task_id || '-') + '</div></div>'
+ '<div><div class="meta-label">Task Hash</div><div class="meta-value">' + truncate(t.task_hash, 24) + '</div></div>'
+ '<div><div class="meta-label">Escrow TX</div><div class="meta-value">' + truncate(t.escrow_tx, 24) + '</div></div>'
+ '<div><div class="meta-label">Claim TX</div><div class="meta-value">' + truncate(t.claim_tx, 24) + '</div></div>'
+ '<div><div class="meta-label">Submit TX</div><div class="meta-value">' + truncate(t.submit_tx, 24) + '</div></div>'
+ '<div><div class="meta-label">Accept TX</div><div class="meta-value">' + truncate(t.accept_tx, 24) + '</div></div>'
+ '<div><div class="meta-label">Input Tokens</div><div class="meta-value">' + (t.input_tokens||0) + '</div></div>'
+ '<div><div class="meta-label">Output Tokens</div><div class="meta-value">' + (t.output_tokens||0) + '</div></div>'
+ '<div><div class="meta-label">Latency</div><div class="meta-value">' + (t.latency_ms||0) + 'ms</div></div>'
+ '<div><div class="meta-label">Supplier</div><div class="meta-value">' + esc(t.supplier_url || '-') + '</div></div>'
+ '<div><div class="meta-label">Status</div><div class="meta-value" style="color:' + (isAccepted ? '#6366f1' : '#10b981') + '">' + esc(t.status||'completed') + '</div></div>'
+ '</div>';
}
html += '<div class="expand-hint">' + (isExp ? 'Click to collapse' : 'Click to expand') + '</div></div>';
return html;
}).join('');
} catch(e) {
document.getElementById('history-list').innerHTML = '<div class="empty"><p>Could not load history</p><p>' + esc(e.message) + '</p></div>';
}
}
function toggleHistory(id) { expandedId = expandedId === id ? null : id; loadHistory(); }
function esc(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
// --- Init ---
loadBalance();
loadSuppliers();
</script>
</body>
</html>

51
docs/buyer/storage.py Normal file
View File

@@ -0,0 +1,51 @@
"""Simple JSON file storage for task history."""
import json
from pathlib import Path
from typing import List, Optional
DATA_DIR = Path(__file__).parent / "data"
TASKS_FILE = DATA_DIR / "tasks.json"
def _ensure_data_dir():
DATA_DIR.mkdir(parents=True, exist_ok=True)
if not TASKS_FILE.exists():
TASKS_FILE.write_text("[]", encoding="utf-8")
def save_task(task_data: dict):
"""Append a task record to tasks.json."""
_ensure_data_dir()
tasks = load_tasks()
tasks.append(task_data)
TASKS_FILE.write_text(json.dumps(tasks, indent=2), encoding="utf-8")
def load_tasks() -> List[dict]:
"""Read all tasks from tasks.json."""
_ensure_data_dir()
try:
raw = TASKS_FILE.read_text(encoding="utf-8")
return json.loads(raw)
except (json.JSONDecodeError, FileNotFoundError):
return []
def get_task(task_id: str) -> Optional[dict]:
"""Find a task by its task_id."""
tasks = load_tasks()
for t in tasks:
if t.get("task_id") == task_id:
return t
return None
def update_task(task_id: str, updates: dict):
"""Update fields of an existing task by task_id."""
_ensure_data_dir()
tasks = load_tasks()
for i, t in enumerate(tasks):
if t.get("task_id") == task_id:
tasks[i].update(updates)
break
TASKS_FILE.write_text(json.dumps(tasks, indent=2), encoding="utf-8")

View File

@@ -0,0 +1,44 @@
"""HTTP client for communicating with suppliers."""
from typing import Optional
import httpx
from lib.protocol import SupplierInfo, TaskRequest, TaskResponse
async def send_task(supplier_url: str, task_request: TaskRequest) -> TaskResponse:
"""POST a task to a supplier and return the parsed response.
Args:
supplier_url: Base URL of the supplier (e.g. http://localhost:9000).
task_request: The task request payload.
Returns:
TaskResponse parsed from the supplier's JSON response.
"""
async with httpx.AsyncClient(timeout=120.0) as client:
resp = await client.post(
f"{supplier_url.rstrip('/')}/task",
json=task_request.model_dump(),
)
resp.raise_for_status()
return TaskResponse(**resp.json())
async def check_supplier_health(supplier_url: str) -> Optional[SupplierInfo]:
"""GET supplier info endpoint and return parsed SupplierInfo, or None on failure.
Args:
supplier_url: Base URL of the supplier.
Returns:
SupplierInfo if reachable, None otherwise.
"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(f"{supplier_url.rstrip('/')}/info")
resp.raise_for_status()
data = resp.json()
return SupplierInfo(**data)
except Exception:
return None

167
docs/buyer/task_manager.py Normal file
View File

@@ -0,0 +1,167 @@
"""Orchestrates the full buyer flow: escrow -> task -> accept."""
import hashlib
import time
from uuid import uuid4
from pycardano import Address
from pycardano.hash import ScriptHash
from lib.blueprint import load_escrow_script
from lib.chain import find_script_utxo, wait_for_utxo
from lib.constants import DEFAULT_DEADLINE_OFFSET_MS, DISPUTE_WINDOW_MS
from lib.protocol import TaskRequest
from escrow_ops import post_escrow, accept_result
from supplier_client import send_task, check_supplier_health
from storage import get_task
async def submit_task(prompt, payment_apex, supplier_url, buyer_agent, config) -> dict:
"""Execute the full buyer flow:
1. Load script from blueprint
2. Compute task_hash = sha256(f"{task_id}:{prompt}")
3. Compute deadline_posix_ms = current_posix_ms + DEFAULT_DEADLINE_OFFSET_MS
4. Post escrow on-chain (Open state)
5. Send TaskRequest to supplier via HTTP
6. Return combined result dict
Args:
prompt: The user's prompt text.
payment_apex: Payment amount in APEX (float).
supplier_url: URL of the supplier to use.
buyer_agent: VectorAgent instance for the buyer.
config: BuyerConfig instance.
Returns:
dict with escrow_tx, response data, supplier info, etc.
"""
context = buyer_agent.context
task_id = f"task-{uuid4().hex[:8]}"
payment_dfm = int(payment_apex * 1_000_000)
# 1. Load script from blueprint
script, script_hash_str = load_escrow_script()
# 2. Compute task hash
task_hash = hashlib.sha256(f"{task_id}:{prompt}".encode()).hexdigest()
# 3. Compute deadline
current_posix_ms = int(time.time() * 1000)
deadline_posix_ms = current_posix_ms + DEFAULT_DEADLINE_OFFSET_MS
# 4. Extract resolver credential from resolver address
resolver_addr = Address.from_primitive(config.resolver_address)
resolver_cred = bytes(resolver_addr.payment_part).hex()
# 5. Post escrow on-chain
escrow_tx = await post_escrow(
context=context,
signer=buyer_agent,
script_hash=config.escrow_script_hash,
task_hash=task_hash,
payment_dfm=payment_dfm,
deadline_posix_ms=deadline_posix_ms,
resolver_cred=resolver_cred,
)
print(f"[Buyer] Escrow posted: {escrow_tx}")
# 6. Send task to supplier
task_request = TaskRequest(
task_id=task_id,
prompt=prompt,
escrow_tx=escrow_tx,
escrow_script_hash=config.escrow_script_hash,
payment_dfm=payment_dfm,
task_hash=task_hash,
buyer_address=buyer_agent.address,
model_preference="nemotron",
max_tokens=2048,
temperature=0.7,
deadline_posix_ms=deadline_posix_ms,
dispute_window_ms=DISPUTE_WINDOW_MS,
)
start_time = time.time()
response = await send_task(supplier_url, task_request)
latency_ms = int((time.time() - start_time) * 1000)
print(f"[Buyer] Supplier response: status={response.status}")
return {
"task_id": task_id,
"prompt": prompt,
"payment_apex": payment_apex,
"payment_dfm": payment_dfm,
"escrow_tx": escrow_tx,
"task_hash": task_hash,
"supplier_url": supplier_url,
"buyer_address": buyer_agent.address,
"deadline_posix_ms": deadline_posix_ms,
"status": response.status,
"response": response.response,
"result_hash": response.result_hash,
"model_used": response.model_used,
"input_tokens": response.input_tokens,
"output_tokens": response.output_tokens,
"claim_tx": response.claim_tx,
"submit_tx": response.submit_tx,
"latency_ms": latency_ms,
"completed_at": time.time(),
"error": response.error,
}
async def accept_task(task_id, buyer_agent, config) -> str:
"""Accept a submitted task result by spending the escrow UTxO.
Loads the task from local storage, finds the Submit UTxO on-chain,
and calls accept_result.
Args:
task_id: The task identifier.
buyer_agent: VectorAgent instance for the buyer.
config: BuyerConfig instance.
Returns:
The accept transaction hash as a string.
"""
task_data = get_task(task_id)
if not task_data:
raise ValueError(f"Task {task_id} not found in storage")
submit_tx_hash = task_data.get("submit_tx")
if not submit_tx_hash:
raise ValueError(f"Task {task_id} has no submit_tx recorded")
context = buyer_agent.context
script, script_hash_str = load_escrow_script()
script_hash_obj = ScriptHash.from_primitive(config.escrow_script_hash)
script_address = Address(script_hash_obj, network=context.network)
# Find the UTxO at the script address
utxo = await find_script_utxo(context, str(script_address), submit_tx_hash)
if not utxo:
# Try waiting for it
utxo = await wait_for_utxo(context, str(script_address), submit_tx_hash)
# Get the supplier's wallet address
supplier_url = task_data.get("supplier_url", "")
supplier_info = await check_supplier_health(supplier_url) if supplier_url else None
if not supplier_info or not supplier_info.address:
raise ValueError(f"Cannot resolve supplier address from {supplier_url}")
accept_tx = await accept_result(
context=context,
signer=buyer_agent,
utxo=utxo,
script=script,
script_address=script_address,
supplier_address=supplier_info.address,
payment_dfm=task_data.get("payment_dfm", 0),
)
print(f"[Buyer] Accepted task {task_id}: {accept_tx}")
return accept_tx

15
docs/buyer/wallet.py Normal file
View File

@@ -0,0 +1,15 @@
"""Buyer wallet -- initializes VectorAgent from mnemonic."""
from vector_agent import VectorAgent
from config import get_config
def get_buyer_agent() -> VectorAgent:
"""Create and return a VectorAgent from the configured buyer mnemonic."""
config = get_config()
agent = VectorAgent(
ogmios_url=config.ogmios_url,
submit_url=config.submit_url,
mnemonic=config.buyer_mnemonic,
)
return agent

1
docs/img/.blocks.json Normal file
View File

@@ -0,0 +1 @@
[{"file":"docs/img/diagram-001.mmd","png":"docs/img/diagram-001.png"},{"file":"docs/img/diagram-002.mmd","png":"docs/img/diagram-002.png"},{"file":"docs/img/diagram-003.mmd","png":"docs/img/diagram-003.png"}]

BIN
docs/img/diagram-001.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

BIN
docs/img/diagram-002.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

BIN
docs/img/diagram-003.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

View File

@@ -0,0 +1,412 @@
---
marp: true
paginate: true
size: 16:9
---
<!--
EXECUTIVE_SUMMARY.md — slide-style overview of the Local Agents Marketplace.
Build PDF + PPTX with: bash docs/build-deck.sh
Edit this file; mermaid blocks are pre-rendered to docs/img/diagram-*.png.
-->
<style>
/* ── Local Agents Marketplace — unified dark theme ────────────────────
* Every slide uses the same navy→indigo gradient as the title slide so
* the deck reads as one continuous document. Title and closing slides
* keep the centered hero treatment; content slides stay top-aligned but
* share the same palette and accent rules. */
:root {
--ink: #f1f5f9; /* primary text on dark gradient */
--muted: #94a3b8;
--accent: #818cf8; /* indigo-400, readable on the dark gradient */
--accent-deep: #4f46e5; /* used for solid accents (h2 bar, table head) */
--accent-soft: rgba(129,140,248,0.12);
--gradient: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #312e81 100%);
--row-even: rgba(255,255,255,0.04);
--row-odd: rgba(255,255,255,0.02);
--border: rgba(255,255,255,0.10);
}
section {
background: var(--gradient);
color: var(--ink);
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
font-size: 22px;
padding: 40px 60px;
display: flex;
flex-direction: column;
justify-content: flex-start;
}
section::after {
content: attr(data-marpit-pagination) ' / ' attr(data-marpit-pagination-total);
color: var(--muted);
font-size: 13px;
}
h1 {
font-size: 48px;
font-weight: 700;
color: #ffffff;
border-bottom: 4px solid var(--accent);
padding-bottom: 10px;
margin: 0 0 18px 0;
}
h2 {
font-size: 34px;
font-weight: 700;
color: #ffffff;
margin: 0 0 14px 0;
}
h2::before {
content: '';
display: inline-block;
width: 6px;
height: 28px;
background: var(--accent);
margin-right: 14px;
vertical-align: middle;
border-radius: 2px;
}
h3 { color: #c7d2fe; font-weight: 600; font-size: 24px; margin: 8px 0; }
p, li { margin: 6px 0; line-height: 1.45; }
ul, ol { margin: 8px 0 12px 0; padding-left: 28px; }
strong { color: #ffffff; }
a { color: #a5b4fc; }
blockquote {
border-left: 4px solid var(--accent);
background: rgba(255,255,255,0.06);
padding: 10px 18px;
color: #e0e7ff;
border-radius: 0 8px 8px 0;
margin: 10px 0;
}
code {
background: rgba(255,255,255,0.10);
color: #e0e7ff;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
}
/* Constrain mermaid images so they always fit on a 720px-tall slide
* (16:9 @ 1280×720 default). Soft white card behind the diagram so the
* mermaid SVG (which renders on a transparent canvas with dark ink)
* stays readable on the navy gradient. */
img {
display: block;
margin: 8px auto;
max-width: 92%;
max-height: 420px;
object-fit: contain;
background: #ffffff;
border-radius: 10px;
padding: 10px;
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
}
/* Tile grid for the local-agent versatility slide. */
.backends {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
margin: 14px 0 12px 0;
}
.tile {
background: var(--accent-soft);
border: 2px solid var(--accent);
border-radius: 10px;
padding: 14px 16px;
text-align: center;
font-weight: 600;
color: #ffffff;
}
.tile small {
display: block;
margin-top: 4px;
font-weight: 400;
color: var(--muted);
font-size: 0.8em;
}
.tile.dots {
background: rgba(255,255,255,0.04);
border-style: dashed;
color: var(--muted);
font-size: 28px;
}
.pipeline {
text-align: center;
background: rgba(255,255,255,0.08);
border: 1px solid var(--border);
color: #f8fafc;
padding: 12px 18px;
border-radius: 10px;
margin: 6px auto 14px auto;
max-width: 760px;
font-weight: 600;
}
.pipeline span {
display: inline-block;
margin: 0 10px;
}
/* Tables sit on a solid white card so cell text reads cleanly against
* the dark gradient backdrop. Rounded outer corners + shadow match the
* mermaid card styling. `display:table` + `table-layout:fixed` are
* needed so the table actually consumes the full slide width — Marp's
* default theme leaves tables at content-natural width, which left
* blank gutters on either side. */
table {
display: table !important;
width: 100% !important;
table-layout: fixed;
border-collapse: collapse;
margin: 12px 0;
font-size: 0.82em;
color: #0f172a;
background: #ffffff;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
}
td, th { word-wrap: break-word; overflow-wrap: anywhere; }
th {
background: var(--accent-deep);
color: #ffffff;
text-align: left;
padding: 9px 12px;
font-weight: 700;
}
td {
padding: 7px 12px;
border-bottom: 1px solid #e2e8f0;
color: #0f172a;
}
tr:nth-child(even) td { background: #ffffff; }
tr:nth-child(odd) td { background: #f8fafc; }
/* code spans inside table cells need a light pill, not the dark one
* defined globally — otherwise they'd disappear into the white row. */
td code {
background: #eef2ff;
color: #1e293b;
}
/* Bold/strong inside cells must stay dark — the global rule paints
* <strong> white for the dark-gradient body text, which would render
* invisible against the white table card. */
td strong, th strong {
color: #0f172a;
}
th strong {
color: #ffffff;
}
/* Title slide — centered hero treatment on the same gradient. */
section.title {
text-align: center;
padding: 100px 80px;
justify-content: center;
}
section.title h1 {
display: inline-block;
font-size: 64px;
margin: 0 auto 20px auto;
}
section.title blockquote {
margin: 28px auto;
max-width: 900px;
}
/* Closing slide — same centered treatment. */
section.closing {
text-align: center;
padding: 100px 80px;
justify-content: center;
}
section.closing h2::before { display: none; }
section.closing blockquote { font-size: 26px; max-width: 900px; }
</style>
<!-- _class: title -->
# Local Agents Marketplace
### A trust-minimised, on-chain marketplace for LLM inference
Buy and sell prompt → response with **bonded escrow** on **Vector** (UTxO).
No platform fee. No central rate-limiter. Verifiable receipts on chain.
> *Status: M1 lifecycle proven end-to-end on Vector testnet — 2026-05-05.*
---
## The problem
* AI inference is **centralised** — a handful of providers, opaque pricing, account-level rate limits.
* Local-AI hardware is **idle most of the time** — GPUs and CPUs sit unused between user prompts.
* Buyers and sellers can't transact directly without a **trusted intermediary** taking a cut.
* "Verifiable" claims about which model ran are usually nothing more than the provider's word.
We need a **two-sided marketplace** where:
1. Sellers monetise spare compute,
2. Buyers get a **cryptographic receipt** of what was actually computed,
3. **Neither side has to trust the other** — bonds + on-chain settlement enforce honesty.
---
## How it works — three actors, one chain
```mermaid
flowchart LR
subgraph CHAIN[Vector — UTxO]
ADV[Advert UTxO<br/>capability, price, bonds]
ESC[Escrow UTxO<br/>locked funds + state]
end
SUP[🧠 Supplier<br/>local LLM node]
BUY[💼 Buyer<br/>app or agent]
IDX[🔭 Indexer<br/>read-only bulletin board]
SUP -->|publish advert| ADV
BUY -->|discover| IDX
IDX -.->|index events| CHAIN
BUY -->|post escrow| ESC
BUY -->|prompt + escrow_ref| SUP
SUP -->|signed receipt| BUY
SUP -->|claim & submit| ESC
BUY -->|accept & pay| ESC
```
* **Supplier** runs the inference and publishes a price.
* **Buyer** posts the work as a bonded escrow, sends the prompt, accepts the receipt.
* **Indexer** is a public mirror of chain state — anyone can run one. No platform middleman.
---
## The bonded-escrow lifecycle
```mermaid
sequenceDiagram
autonumber
participant B as Buyer
participant Chain as Vector
participant S as Supplier
participant LLM as Local LLM<br/>(OpenAI-compatible)
S->>Chain: PostAdvert (locks supplier_bond)
B->>Chain: PostEscrow → Open<br/>(locks payment + bonds)
B->>S: POST /v1/chat/completions
S->>Chain: ClaimEscrow → Claimed
S->>LLM: run inference
LLM-->>S: assistant message
S->>Chain: SubmitEscrow → Submitted
S-->>B: 200 OK + signed receipt
B->>Chain: AcceptEscrow — supplier paid
```
Every transition is on-chain, validated by an Aiken Plutus V3 script. The receipt is signed by the supplier's advert key; prompt and response hashes are bound on chain.
---
## What if someone misbehaves?
```mermaid
flowchart LR
OPEN[Open]:::norm --> CLAIMED[Claimed]:::norm --> SUBMITTED[Submitted]:::norm --> ACCEPTED[Accepted ✓]:::good
OPEN -->|deliver_by passed| RECLAIM[Reclaimed<br/>buyer recovers funds]:::warn
CLAIMED -->|deliver_by passed| RECLAIM
SUBMITTED -->|accept_window passed| RELEASE[Released<br/>supplier collects]:::warn
classDef norm fill:#e0e7ff,stroke:#4f46e5
classDef good fill:#dcfce7,stroke:#16a34a
classDef warn fill:#fef3c7,stroke:#d97706
```
| Failure mode | Recovery | Loss bearer |
|---|---|---|
| Supplier never claims/submits | Buyer **Reclaim** after `deliver_by` | Supplier — forfeits supplier_bond |
| Buyer disappears after Submit | Supplier **Release** after `submitted_at + 10 min` | Buyer — forfeits buyer_bond |
Bonds make honesty cheaper than running away. Both sides have skin in the game; no third-party arbiter is needed for the M1 happy/timeout paths.
---
## Local-agent versatility — any OpenAI-compatible engine
<div class="pipeline">
<span>💼 Buyer</span> →
<span>🧠 Supplier (OpenAI-compatible HTTP wrapper)</span> →
<span>backend ↓</span>
</div>
<div class="backends">
<div class="tile">Ollama<br/><small>local model runner</small></div>
<div class="tile">vLLM<br/><small>high-throughput GPU</small></div>
<div class="tile">llama.cpp<br/><small>CPU · Metal · ROCm</small></div>
<div class="tile">OpenRouter<br/><small>cloud API mux</small></div>
<div class="tile">openclaw / custom<br/><small>arbitrary stack</small></div>
<div class="tile dots">…<br/><small>any OpenAI-shaped API</small></div>
</div>
The supplier service is a thin HTTP wrapper — it forwards `/v1/chat/completions` to whatever backend is configured. Each supplier picks its own model, hardware, and engine, then advertises a `capability_id`. Buyers shop on capability + model + price.
---
## Capability + model = task selection
| Capability ID | Example models | Use case |
|---|---|---|
| `llm.text.generate.v1` | Llama 3 · Qwen 2.5 · Mistral · GPT-4 | Q&A, drafts, summarisation |
| `code.completion.v1` | Codestral · DeepSeek-Coder · StarCoder | IDE tab-complete, refactor |
| `vision.describe.v1` | LLaVA · Qwen-VL · Pixtral | Image captioning, OCR |
| `embedding.text.v1` | nomic-embed · BGE · ada-002 | RAG indexing |
| `audio.transcribe.v1` | whisper-large-v3 · distil-whisper | Voice-to-text |
| `…` | … | … |
* New capabilities = **just publish a new advert**. No marketplace governance gate.
* Today's deploy uses Ollama + Qwen 2.5; switching to vLLM with Llama 3 70B is a config change, not a code change.
---
## OpenClaw — agentic teams on your existing OpenAI seat
<div class="pipeline">
<span>🧠 Supplier wrapper</span> →
<span>🦾 OpenClaw</span> →
<span>🪪 OpenAI ChatGPT seat (flat-rate)</span>
</div>
* **Cost-efficient by default.** OpenClaw exposes your existing **OpenAI ChatGPT subscription** as an OpenAI-compatible API. The marketplace supplier dispatches its inference against it, so an entire **agentic team** (coders, QA automation testers, release curators…) draws from the **flat-rate seat you already pay for** — no per-token metering, no surprise invoices.
* **Reliability layer (optional, marginal cost).** OpenClaw can fall back to the **Anthropic API** or **OpenRouter** model pool when the primary seat is rate-limited, the prompt requires a different model, or you want a **second-layer hardening** of responses (e.g. cross-provider consensus on critical outputs). You only pay metered tokens for the requests that actually fall through.
---
## Proven on Vector testnet — 2026-05-05
| Step | Tx hash |
|---|---|
| Buyer posts escrow | `af762561…c2f7bc5` |
| Supplier claims | (lifecycle tx) |
| Supplier submits receipt | (lifecycle tx) |
| Buyer accepts | `125e3cfe…7dc100d` |
Funds flowed correctly: supplier received `payment + supplier_bond` (3 ADA); buyer got `buyer_bond` back.
* **Browser demo:** https://mp-buyers.vector.testnet.apexfusion.org
* **Live event stream:** https://mp-indexer.vector.testnet.apexfusion.org
* **Test suite:** 1098 unit tests passing, full lifecycle exercised in CI.
---
## Why UTxO + Vector
* **Bonded escrow is native** — Plutus validators enforce the state machine without a custom token or oracle.
* **Vector settles in seconds** — the buyer→claim→submit→accept loop fits inside a single chat-completion's wall clock.
* **AP3X token** for fees and bonds — fungible, transparent, on-chain.
* **No custom rollup, no bridge** — runs on a production chain today.
---
<!-- _class: closing -->
## The pitch in one sentence
> Local Agents Marketplace turns any OpenAI-compatible inference endpoint into a **trustlessly billable service** — buyer and supplier exchange prompt, response, and payment with on-chain proof that what was paid for is what was delivered.
### Try it
* **Indexer (live event stream):** https://mp-indexer.vector.testnet.apexfusion.org
* **Buyer:** https://mp-buyers.vector.testnet.apexfusion.org
* **Supplier:** https://mp-suppliers.vector.testnet.apexfusion.org/capability

Binary file not shown.

Binary file not shown.

3
docs/puppeteer.json Normal file
View File

@@ -0,0 +1,3 @@
{
"args": ["--no-sandbox", "--disable-setuid-sandbox"]
}

View File

@@ -0,0 +1,14 @@
# Chain
VECTOR_OGMIOS_URL=https://ogmios.vector.testnet.apexfusion.org
VECTOR_SUBMIT_URL=https://submit.vector.apexfusion.org/api/submit/tx
# Wallet
SUPPLIER_MNEMONIC=your fifteen word mnemonic here
# Contract
ESCROW_SCRIPT_HASH=74d7f72f7ef0e2e79290ccf8d2eeda672b43379f037def441709395d
# LLM
OPENROUTER_API_KEY=sk-or-v1-...
# Resolver
RESOLVER_ADDRESS=addr1qxql380vv0up49vyw6ey8rsgut7saqval7fc40e9hvy9t60xw4lf7cz8rxkq9fmes4yf72tvjcjuh0tweghyrcpnl9vs6h5gjs
# Server
SUPPLIER_PORT=9000
SUPPLIER_NAME=default-supplier

185
docs/supplier/README.md Normal file
View File

@@ -0,0 +1,185 @@
# Vector Supplier Node
A self-contained AI compute supplier for the Vector L2 testnet (Cardano). You receive task requests from buyers, fulfill them with an LLM, and settle payment on-chain via escrow smart contracts.
## How It Works
1. A **buyer** posts an escrow transaction on-chain, locking their payment + bond
2. The buyer sends you an HTTP request with the task prompt and escrow TX hash
3. Your node **verifies** the escrow exists on-chain
4. Your node calls the **LLM** to generate a response
5. Your node **claims** the escrow on-chain (Open -> Claimed)
6. Your node **submits** the result hash on-chain (Claimed -> Submitted)
7. The buyer reviews the response and **accepts** (releasing payment to you)
All on-chain steps happen automatically. You just need to keep this node running.
## Quick Start
### 1. Install Python
You need Python 3.9 or newer. Download from https://www.python.org/downloads/
### 2. Create a virtual environment
```bash
cd supplier
python -m venv .venv
# Windows:
.venv\Scripts\activate
# macOS/Linux:
source .venv/bin/activate
```
### 3. Install dependencies
```bash
pip install -r requirements.txt
```
### 4. Configure your environment
Copy the example file and fill in your values:
```bash
copy .env.example .env
```
Edit `.env` with your settings:
```
# Your wallet seed phrase (15 words)
SUPPLIER_MNEMONIC=word1 word2 word3 ... word15
# LLM API key from https://openrouter.ai
OPENROUTER_API_KEY=sk-or-v1-...
# Port to run on (default 9000)
SUPPLIER_PORT=9000
# Your node's display name
SUPPLIER_NAME=my-supplier
```
The other values (VECTOR_OGMIOS_URL, VECTOR_SUBMIT_URL, ESCROW_SCRIPT_HASH, RESOLVER_ADDRESS) are pre-configured for the Vector testnet and should not need changing.
### 5. Fund your wallet
Your supplier wallet needs ADA to pay transaction fees and post bonds. You need at least **5 ADA** to get started.
When you start the node, it will print your wallet address. Send ADA to that address.
### 6. Start the node
```bash
python main.py
```
You will see:
```
Starting Vector Supplier Node 'my-supplier' on port 9000
Dashboard: http://localhost:9000
```
### 7. Open the dashboard
Go to **http://localhost:9000** in your browser. The dashboard shows:
- Your wallet balance
- Total earnings
- Number of tasks completed
- Task history with full details (prompts, responses, TX hashes)
## Giving Your URL to Buyers
Buyers need your URL to send you tasks. If you are running on:
- **Same machine**: `http://localhost:9000`
- **Local network**: `http://YOUR_IP:9000` (find your IP with `ipconfig` on Windows)
- **Public internet**: You need to either:
- Set up port forwarding on your router (forward port 9000)
- Use a service like ngrok: `ngrok http 9000`
- Deploy to a cloud server (AWS, DigitalOcean, etc.)
The buyer adds your URL to their `SUPPLIER_URLS` config and your node appears in their dashboard.
## File Structure
```
supplier/
main.py Entry point - starts the web server
config.py Loads settings from .env
wallet.py Initializes blockchain wallet
api.py HTTP API endpoints
task_runner.py Orchestrates the full task flow
escrow_ops.py On-chain escrow transactions
llm.py LLM API client (OpenRouter/Anthropic/OpenAI)
storage.py Task history (JSON file)
.env Your configuration (secret - do not share)
.env.example Template for .env
requirements.txt Python dependencies
static/
dashboard.html Web dashboard UI
lib/ Blockchain utilities
constants.py Protocol constants
chain.py Chain query helpers
datum.py CBOR datum builders
escrow_tx.py Transaction builder
blueprint.py Smart contract loader
protocol.py API data models
contracts/
escrow/
plutus.json Compiled smart contract
data/
tasks.json Task history (auto-created)
```
## Configuration Reference
| Variable | Description | Default |
|----------|-------------|---------|
| VECTOR_OGMIOS_URL | Blockchain node URL | Vector testnet |
| VECTOR_SUBMIT_URL | TX submission URL | Vector testnet |
| SUPPLIER_MNEMONIC | Your 15-word wallet seed phrase | (required) |
| ESCROW_SCRIPT_HASH | Escrow contract hash | Pre-configured |
| OPENROUTER_API_KEY | LLM API key from openrouter.ai | (required) |
| RESOLVER_ADDRESS | Dispute resolver address | Pre-configured |
| SUPPLIER_PORT | HTTP port | 9000 |
| SUPPLIER_NAME | Display name | default-supplier |
## Earnings
Payment is in **AP3X** (the Vector testnet token). 1 AP3X = 1,000,000 lovelace.
When a buyer accepts your response, the escrow releases:
- Your payment (set by the buyer, typically 3-10 AP3X per task)
- Your bond back (1 AP3X)
- The buyer's bond (1 AP3X bonus to you)
If the buyer does NOT accept within the dispute window (10 minutes), you can collect payment automatically.
## Security Notes
- **Never share your .env file** - it contains your wallet seed phrase
- **Never share your data/ folder** - it contains task history
- The seed phrase controls your wallet funds. Back it up securely.
- The node only responds to buyers who have already locked funds on-chain, so you cannot be scammed out of compute.
## Troubleshooting
**"No module named 'vector_agent'"**
- Make sure you installed dependencies: `pip install -r requirements.txt`
**"ogmios_url required"**
- Make sure your .env file exists and is in the supplier/ folder
**"Collateral check failed"**
- Your wallet needs more ADA. Send at least 5 ADA to your supplier address.
**Dashboard shows "Balance: unavailable"**
- The Vector testnet node may be temporarily down. Wait and refresh.
**Task fails with "UTxO not found"**
- The buyer's escrow transaction hasn't propagated yet. The node retries automatically for up to 60 seconds.

View File

186
docs/supplier/api.py Normal file
View File

@@ -0,0 +1,186 @@
"""
FastAPI router -- HTTP endpoints for the supplier service.
Includes task processing, health checks, and dashboard API.
"""
import logging
import time
from fastapi import APIRouter, HTTPException
from lib.protocol import TaskRequest, TaskResponse, SupplierInfo
from config import get_config
from wallet import get_supplier_agent
from llm import LLMProvider
from task_runner import run_task
import storage
logger = logging.getLogger(__name__)
router = APIRouter()
# Lazy-initialized singletons
_agent = None
_llm_provider = None
_config = None
def _get_agent():
global _agent
if _agent is None:
_agent = get_supplier_agent()
return _agent
def _get_llm_provider():
global _llm_provider
if _llm_provider is None:
config = _get_config()
_llm_provider = LLMProvider(
openrouter_api_key=config.openrouter_api_key,
)
return _llm_provider
def _get_config():
global _config
if _config is None:
_config = get_config()
return _config
@router.post("/task", response_model=TaskResponse)
async def handle_task(task_request: TaskRequest):
"""Receive a task request from a buyer, fulfill it, and settle on-chain."""
config = _get_config()
agent = _get_agent()
llm = _get_llm_provider()
logger.info(f"Received task {task_request.task_id} -- escrow_tx={task_request.escrow_tx}")
start_time = time.time()
response = await run_task(task_request, agent, llm, config)
latency_ms = int((time.time() - start_time) * 1000)
# Save to local storage regardless of success/failure
task_record = {
"task_id": task_request.task_id,
"prompt": task_request.prompt,
"buyer_address": task_request.buyer_address,
"payment_dfm": task_request.payment_dfm,
"escrow_tx": task_request.escrow_tx,
"task_hash": task_request.task_hash,
"model_preference": task_request.model_preference,
"status": response.status,
"response": response.response,
"result_hash": response.result_hash,
"model_used": response.model_used,
"input_tokens": response.input_tokens,
"output_tokens": response.output_tokens,
"claim_tx": response.claim_tx,
"submit_tx": response.submit_tx,
"error": response.error,
"latency_ms": latency_ms,
"completed_at": time.time(),
}
storage.save_task(task_record)
if response.status == "failed":
logger.error(f"Task {task_request.task_id} failed: {response.error}")
raise HTTPException(status_code=500, detail=response.error)
return response
@router.get("/health")
async def health():
"""Health check endpoint."""
config = _get_config()
try:
agent = _get_agent()
address = str(agent.address)
except Exception:
address = "not initialized"
return {
"status": "ok",
"name": config.name,
"address": address,
}
@router.get("/info", response_model=SupplierInfo)
async def info():
"""Return supplier metadata."""
config = _get_config()
try:
agent = _get_agent()
address = str(agent.address)
except Exception:
address = "not initialized"
return SupplierInfo(
name=config.name,
address=address,
endpoint=f"http://localhost:{config.port}",
models=[
"nvidia/nemotron-3-super-120b-a12b",
"nvidia/nemotron-3-nano-30b-a3b",
"anthropic/claude-sonnet-4-6",
"anthropic/claude-haiku-4-5-20251001",
],
)
@router.get("/api/dashboard")
async def dashboard():
"""Return dashboard data: wallet info, stats, and task history."""
config = _get_config()
# Wallet info
wallet_info = {"address": "not initialized", "ada": "0", "lovelace": 0}
try:
agent = _get_agent()
balance = await agent.get_balance()
wallet_info = {
"address": balance.address,
"ada": balance.ada,
"lovelace": balance.lovelace,
}
except Exception as e:
logger.warning(f"Could not fetch balance: {e}")
# Task history
tasks = storage.load_tasks()
# Compute stats
completed_tasks = [t for t in tasks if t.get("status") == "completed"]
total_earnings_dfm = sum(t.get("payment_dfm", 0) for t in completed_tasks)
tasks_completed = len(completed_tasks)
total_tokens = sum(
t.get("input_tokens", 0) + t.get("output_tokens", 0)
for t in completed_tasks
)
avg_latency_ms = 0
if completed_tasks:
latencies = [t.get("latency_ms", 0) for t in completed_tasks if t.get("latency_ms")]
avg_latency_ms = int(sum(latencies) / len(latencies)) if latencies else 0
return {
"wallet": wallet_info,
"stats": {
"total_earnings_dfm": total_earnings_dfm,
"tasks_completed": tasks_completed,
"avg_latency_ms": avg_latency_ms,
"total_tokens": total_tokens,
},
"tasks": list(reversed(tasks)), # Most recent first
"name": config.name,
}
@router.get("/api/tasks")
async def api_tasks():
"""Return task history."""
tasks = storage.load_tasks()
return {"tasks": list(reversed(tasks)), "total": len(tasks)}

33
docs/supplier/config.py Normal file
View File

@@ -0,0 +1,33 @@
"""Supplier configuration — loads .env and exposes typed settings."""
import os
from dataclasses import dataclass
from pathlib import Path
from dotenv import load_dotenv
load_dotenv(Path(__file__).parent / ".env")
@dataclass
class SupplierConfig:
ogmios_url: str
submit_url: str
supplier_mnemonic: str
escrow_script_hash: str
openrouter_api_key: str
resolver_address: str
port: int
name: str
def get_config() -> SupplierConfig:
return SupplierConfig(
ogmios_url=os.getenv("VECTOR_OGMIOS_URL", "https://ogmios.vector.testnet.apexfusion.org"),
submit_url=os.getenv("VECTOR_SUBMIT_URL", "https://submit.vector.apexfusion.org/api/submit/tx"),
supplier_mnemonic=os.getenv("SUPPLIER_MNEMONIC", ""),
escrow_script_hash=os.getenv("ESCROW_SCRIPT_HASH", ""),
openrouter_api_key=os.getenv("OPENROUTER_API_KEY", ""),
resolver_address=os.getenv("RESOLVER_ADDRESS", ""),
port=int(os.getenv("SUPPLIER_PORT", "9000")),
name=os.getenv("SUPPLIER_NAME", "default-supplier"),
)

View File

@@ -0,0 +1,257 @@
{
"preamble": {
"title": "vector/escrow",
"description": "Vector AI Compute Network — Optimistic Escrow Contract",
"version": "0.1.0",
"plutusVersion": "v3",
"compiler": {
"name": "Aiken",
"version": "v1.1.21+unknown"
},
"license": "Apache-2.0"
},
"validators": [
{
"title": "escrow.escrow.spend",
"datum": {
"title": "datum",
"schema": {
"$ref": "#/definitions/escrow~1EscrowDatum"
}
},
"redeemer": {
"title": "action",
"schema": {
"$ref": "#/definitions/escrow~1EscrowAction"
}
},
"compiledCode": "59090801010029800aba2aba1aba0aab9faab9eaab9dab9a488888896600264653001300800198041804800cdc3a400530080024888966002600460106ea800e3300130093754007370e90004dc3a4009370e90034c020dd50022444453001301100598089809002c88c966002600e0031323259800980b80140122c80a0dd7180a80098089baa0038acc004c02800626464b300130170028024590141bae30150013011375400716403c8078c03cdd5001244464b300130080058992cc004c05c006266006602c00200516405060246ea80362b3001300b005899192cc004c06000a0071640546eb8c058004c048dd5006c566002600e00b15980098091baa00d800c590134566002600c00b15980098091baa00d800c59013456600266e1d20080058992cc004c05c006266006602c00200516405060246ea80362b30013370e9005002c56600260246ea803600316404d15980099b87480300162b30013012375401b0018b20268b202040408081010202040408080566002600e60206ea800a330013014301137540052301530163016301630163016301630160019180a980b180b180b180b180b180b180b180b000c8c054c058c058c058c05800660206ea80324602a602c0032301530163016001911919800800801912cc0040062980103d87a80008992cc004c010006266e952000330180014bd7044cc00c00cc068009014180c000a02c9180a980b180b180b180b180b180b000a4444444446530012232598009809980e9baa0018992cc004c054c078dd5000c4c8c8c8c8c8c8c8c8c8ca60026058003375c6058015375a6058011375a605800f302c0069bad302c0059bad302c0049bad302c00398160012444444444b3001303600a899811181a809099912cc004c0a8006264b30013039001899812981c000801c59036181a1baa0078acc004c0b40062b30013034375400f0028b206a8b206440c826644b3001302a0018acc004c0d0dd5001c00a2c81aa2b3001302d0018acc004c0d0dd5001c00a2c81aa2b300130290018991919912cc004c0f000e00d1640e46eb4c0e4004dd7181c801181c800981a1baa0038acc004c0a00062b3001303437540070028b206a8b206440c88191032099811004880a18189baa0013031375400b1640cc302c001302b001302a001302900130280013027001302600130250013024001301f37540031640746042603c6ea80062c80e0c018c074dd519801801000c8966002602660386ea800626464660020026eb0c030c07cdd5002112cc00400629422b30013371e6eb8c08c00400e2946266004004604800280f10211bae3020301d375400314a080da64b30013015301b37540031375a603e60386ea8006290002034301e301b3754603c60366ea8c024c06cdd5009a444b300130140098992cc004cdd79806180f9baa00e4c0103d87980008acc004cdc40011bad3006301f375401d1325980099baf300d3020375400298103d87a80008acc004cdd7980618101baa0013374a90001981100125eb822b30013371e6eb8c08cc080dd50009bae30233020375401f15980099baf300a30203754002601460406ea803e2b30013370e6eb4c024c080dd50009bad30093020375401f15980099b893370066e00dd6980498101baa00f375a604660486048604860406ea803cdd698119812181218121812181218101baa00f323322330020020012259800800c00e2646644b3001337229101000028acc004cdc7a441000028800c01902444cc014014c0a80110241bae3023001375a6048002604c0028120c8c8cc004004dd5980698119baa3300901b0152259800800c00e2646644b3001337229101000028acc004cdc7a441000028800c01902544cc014014c0ac0110251bae30240013756604a002604e002812852f5bded8c02900044cc01006000a2c80f22c80f22c80f22c80f22c80f22c80f0cc01005c0462c80ea2c80e8c084c078dd500cc566002602e01315980099baf300b301e375401a98103d87a80008992cc004c054c078dd5000c56600266e20008dd69803180f9baa00e8992cc004cdd7980698101baa0013374a900219811181198101baa01b33022375000697ae08acc004cdc79bae3023302037540026eb8c08cc080dd5007c56600266ebcc028c080dd5000980518101baa00f8acc004cdd7980618101baa001300c3020375401f1330040183023302037540051640791640791640791640786600802e0231640751640746014603c6ea80362c80e22b300130130098acc004c04cc074dd51805980f1baa00d89980100b1804180f1baa00d8b20388acc004c048026264b30013014301e37540031598009980180b9804980f9baa00e8acc004cdc480119b80375a60446046603e6ea8004dd69806980f9baa00e8992cc004cdd7980698101baa0014c0103d87c80008acc004cdc79bae3023302037540026eb8c08cc080dd5007c56600266ebcc028c080dd5000980518101baa00f8acc004cdd7980618101baa001300c3020375401f14a31640791640791640791640786600802e0231640751640751640746016603c6ea80362b30013370e9004004c4c96600266ebcc030c07cdd5007260103d87c80008acc004cc00c05cc088c08cc08cc08cc08cc08cc08cc08cc08cc08cc07cdd500744c966002602c603e6ea80062b30015980099baf002300a3020375401f14a313375e004604660406ea800501e4528c5901e45901e1805980f9baa00e8b203a8b203a3021301e375403315980099b87480280262b3001330020163008301e375401b13259800980a800c4cdc41bad3006301f375401c0051598009809800c4cdc419b80375a600c603e6ea8038dd69806980f9baa00e0028a50407480e8c074dd51805980f1baa00d8b20388992cc004c050c078dd5000c4c966002602c603e6ea80062b30013300401830233020375400313371066e00dd69811981218101baa002375a601c60406ea803c00e2c80f22c80f0c02cc07cdd500745901d1805980f1baa00d407080e101c2038407080e0446464b30013014301d375400313021301e37540031640706600a6eb0c018c074dd5001919baf3021301e375400200464b30013013301c375400313020301d3754600e603a6ea8c080c074dd5000c5901b198021bac301f301c3754004466ebcc080c074dd500080122c80788b200e180400098019baa0088a4d1365640041",
"hash": "74d7f72f7ef0e2e79290ccf8d2eeda672b43379f037def441709395d"
},
{
"title": "escrow.escrow.else",
"redeemer": {
"schema": {}
},
"compiledCode": "59090801010029800aba2aba1aba0aab9faab9eaab9dab9a488888896600264653001300800198041804800cdc3a400530080024888966002600460106ea800e3300130093754007370e90004dc3a4009370e90034c020dd50022444453001301100598089809002c88c966002600e0031323259800980b80140122c80a0dd7180a80098089baa0038acc004c02800626464b300130170028024590141bae30150013011375400716403c8078c03cdd5001244464b300130080058992cc004c05c006266006602c00200516405060246ea80362b3001300b005899192cc004c06000a0071640546eb8c058004c048dd5006c566002600e00b15980098091baa00d800c590134566002600c00b15980098091baa00d800c59013456600266e1d20080058992cc004c05c006266006602c00200516405060246ea80362b30013370e9005002c56600260246ea803600316404d15980099b87480300162b30013012375401b0018b20268b202040408081010202040408080566002600e60206ea800a330013014301137540052301530163016301630163016301630160019180a980b180b180b180b180b180b180b180b000c8c054c058c058c058c05800660206ea80324602a602c0032301530163016001911919800800801912cc0040062980103d87a80008992cc004c010006266e952000330180014bd7044cc00c00cc068009014180c000a02c9180a980b180b180b180b180b180b000a4444444446530012232598009809980e9baa0018992cc004c054c078dd5000c4c8c8c8c8c8c8c8c8c8ca60026058003375c6058015375a6058011375a605800f302c0069bad302c0059bad302c0049bad302c00398160012444444444b3001303600a899811181a809099912cc004c0a8006264b30013039001899812981c000801c59036181a1baa0078acc004c0b40062b30013034375400f0028b206a8b206440c826644b3001302a0018acc004c0d0dd5001c00a2c81aa2b3001302d0018acc004c0d0dd5001c00a2c81aa2b300130290018991919912cc004c0f000e00d1640e46eb4c0e4004dd7181c801181c800981a1baa0038acc004c0a00062b3001303437540070028b206a8b206440c88191032099811004880a18189baa0013031375400b1640cc302c001302b001302a001302900130280013027001302600130250013024001301f37540031640746042603c6ea80062c80e0c018c074dd519801801000c8966002602660386ea800626464660020026eb0c030c07cdd5002112cc00400629422b30013371e6eb8c08c00400e2946266004004604800280f10211bae3020301d375400314a080da64b30013015301b37540031375a603e60386ea8006290002034301e301b3754603c60366ea8c024c06cdd5009a444b300130140098992cc004cdd79806180f9baa00e4c0103d87980008acc004cdc40011bad3006301f375401d1325980099baf300d3020375400298103d87a80008acc004cdd7980618101baa0013374a90001981100125eb822b30013371e6eb8c08cc080dd50009bae30233020375401f15980099baf300a30203754002601460406ea803e2b30013370e6eb4c024c080dd50009bad30093020375401f15980099b893370066e00dd6980498101baa00f375a604660486048604860406ea803cdd698119812181218121812181218101baa00f323322330020020012259800800c00e2646644b3001337229101000028acc004cdc7a441000028800c01902444cc014014c0a80110241bae3023001375a6048002604c0028120c8c8cc004004dd5980698119baa3300901b0152259800800c00e2646644b3001337229101000028acc004cdc7a441000028800c01902544cc014014c0ac0110251bae30240013756604a002604e002812852f5bded8c02900044cc01006000a2c80f22c80f22c80f22c80f22c80f22c80f0cc01005c0462c80ea2c80e8c084c078dd500cc566002602e01315980099baf300b301e375401a98103d87a80008992cc004c054c078dd5000c56600266e20008dd69803180f9baa00e8992cc004cdd7980698101baa0013374a900219811181198101baa01b33022375000697ae08acc004cdc79bae3023302037540026eb8c08cc080dd5007c56600266ebcc028c080dd5000980518101baa00f8acc004cdd7980618101baa001300c3020375401f1330040183023302037540051640791640791640791640786600802e0231640751640746014603c6ea80362c80e22b300130130098acc004c04cc074dd51805980f1baa00d89980100b1804180f1baa00d8b20388acc004c048026264b30013014301e37540031598009980180b9804980f9baa00e8acc004cdc480119b80375a60446046603e6ea8004dd69806980f9baa00e8992cc004cdd7980698101baa0014c0103d87c80008acc004cdc79bae3023302037540026eb8c08cc080dd5007c56600266ebcc028c080dd5000980518101baa00f8acc004cdd7980618101baa001300c3020375401f14a31640791640791640791640786600802e0231640751640751640746016603c6ea80362b30013370e9004004c4c96600266ebcc030c07cdd5007260103d87c80008acc004cc00c05cc088c08cc08cc08cc08cc08cc08cc08cc08cc08cc07cdd500744c966002602c603e6ea80062b30015980099baf002300a3020375401f14a313375e004604660406ea800501e4528c5901e45901e1805980f9baa00e8b203a8b203a3021301e375403315980099b87480280262b3001330020163008301e375401b13259800980a800c4cdc41bad3006301f375401c0051598009809800c4cdc419b80375a600c603e6ea8038dd69806980f9baa00e0028a50407480e8c074dd51805980f1baa00d8b20388992cc004c050c078dd5000c4c966002602c603e6ea80062b30013300401830233020375400313371066e00dd69811981218101baa002375a601c60406ea803c00e2c80f22c80f0c02cc07cdd500745901d1805980f1baa00d407080e101c2038407080e0446464b30013014301d375400313021301e37540031640706600a6eb0c018c074dd5001919baf3021301e375400200464b30013013301c375400313020301d3754600e603a6ea8c080c074dd5000c5901b198021bac301f301c3754004466ebcc080c074dd500080122c80788b200e180400098019baa0088a4d1365640041",
"hash": "74d7f72f7ef0e2e79290ccf8d2eeda672b43379f037def441709395d"
}
],
"definitions": {
"ByteArray": {
"dataType": "bytes"
},
"Int": {
"dataType": "integer"
},
"Option<cardano/address/Credential>": {
"title": "Option",
"anyOf": [
{
"title": "Some",
"description": "An optional value.",
"dataType": "constructor",
"index": 0,
"fields": [
{
"$ref": "#/definitions/cardano~1address~1Credential"
}
]
},
{
"title": "None",
"description": "Nothing.",
"dataType": "constructor",
"index": 1,
"fields": []
}
]
},
"aiken/crypto/ScriptHash": {
"title": "ScriptHash",
"dataType": "bytes"
},
"aiken/crypto/VerificationKeyHash": {
"title": "VerificationKeyHash",
"dataType": "bytes"
},
"cardano/address/Credential": {
"title": "Credential",
"description": "A general structure for representing an on-chain `Credential`.\n\n Credentials are always one of two kinds: a direct public/private key\n pair, or a script (native or Plutus).",
"anyOf": [
{
"title": "VerificationKey",
"dataType": "constructor",
"index": 0,
"fields": [
{
"$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash"
}
]
},
{
"title": "Script",
"dataType": "constructor",
"index": 1,
"fields": [
{
"$ref": "#/definitions/aiken~1crypto~1ScriptHash"
}
]
}
]
},
"escrow/EscrowAction": {
"title": "EscrowAction",
"anyOf": [
{
"title": "Claim",
"dataType": "constructor",
"index": 0,
"fields": [
{
"title": "supplier_cred",
"$ref": "#/definitions/cardano~1address~1Credential"
}
]
},
{
"title": "Submit",
"dataType": "constructor",
"index": 1,
"fields": [
{
"title": "result_hash",
"$ref": "#/definitions/ByteArray"
}
]
},
{
"title": "Accept",
"dataType": "constructor",
"index": 2,
"fields": []
},
{
"title": "Dispute",
"dataType": "constructor",
"index": 3,
"fields": []
},
{
"title": "Resolve",
"dataType": "constructor",
"index": 4,
"fields": [
{
"title": "winner",
"$ref": "#/definitions/cardano~1address~1Credential"
}
]
},
{
"title": "Reclaim",
"dataType": "constructor",
"index": 5,
"fields": []
},
{
"title": "Collect",
"dataType": "constructor",
"index": 6,
"fields": []
}
]
},
"escrow/EscrowDatum": {
"title": "EscrowDatum",
"anyOf": [
{
"title": "EscrowDatum",
"dataType": "constructor",
"index": 0,
"fields": [
{
"title": "task_hash",
"$ref": "#/definitions/ByteArray"
},
{
"title": "buyer",
"$ref": "#/definitions/cardano~1address~1Credential"
},
{
"title": "payment_dfm",
"$ref": "#/definitions/Int"
},
{
"title": "buyer_bond_dfm",
"$ref": "#/definitions/Int"
},
{
"title": "supplier",
"$ref": "#/definitions/Option<cardano~1address~1Credential>"
},
{
"title": "supplier_bond_dfm",
"$ref": "#/definitions/Int"
},
{
"title": "deadline_slot",
"$ref": "#/definitions/Int"
},
{
"title": "dispute_window",
"$ref": "#/definitions/Int"
},
{
"title": "state",
"$ref": "#/definitions/escrow~1EscrowState"
},
{
"title": "resolver",
"$ref": "#/definitions/cardano~1address~1Credential"
}
]
}
]
},
"escrow/EscrowState": {
"title": "EscrowState",
"anyOf": [
{
"title": "Open",
"dataType": "constructor",
"index": 0,
"fields": []
},
{
"title": "Claimed",
"dataType": "constructor",
"index": 1,
"fields": []
},
{
"title": "Submitted",
"dataType": "constructor",
"index": 2,
"fields": [
{
"title": "result_hash",
"$ref": "#/definitions/ByteArray"
},
{
"title": "submitted_slot",
"$ref": "#/definitions/Int"
}
]
},
{
"title": "Disputed",
"dataType": "constructor",
"index": 3,
"fields": []
}
]
}
}
}

174
docs/supplier/escrow_ops.py Normal file
View File

@@ -0,0 +1,174 @@
"""
Supplier-side escrow operations — verify, claim, and submit on-chain.
"""
import logging
from cbor2 import CBORTag
from pycardano import RawPlutusData, Address as PycAddress
from lib.chain import find_script_utxo, slot_to_posix_ms, wait_for_utxo
from lib.constants import BUYER_BOND, SUPPLIER_BOND
from lib.datum import build_claimed_datum, build_submitted_datum
from lib.escrow_tx import build_and_submit_script_tx
logger = logging.getLogger(__name__)
async def verify_open_utxo(context, script_address, escrow_tx):
"""Find the Open UTxO, waiting for propagation if needed.
Args:
context: Chain context.
script_address: The escrow script address.
escrow_tx: The transaction hash that created the escrow UTxO.
Returns:
The UTxO if found and valid.
Raises:
ValueError: If the UTxO is not found after waiting.
"""
# First try a quick lookup
utxo = await find_script_utxo(context, script_address, escrow_tx)
if utxo is not None:
logger.info(f"Found Open escrow UTxO: {utxo.input.transaction_id}#{utxo.input.index}")
return utxo
# Wait for propagation
logger.info(f"UTxO not found yet, waiting for propagation...")
try:
utxo = await wait_for_utxo(context, script_address, escrow_tx)
logger.info(f"Found Open escrow UTxO: {utxo.input.transaction_id}#{utxo.input.index}")
return utxo
except TimeoutError:
raise ValueError(f"Open escrow UTxO not found for tx {escrow_tx} after waiting")
async def claim_escrow(
context,
signer,
utxo,
script,
script_address,
task_hash,
buyer_vkh,
supplier_vkh,
payment_dfm,
resolver_cred,
deadline_posix_ms,
dispute_window_ms,
):
"""Build Claimed datum, Claim redeemer, and submit the Claim transaction.
Transitions the escrow from Open -> Claimed.
Returns:
str: The claim transaction hash.
"""
# Build the Claimed datum
claimed_datum = build_claimed_datum(
task_hash_hex=task_hash,
buyer_vkh=buyer_vkh,
supplier_vkh=supplier_vkh,
payment_dfm=payment_dfm,
buyer_bond=BUYER_BOND,
supplier_bond=SUPPLIER_BOND,
deadline_posix_ms=deadline_posix_ms,
dispute_window_ms=dispute_window_ms,
resolver_cred=resolver_cred,
)
# Redeemer: Claim { supplier_cred } — constructor 0 = CBORTag(121, [...])
claim_redeemer = CBORTag(121, [
CBORTag(121, [supplier_vkh]),
])
total_value = payment_dfm + BUYER_BOND + SUPPLIER_BOND + 2_000_000
claim_tx = await build_and_submit_script_tx(
context=context,
signer=signer,
utxo=utxo,
script=script,
script_address=script_address,
redeemer_obj=claim_redeemer,
output_value=total_value,
output_datum_obj=claimed_datum,
task_id=task_hash[:8],
step_name="Claim",
)
logger.info(f"Claim TX submitted: {claim_tx}")
return claim_tx
async def submit_result(
context,
signer,
utxo,
script,
script_address,
task_hash,
buyer_vkh,
supplier_vkh,
payment_dfm,
result_hash_hex,
resolver_cred,
deadline_posix_ms,
dispute_window_ms,
):
"""Build Submitted datum, Submit redeemer, and submit the Submit transaction.
Transitions the escrow from Claimed -> Submitted.
Returns:
str: The submit transaction hash.
"""
# Compute submitted_posix_ms from validity_start
current_slot = await context.async_last_block_slot()
validity_start_slot = current_slot - 1000
submitted_posix_ms = slot_to_posix_ms(context, validity_start_slot)
logger.info(
f"Submit: slot={current_slot}, validity_start={validity_start_slot}, "
f"posix_ms={submitted_posix_ms}"
)
# Build the Submitted datum
submitted_datum = build_submitted_datum(
task_hash_hex=task_hash,
buyer_vkh=buyer_vkh,
supplier_vkh=supplier_vkh,
payment_dfm=payment_dfm,
buyer_bond=BUYER_BOND,
supplier_bond=SUPPLIER_BOND,
deadline_posix_ms=deadline_posix_ms,
dispute_window_ms=dispute_window_ms,
result_hash_hex=result_hash_hex,
submitted_posix_ms=submitted_posix_ms,
resolver_cred=resolver_cred,
)
# Redeemer: Submit { result_hash } — constructor 1 = CBORTag(122, [...])
submit_redeemer = CBORTag(122, [
bytes.fromhex(result_hash_hex),
])
total_value = payment_dfm + BUYER_BOND + SUPPLIER_BOND + 2_000_000
submit_tx = await build_and_submit_script_tx(
context=context,
signer=signer,
utxo=utxo,
script=script,
script_address=script_address,
redeemer_obj=submit_redeemer,
output_value=total_value,
output_datum_obj=submitted_datum,
task_id=task_hash[:8],
step_name="Submit",
validity_start_slot=validity_start_slot,
)
logger.info(f"Submit TX submitted: {submit_tx}")
return submit_tx

View File

View File

@@ -0,0 +1,18 @@
import json
from pathlib import Path
from pycardano import PlutusV3Script, plutus_script_hash
def load_escrow_script():
"""Load the escrow PlutusV3 script from the bundled blueprint."""
# Look in supplier/contracts/escrow/plutus.json
blueprint_path = Path(__file__).resolve().parent.parent / "contracts" / "escrow" / "plutus.json"
with open(blueprint_path, "r") as f:
blueprint = json.load(f)
compiled_code = blueprint["validators"][0]["compiledCode"]
script = PlutusV3Script(bytes.fromhex(compiled_code))
script_hash = str(plutus_script_hash(script))
return script, script_hash

View File

@@ -0,0 +1,33 @@
import asyncio
from .constants import MAX_UTXO_WAIT
def slot_to_posix_ms(context, slot):
gp = context.genesis_param
return (gp.system_start + slot * gp.slot_length) * 1000
async def wait_for_utxo(context, script_address, tx_hash, output_index=0):
elapsed = 0
while elapsed < MAX_UTXO_WAIT:
utxos = await context.async_utxos(str(script_address))
for utxo in utxos:
if (
str(utxo.input.transaction_id) == str(tx_hash)
and utxo.input.index == output_index
):
return utxo
await asyncio.sleep(4)
elapsed += 4
raise TimeoutError(
f"UTxO {tx_hash}#{output_index} not found at {script_address} "
f"after {MAX_UTXO_WAIT}s"
)
async def find_script_utxo(context, script_address, tx_hash):
utxos = await context.async_utxos(str(script_address))
for utxo in utxos:
if str(utxo.input.transaction_id) == str(tx_hash):
return utxo
return None

View File

@@ -0,0 +1,6 @@
BUYER_BOND = 1_000_000
SUPPLIER_BOND = 1_000_000
DEFAULT_DEADLINE_OFFSET_MS = 1_800_000 # 30 minutes
DISPUTE_WINDOW_MS = 600_000 # 10 minutes
TX_PROPAGATION_DELAY = 12 # seconds
MAX_UTXO_WAIT = 60 # seconds

View File

@@ -0,0 +1,63 @@
from cbor2 import CBORTag
def _build_datum_fields(
task_hash_hex, buyer_vkh, supplier_vkh, payment_dfm,
buyer_bond, supplier_bond, deadline_posix_ms, dispute_window_ms,
state_field, resolver_cred,
):
if supplier_vkh is None:
supplier_field = CBORTag(122, [])
else:
supplier_field = CBORTag(121, [CBORTag(121, [supplier_vkh])])
return CBORTag(121, [
bytes.fromhex(task_hash_hex),
CBORTag(121, [buyer_vkh]),
payment_dfm,
buyer_bond,
supplier_field,
supplier_bond,
deadline_posix_ms,
dispute_window_ms,
state_field,
CBORTag(121, [resolver_cred]),
])
def build_open_datum(task_hash_hex, buyer_vkh, payment_dfm, buyer_bond,
supplier_bond, deadline_posix_ms, dispute_window_ms, resolver_cred):
state_field = CBORTag(121, [])
return _build_datum_fields(
task_hash_hex=task_hash_hex, buyer_vkh=buyer_vkh, supplier_vkh=None,
payment_dfm=payment_dfm, buyer_bond=buyer_bond, supplier_bond=supplier_bond,
deadline_posix_ms=deadline_posix_ms, dispute_window_ms=dispute_window_ms,
state_field=state_field, resolver_cred=resolver_cred,
)
def build_claimed_datum(task_hash_hex, buyer_vkh, supplier_vkh, payment_dfm,
buyer_bond, supplier_bond, deadline_posix_ms, dispute_window_ms,
resolver_cred):
state_field = CBORTag(122, [])
return _build_datum_fields(
task_hash_hex=task_hash_hex, buyer_vkh=buyer_vkh, supplier_vkh=supplier_vkh,
payment_dfm=payment_dfm, buyer_bond=buyer_bond, supplier_bond=supplier_bond,
deadline_posix_ms=deadline_posix_ms, dispute_window_ms=dispute_window_ms,
state_field=state_field, resolver_cred=resolver_cred,
)
def build_submitted_datum(task_hash_hex, buyer_vkh, supplier_vkh, payment_dfm,
buyer_bond, supplier_bond, deadline_posix_ms, dispute_window_ms,
result_hash_hex, submitted_posix_ms, resolver_cred):
state_field = CBORTag(123, [
bytes.fromhex(result_hash_hex),
submitted_posix_ms,
])
return _build_datum_fields(
task_hash_hex=task_hash_hex, buyer_vkh=buyer_vkh, supplier_vkh=supplier_vkh,
payment_dfm=payment_dfm, buyer_bond=buyer_bond, supplier_bond=supplier_bond,
deadline_posix_ms=deadline_posix_ms, dispute_window_ms=dispute_window_ms,
state_field=state_field, resolver_cred=resolver_cred,
)

View File

@@ -0,0 +1,197 @@
"""
Build, evaluate, and submit script transactions for escrow state transitions.
"""
import json
import os
import httpx
from pycardano import (
Redeemer,
Transaction,
TransactionBuilder,
TransactionOutput,
VerificationKeyWitness,
)
from pycardano.plutus import ExecutionUnits, RawPlutusData
async def build_and_submit_script_tx(
context, signer, utxo, script, script_address,
redeemer_obj, output_value, output_datum_obj,
task_id, step_name, validity_start_slot=None,
extra_outputs=None,
) -> str:
await context.async_protocol_param()
change_address = signer._wallet.payment_address
try:
tx_hash = await _build_direct(
context, signer, utxo, script, script_address,
redeemer_obj, output_value, output_datum_obj,
change_address, validity_start_slot, extra_outputs,
)
print(f"[{step_name}] TX submitted (direct): {tx_hash}")
return tx_hash
except ValueError as e:
if "collateral" not in str(e).lower():
raise
print(f"[{step_name}] Collateral check failed, falling back to Ogmios evaluate...")
tx_hash = await _build_with_ogmios_evaluate(
context, signer, utxo, script, script_address,
redeemer_obj, output_value, output_datum_obj,
change_address, validity_start_slot, step_name, extra_outputs,
)
print(f"[{step_name}] TX submitted (ogmios): {tx_hash}")
return tx_hash
def _make_builder(
context, signer, utxo, script, script_address,
redeemer_obj, output_value, output_datum_obj,
validity_start_slot, ex_units=None, skip_estimate=False,
extra_outputs=None,
):
builder = TransactionBuilder(context)
builder.add_input_address(signer._wallet.payment_address)
builder.fee_buffer = 10_000
if skip_estimate:
builder._should_estimate_execution_units = False
redeemer = Redeemer(RawPlutusData(redeemer_obj))
if ex_units:
redeemer.ex_units = ex_units
builder.add_script_input(utxo, script=script, datum=None, redeemer=redeemer)
if output_datum_obj is not None and output_value > 0:
builder.add_output(
TransactionOutput(script_address, output_value, datum=RawPlutusData(output_datum_obj))
)
# Add extra outputs (e.g. supplier payment in Accept TX)
if extra_outputs:
for out in extra_outputs:
builder.add_output(out)
builder.required_signers = [signer._wallet.payment_verification_key.hash()]
if validity_start_slot is not None:
builder.validity_start = validity_start_slot
return builder
def _sign_tx(signer, tx_body, witness_set):
sig = signer._wallet.payment_signing_key.sign(tx_body.hash())
vk_witness = VerificationKeyWitness(
signer._wallet.payment_verification_key, sig
)
witness_set.vkey_witnesses = [vk_witness]
return Transaction(tx_body, witness_set)
async def _build_direct(
context, signer, utxo, script, script_address,
redeemer_obj, output_value, output_datum_obj,
change_address, validity_start_slot, extra_outputs=None,
):
builder = _make_builder(
context, signer, utxo, script, script_address,
redeemer_obj, output_value, output_datum_obj,
validity_start_slot, extra_outputs=extra_outputs,
)
tx_body = builder.build(change_address=change_address)
witness_set = builder.build_witness_set(True)
tx = _sign_tx(signer, tx_body, witness_set)
tx_cbor = tx.to_cbor()
if isinstance(tx_cbor, bytes):
tx_cbor = tx_cbor.hex()
await context.async_submit_tx_cbor(tx_cbor)
return str(tx.id)
async def _build_with_ogmios_evaluate(
context, signer, utxo, script, script_address,
redeemer_obj, output_value, output_datum_obj,
change_address, validity_start_slot, step_name, extra_outputs=None,
):
ogmios_url = os.getenv("VECTOR_OGMIOS_URL")
if not ogmios_url:
raise RuntimeError("VECTOR_OGMIOS_URL not set")
dummy_ex_units = ExecutionUnits(500_000, 200_000_000)
builder = _make_builder(
context, signer, utxo, script, script_address,
redeemer_obj, output_value, output_datum_obj,
validity_start_slot, ex_units=dummy_ex_units, skip_estimate=True,
extra_outputs=extra_outputs,
)
orig_set = builder._set_collateral_return
def _patched(*a, **k):
try:
return orig_set(*a, **k)
except ValueError:
return
builder._set_collateral_return = _patched
tx_body = builder.build(change_address=change_address)
witness_set = builder.build_witness_set(True)
dummy_tx = _sign_tx(signer, tx_body, witness_set)
tx_cbor = dummy_tx.to_cbor()
if isinstance(tx_cbor, bytes):
tx_cbor = tx_cbor.hex()
evaluate_payload = {
"jsonrpc": "2.0",
"method": "evaluateTransaction",
"params": {"transaction": {"cbor": tx_cbor}},
"id": 1,
}
async with httpx.AsyncClient() as client:
resp = await client.post(ogmios_url, json=evaluate_payload, timeout=30)
result = resp.json()
if "error" in result:
err_data = result["error"]
traces = ""
if isinstance(err_data.get("data"), list):
for item in err_data["data"]:
if "error" in item and "data" in item["error"]:
traces = str(item["error"]["data"].get("traces", ""))
raise RuntimeError(
f"[{step_name}] Validator failed: {err_data.get('message', '')} traces={traces}"
)
budget = result["result"][0]["budget"]
real_mem = int(budget["memory"] * 1.2)
real_cpu = int(budget["cpu"] * 1.2)
print(f"[{step_name}] Ogmios ex_units: mem={real_mem}, cpu={real_cpu}")
builder2 = _make_builder(
context, signer, utxo, script, script_address,
redeemer_obj, output_value, output_datum_obj,
validity_start_slot, ex_units=ExecutionUnits(real_mem, real_cpu),
skip_estimate=True, extra_outputs=extra_outputs,
)
orig_set2 = builder2._set_collateral_return
def _patched2(*a, **k):
try:
return orig_set2(*a, **k)
except ValueError:
return
builder2._set_collateral_return = _patched2
tx_body2 = builder2.build(change_address=change_address)
witness_set2 = builder2.build_witness_set(True)
real_tx = _sign_tx(signer, tx_body2, witness_set2)
real_cbor = real_tx.to_cbor()
if isinstance(real_cbor, bytes):
real_cbor = real_cbor.hex()
await context.async_submit_tx_cbor(real_cbor)
return str(real_tx.id)

View File

@@ -0,0 +1,38 @@
from typing import List, Optional
from pydantic import BaseModel
class TaskRequest(BaseModel):
task_id: str
prompt: str
escrow_tx: str
escrow_script_hash: str
payment_dfm: int
task_hash: str
buyer_address: str
model_preference: str = "nemotron"
max_tokens: int = 2048
temperature: float = 0.7
deadline_posix_ms: int = 9_999_999_999_999
dispute_window_ms: int = 600_000
class TaskResponse(BaseModel):
task_id: str
status: str
response: str
result_hash: str
model_used: str
input_tokens: int
output_tokens: int
claim_tx: str
submit_tx: str
error: str = ""
class SupplierInfo(BaseModel):
name: str
address: str
endpoint: str
models: List[str]
min_payment_dfm: int = 1_000_000

216
docs/supplier/llm.py Normal file
View File

@@ -0,0 +1,216 @@
"""
LLM API provider — abstracts Anthropic / OpenAI / OpenRouter API calls for task fulfillment.
"""
import logging
from dataclasses import dataclass
from typing import Optional
logger = logging.getLogger(__name__)
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
@dataclass
class LLMResult:
text: str
model: str
input_tokens: int
output_tokens: int
class LLMProvider:
"""Multi-provider LLM client for supplier agents."""
def __init__(
self,
anthropic_api_key: str = "",
openai_api_key: str = "",
openrouter_api_key: str = "",
default_model: str = "nvidia/nemotron-3-super-120b-a12b",
):
self.anthropic_api_key = anthropic_api_key
self.openai_api_key = openai_api_key
self.openrouter_api_key = openrouter_api_key
self.default_model = default_model
self._anthropic_client = None
self._openai_client = None
self._openrouter_client = None
def _get_anthropic_client(self):
if self._anthropic_client is None and self.anthropic_api_key:
import anthropic
self._anthropic_client = anthropic.AsyncAnthropic(
api_key=self.anthropic_api_key
)
return self._anthropic_client
def _get_openai_client(self):
if self._openai_client is None and self.openai_api_key:
import openai
self._openai_client = openai.AsyncOpenAI(api_key=self.openai_api_key)
return self._openai_client
def _get_openrouter_client(self):
"""OpenRouter uses the OpenAI-compatible API with a different base URL."""
if self._openrouter_client is None and self.openrouter_api_key:
import openai
self._openrouter_client = openai.AsyncOpenAI(
api_key=self.openrouter_api_key,
base_url=OPENROUTER_BASE_URL,
)
return self._openrouter_client
async def complete(
self,
prompt: str,
model_preference: list[str] = None,
max_tokens: int = 2048,
temperature: float = 0.7,
) -> LLMResult:
"""Call the best available LLM based on preference list."""
model_preference = model_preference or [self.default_model, "any"]
model, provider = self._select_model(model_preference)
if provider == "anthropic":
return await self._call_anthropic(prompt, model, max_tokens, temperature)
elif provider == "openrouter":
return await self._call_openrouter(prompt, model, max_tokens, temperature)
elif provider == "openai":
return await self._call_openai(prompt, model, max_tokens, temperature)
else:
raise ValueError("No LLM API keys configured")
def _select_model(self, preferences: list[str]) -> tuple[str, str]:
"""Select the best available model and provider from the preference list.
Returns (model_id, provider)."""
# OpenRouter model IDs use vendor/model format
model_map = {
"nemotron": ("nvidia/nemotron-3-super-120b-a12b", "nemotron-3-super"),
"nemotron-super": ("nvidia/nemotron-3-super-120b-a12b", "nemotron-3-super"),
"nemotron-nano": ("nvidia/nemotron-3-nano-30b-a3b", "nemotron-3-nano"),
"claude-opus": ("anthropic/claude-opus-4-6", "claude-opus-4-6"),
"claude-sonnet": ("anthropic/claude-sonnet-4-6", "claude-sonnet-4-6"),
"claude-haiku": ("anthropic/claude-haiku-4-5-20251001", "claude-haiku-4-5-20251001"),
"gpt-4": ("openai/gpt-4", "gpt-4"),
"gpt-4o": ("openai/gpt-4o", "gpt-4o"),
}
for pref in preferences:
if pref == "any":
# Priority: OpenRouter > Anthropic > OpenAI
if self.openrouter_api_key:
or_model = self._to_openrouter_model(self.default_model)
return (or_model, "openrouter")
if self.anthropic_api_key:
return (self.default_model, "anthropic")
if self.openai_api_key:
return ("gpt-4o", "openai")
break
if pref in model_map:
or_id, native_id = model_map[pref]
# Try OpenRouter first (it supports all models)
if self.openrouter_api_key:
return (or_id, "openrouter")
# Then try native APIs
if native_id.startswith("claude") and self.anthropic_api_key:
return (native_id, "anthropic")
if native_id.startswith("gpt") and self.openai_api_key:
return (native_id, "openai")
# Last resort fallback
if self.openrouter_api_key:
return (self._to_openrouter_model(self.default_model), "openrouter")
if self.anthropic_api_key:
return (self.default_model, "anthropic")
if self.openai_api_key:
return ("gpt-4o", "openai")
raise ValueError("No LLM API keys configured")
def _to_openrouter_model(self, model: str) -> str:
"""Convert a native model name to OpenRouter format."""
if "/" in model:
return model
if model.startswith("nemotron"):
return f"nvidia/{model}"
if model.startswith("claude"):
return f"anthropic/{model}"
if model.startswith("gpt"):
return f"openai/{model}"
return model
async def _call_anthropic(
self, prompt: str, model: str, max_tokens: int, temperature: float
) -> LLMResult:
client = self._get_anthropic_client()
if not client:
raise ValueError("Anthropic API key not configured")
response = await client.messages.create(
model=model,
max_tokens=max_tokens,
temperature=temperature,
messages=[{"role": "user", "content": prompt}],
)
text = response.content[0].text if response.content else ""
return LLMResult(
text=text,
model=model,
input_tokens=response.usage.input_tokens,
output_tokens=response.usage.output_tokens,
)
async def _call_openai(
self, prompt: str, model: str, max_tokens: int, temperature: float
) -> LLMResult:
client = self._get_openai_client()
if not client:
raise ValueError("OpenAI API key not configured")
response = await client.chat.completions.create(
model=model,
max_tokens=max_tokens,
temperature=temperature,
messages=[{"role": "user", "content": prompt}],
)
choice = response.choices[0] if response.choices else None
text = choice.message.content if choice else ""
return LLMResult(
text=text,
model=model,
input_tokens=response.usage.prompt_tokens if response.usage else 0,
output_tokens=response.usage.completion_tokens if response.usage else 0,
)
async def _call_openrouter(
self, prompt: str, model: str, max_tokens: int, temperature: float
) -> LLMResult:
"""Call LLM via OpenRouter (OpenAI-compatible API)."""
client = self._get_openrouter_client()
if not client:
raise ValueError("OpenRouter API key not configured")
response = await client.chat.completions.create(
model=model,
max_tokens=max_tokens,
temperature=temperature,
messages=[{"role": "user", "content": prompt}],
extra_headers={
"HTTP-Referer": "https://vector.apexfusion.org",
"X-Title": "Vector AI Compute Network",
},
)
choice = response.choices[0] if response.choices else None
text = choice.message.content if choice else ""
return LLMResult(
text=text,
model=model,
input_tokens=response.usage.prompt_tokens if response.usage else 0,
output_tokens=response.usage.completion_tokens if response.usage else 0,
)

37
docs/supplier/main.py Normal file
View File

@@ -0,0 +1,37 @@
"""
Supplier service entry point.
Serves the API and the web dashboard.
"""
import sys
import os
# Make the supplier folder the root for imports
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from dotenv import load_dotenv
from pathlib import Path
load_dotenv(Path(__file__).parent / ".env")
import uvicorn
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from api import router
from config import get_config
app = FastAPI(title="Vector Supplier Service")
app.include_router(router)
app.mount("/static", StaticFiles(directory=str(Path(__file__).parent / "static")), name="static")
@app.get("/")
async def index():
return FileResponse(str(Path(__file__).parent / "static" / "dashboard.html"))
if __name__ == "__main__":
config = get_config()
print(f"Starting Vector Supplier Node '{config.name}' on port {config.port}")
print(f"Dashboard: http://localhost:{config.port}")
uvicorn.run(app, host="0.0.0.0", port=config.port)

View File

@@ -0,0 +1,10 @@
fastapi>=0.115.0
uvicorn>=0.32.0
pydantic>=2.0
httpx>=0.27.0
python-dotenv>=1.0.0
pycardano>=0.19.2
vector-agent>=0.1.0
cbor2>=5.6.0
openai>=1.0.0
anthropic>=0.40.0

View File

@@ -0,0 +1,552 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vector Supplier Node -- Dashboard</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: linear-gradient(135deg, #0a0b0f 0%, #12141c 100%);
color: #e0e0e0;
min-height: 100vh;
}
/* Header */
header {
display: flex; justify-content: space-between; align-items: center;
padding: 16px 32px; border-bottom: 1px solid #1e2030;
background: rgba(10,11,15,0.95); backdrop-filter: blur(12px);
position: sticky; top: 0; z-index: 100;
}
.brand { display: flex; align-items: center; gap: 12px; }
.logo {
width: 40px; height: 40px; border-radius: 10px;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
display: flex; align-items: center; justify-content: center;
font-weight: 800; font-size: 20px; color: #fff; line-height: 40px; text-align: center;
}
.brand-text h1 { font-size: 18px; font-weight: 700; color: #f0f0f0; }
.brand-text p { font-size: 12px; color: #6b7280; }
.header-right { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.wallet-address {
padding: 6px 14px; border-radius: 8px; background: #1e2030;
font-size: 13px; color: #9ca3af; font-family: 'Consolas', 'Courier New', monospace;
}
.balance-display {
padding: 6px 14px; border-radius: 8px; background: #1e2030;
font-size: 13px; color: #10b981; font-weight: 600;
font-family: 'Consolas', 'Courier New', monospace;
}
.status-indicator {
display: flex; align-items: center; gap: 8px;
padding: 6px 14px; border-radius: 8px; background: #10b98115;
border: 1px solid #10b98130; font-size: 13px; color: #10b981; font-weight: 600;
}
.status-dot {
width: 8px; height: 8px; border-radius: 50%; background: #10b981;
animation: pulse-dot 2s ease-in-out infinite;
}
@keyframes pulse-dot {
0%, 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.5); }
50% { box-shadow: 0 0 0 6px rgba(16, 185, 129, 0); }
}
/* Main */
main { padding: 24px 32px; max-width: 1400px; margin: 0 auto; }
/* Stats Cards */
.stats-grid {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px;
margin-bottom: 28px;
}
.stat-card {
background: #14161e; border-radius: 12px; border: 1px solid #1e2030;
padding: 20px 24px; transition: border-color 0.2s;
}
.stat-card:hover { border-color: #2a2d3e; }
.stat-label {
font-size: 12px; font-weight: 600; color: #6b7280;
text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px;
}
.stat-value {
font-size: 28px; font-weight: 800; color: #f0f0f0;
font-family: 'Consolas', 'Courier New', monospace;
}
.stat-value.green { color: #10b981; }
.stat-unit {
font-size: 14px; font-weight: 600; color: #6b7280; margin-left: 4px;
}
/* Section Header */
.section-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 16px;
}
.section-title { font-size: 20px; font-weight: 700; color: #f0f0f0; }
.refresh-info { font-size: 12px; color: #6b7280; }
/* Task List */
.task-list { display: flex; flex-direction: column; gap: 8px; }
.task-row {
background: #14161e; border-radius: 10px; border: 1px solid #1e2030;
cursor: pointer; transition: border-color 0.2s, background 0.2s;
overflow: hidden;
}
.task-row:hover { border-color: #2a2d3e; background: #161822; }
.task-row.expanded { border-color: #6366f140; }
.task-summary {
display: grid;
grid-template-columns: 100px 1fr 80px 100px 100px 90px 80px;
align-items: center; gap: 12px; padding: 14px 20px;
font-size: 13px;
}
.task-id {
font-family: 'Consolas', 'Courier New', monospace;
color: #6366f1; font-weight: 600; font-size: 12px;
}
.task-prompt {
color: #d1d5db; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; min-width: 0;
}
.model-badge {
padding: 3px 10px; border-radius: 6px; background: #6366f120;
color: #a78bfa; font-size: 11px; font-weight: 600;
border: 1px solid #6366f130; text-align: center;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.task-tokens {
font-family: 'Consolas', 'Courier New', monospace;
color: #9ca3af; font-size: 12px; text-align: center;
}
.task-payment {
font-family: 'Consolas', 'Courier New', monospace;
color: #10b981; font-weight: 600; text-align: right;
}
.task-latency {
font-family: 'Consolas', 'Courier New', monospace;
color: #9ca3af; font-size: 12px; text-align: center;
}
.status-badge {
padding: 3px 10px; border-radius: 6px; font-size: 11px;
font-weight: 600; text-align: center; text-transform: uppercase;
letter-spacing: 0.3px;
}
.status-badge.completed {
background: #10b98120; color: #10b981; border: 1px solid #10b98130;
}
.status-badge.failed {
background: #ef444420; color: #ef4444; border: 1px solid #ef444430;
}
.status-badge.pending {
background: #f59e0b20; color: #f59e0b; border: 1px solid #f59e0b30;
}
/* Expanded Details */
.task-details {
display: none; padding: 0 20px 20px 20px;
border-top: 1px solid #1e2030;
}
.task-row.expanded .task-details { display: block; }
.detail-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 16px;
margin-top: 16px;
}
.detail-block {
background: #1a1c28; border-radius: 8px; padding: 14px 16px;
}
.detail-block.full-width { grid-column: 1 / -1; }
.detail-label {
font-size: 11px; font-weight: 600; color: #6b7280;
text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px;
}
.detail-value {
font-size: 13px; color: #d1d5db; line-height: 1.6;
word-break: break-word; white-space: pre-wrap;
}
.detail-value.mono {
font-family: 'Consolas', 'Courier New', monospace; font-size: 12px;
color: #9ca3af;
}
.detail-value.green { color: #10b981; }
.tx-row {
display: flex; justify-content: space-between; align-items: center;
padding: 6px 0; border-bottom: 1px solid #1e203060;
}
.tx-row:last-child { border-bottom: none; }
.tx-label { font-size: 12px; color: #6b7280; min-width: 80px; }
.tx-hash {
font-family: 'Consolas', 'Courier New', monospace;
font-size: 12px; color: #a78bfa;
}
.meta-row {
display: flex; gap: 24px; margin-top: 12px; flex-wrap: wrap;
}
.meta-item { font-size: 12px; color: #6b7280; }
.meta-item span { color: #d1d5db; }
/* Column Headers */
.task-header {
display: grid;
grid-template-columns: 100px 1fr 80px 100px 100px 90px 80px;
gap: 12px; padding: 10px 20px;
font-size: 11px; font-weight: 600; color: #6b7280;
text-transform: uppercase; letter-spacing: 0.5px;
border-bottom: 1px solid #1e2030;
}
.task-header .col-right { text-align: right; }
.task-header .col-center { text-align: center; }
/* Empty State */
.empty-state {
text-align: center; padding: 60px 20px; color: #6b7280;
}
.empty-state .icon { font-size: 48px; margin-bottom: 16px; opacity: 0.3; }
.empty-state p { font-size: 14px; }
/* Loading */
.loading-overlay {
display: flex; align-items: center; justify-content: center;
padding: 40px; color: #6b7280; font-size: 14px;
}
.spinner {
width: 20px; height: 20px; border: 2px solid #1e2030;
border-top-color: #6366f1; border-radius: 50%;
animation: spin 0.8s linear infinite; margin-right: 10px;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Error Banner */
.error-banner {
display: none; padding: 12px 20px; margin-bottom: 16px;
background: #ef444415; border: 1px solid #ef444430;
border-radius: 10px; color: #ef4444; font-size: 13px;
}
/* Responsive */
@media (max-width: 1200px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 900px) {
header { flex-direction: column; gap: 12px; padding: 16px; }
main { padding: 16px; }
.task-summary {
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.task-header { display: none; }
.detail-grid { grid-template-columns: 1fr; }
}
@media (max-width: 600px) {
.stats-grid { grid-template-columns: 1fr; }
.header-right { justify-content: center; }
}
</style>
</head>
<body>
<header>
<div class="brand">
<div class="logo">S</div>
<div class="brand-text">
<h1>Vector Supplier Node</h1>
<p>AI Compute Provider</p>
</div>
</div>
<div class="header-right">
<div class="wallet-address" id="walletAddress">--</div>
<div class="balance-display" id="balanceDisplay">-- AP3X</div>
<div class="status-indicator">
<div class="status-dot"></div>
Online
</div>
</div>
</header>
<main>
<div class="error-banner" id="errorBanner"></div>
<!-- Stats -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">Total Earnings</div>
<div class="stat-value green" id="statEarnings">--<span class="stat-unit">AP3X</span></div>
</div>
<div class="stat-card">
<div class="stat-label">Tasks Completed</div>
<div class="stat-value" id="statTasks">--</div>
</div>
<div class="stat-card">
<div class="stat-label">Avg Response Time</div>
<div class="stat-value" id="statLatency">--<span class="stat-unit">s</span></div>
</div>
<div class="stat-card">
<div class="stat-label">Total Tokens Processed</div>
<div class="stat-value" id="statTokens">--</div>
</div>
</div>
<!-- Task History -->
<div class="section-header">
<div class="section-title">Task History</div>
<div class="refresh-info" id="refreshInfo">Auto-refresh in 30s</div>
</div>
<div class="task-header">
<div>Task ID</div>
<div>Prompt</div>
<div class="col-center">Model</div>
<div class="col-center">Tokens</div>
<div class="col-right">Payment</div>
<div class="col-center">Latency</div>
<div class="col-center">Status</div>
</div>
<div class="task-list" id="taskList">
<div class="loading-overlay" id="loadingOverlay">
<div class="spinner"></div>
Loading tasks...
</div>
</div>
</main>
<script>
(function() {
var refreshInterval = 30;
var countdown = refreshInterval;
var timer = null;
function truncate(str, len) {
if (!str) return '--';
return str.length > len ? str.substring(0, len) + '...' : str;
}
function truncateHash(hash) {
if (!hash) return '--';
if (hash.length <= 16) return hash;
return hash.substring(0, 8) + '...' + hash.substring(hash.length - 6);
}
function truncateAddress(addr) {
if (!addr) return '--';
if (addr.length <= 20) return addr;
return addr.substring(0, 10) + '...' + addr.substring(addr.length - 8);
}
function formatNumber(n) {
if (n === null || n === undefined) return '--';
return n.toLocaleString('en-US');
}
function formatAP3X(dfm) {
if (dfm === null || dfm === undefined) return '--';
return (dfm / 1000000).toFixed(2);
}
function timeAgo(ts) {
if (!ts) return '--';
var now = Date.now();
var then = new Date(ts).getTime();
var diff = Math.floor((now - then) / 1000);
if (diff < 0) return 'just now';
if (diff < 60) return diff + 's ago';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
return Math.floor(diff / 86400) + 'd ago';
}
function escapeHtml(str) {
if (!str) return '';
var div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
}
function toggleTask(el) {
el.classList.toggle('expanded');
}
function showError(msg) {
var banner = document.getElementById('errorBanner');
banner.textContent = msg;
banner.style.display = 'block';
}
function hideError() {
document.getElementById('errorBanner').style.display = 'none';
}
function renderDashboard(data) {
hideError();
// Wallet
if (data.wallet) {
document.getElementById('walletAddress').textContent =
truncateAddress(data.wallet.address);
var ada = data.wallet.ada !== undefined ? parseFloat(data.wallet.ada) :
(data.wallet.lovelace !== undefined ? (data.wallet.lovelace / 1000000) : null);
document.getElementById('balanceDisplay').textContent =
ada !== null ? ada.toFixed(2) + ' AP3X' : '-- AP3X';
}
// Stats
if (data.stats) {
var s = data.stats;
document.getElementById('statEarnings').innerHTML =
formatAP3X(s.total_earnings_dfm) + '<span class="stat-unit">AP3X</span>';
document.getElementById('statTasks').textContent =
formatNumber(s.tasks_completed);
document.getElementById('statLatency').innerHTML =
(s.avg_latency_ms !== null && s.avg_latency_ms !== undefined
? (s.avg_latency_ms / 1000).toFixed(2)
: '--') + '<span class="stat-unit">s</span>';
document.getElementById('statTokens').textContent =
formatNumber(s.total_tokens);
}
// Tasks
var container = document.getElementById('taskList');
var tasks = data.tasks || [];
if (tasks.length === 0) {
container.innerHTML =
'<div class="empty-state">' +
'<div class="icon">[ ]</div>' +
'<p>No tasks yet. Waiting for inference requests...</p>' +
'</div>';
return;
}
var html = '';
for (var i = 0; i < tasks.length; i++) {
var t = tasks[i];
var statusClass = (t.status || 'completed').toLowerCase();
var inputTok = t.input_tokens || 0;
var outputTok = t.output_tokens || 0;
html += '<div class="task-row" onclick="this.classList.toggle(\'expanded\')">';
// Summary row
html += '<div class="task-summary">';
html += '<div class="task-id">' + escapeHtml(truncate(t.task_id || t.id || '--', 10)) + '</div>';
html += '<div class="task-prompt">' + escapeHtml(truncate(t.prompt, 80)) + '</div>';
html += '<div><span class="model-badge">' + escapeHtml(t.model || '--') + '</span></div>';
html += '<div class="task-tokens">' + formatNumber(inputTok) + ' / ' + formatNumber(outputTok) + '</div>';
html += '<div class="task-payment">' + formatAP3X(t.payment_dfm) + ' AP3X</div>';
html += '<div class="task-latency">' +
(t.latency_ms !== null && t.latency_ms !== undefined
? (t.latency_ms / 1000).toFixed(2) + 's'
: '--') + '</div>';
html += '<div><span class="status-badge ' + statusClass + '">' +
escapeHtml(t.status || 'completed') + '</span></div>';
html += '</div>';
// Expanded details
html += '<div class="task-details">';
html += '<div class="detail-grid">';
// Full prompt
html += '<div class="detail-block full-width">';
html += '<div class="detail-label">Full Prompt</div>';
html += '<div class="detail-value">' + escapeHtml(t.prompt || '--') + '</div>';
html += '</div>';
// Full response
html += '<div class="detail-block full-width">';
html += '<div class="detail-label">Full Response</div>';
html += '<div class="detail-value">' + escapeHtml(t.response || '--') + '</div>';
html += '</div>';
// Transactions
html += '<div class="detail-block">';
html += '<div class="detail-label">Transactions</div>';
html += '<div class="detail-value mono">';
html += '<div class="tx-row"><span class="tx-label">Escrow TX</span><span class="tx-hash">' +
escapeHtml(t.escrow_tx || '--') + '</span></div>';
html += '<div class="tx-row"><span class="tx-label">Submit TX</span><span class="tx-hash">' +
escapeHtml(t.submit_tx || '--') + '</span></div>';
html += '<div class="tx-row"><span class="tx-label">Claim TX</span><span class="tx-hash">' +
escapeHtml(t.claim_tx || '--') + '</span></div>';
html += '</div></div>';
// Meta
html += '<div class="detail-block">';
html += '<div class="detail-label">Details</div>';
html += '<div class="detail-value">';
html += '<div class="tx-row"><span class="tx-label">Task ID</span><span class="tx-hash">' +
escapeHtml(t.task_id || t.id || '--') + '</span></div>';
html += '<div class="tx-row"><span class="tx-label">Buyer</span><span class="tx-hash">' +
escapeHtml(t.buyer_address ? truncateAddress(t.buyer_address) : '--') + '</span></div>';
html += '<div class="tx-row"><span class="tx-label">Timestamp</span><span style="color:#d1d5db;font-size:12px">' +
timeAgo(t.timestamp || t.created_at) + '</span></div>';
html += '<div class="tx-row"><span class="tx-label">Payment</span><span class="tx-hash" style="color:#10b981">' +
formatAP3X(t.payment_dfm) + ' AP3X</span></div>';
html += '<div class="tx-row"><span class="tx-label">Tokens</span><span style="color:#d1d5db;font-size:12px">' +
formatNumber(inputTok) + ' in / ' + formatNumber(outputTok) + ' out</span></div>';
html += '</div></div>';
html += '</div>'; // detail-grid
html += '</div>'; // task-details
html += '</div>'; // task-row
}
container.innerHTML = html;
}
function fetchDashboard() {
var xhr = new XMLHttpRequest();
xhr.open('GET', '/api/dashboard', true);
xhr.timeout = 10000;
xhr.onload = function() {
document.getElementById('loadingOverlay').style.display = 'none';
if (xhr.status >= 200 && xhr.status < 300) {
try {
var data = JSON.parse(xhr.responseText);
renderDashboard(data);
} catch (e) {
showError('Failed to parse dashboard data: ' + e.message);
}
} else {
showError('API returned status ' + xhr.status);
}
};
xhr.onerror = function() {
document.getElementById('loadingOverlay').style.display = 'none';
showError('Network error -- could not reach /api/dashboard');
};
xhr.ontimeout = function() {
document.getElementById('loadingOverlay').style.display = 'none';
showError('Request timed out');
};
xhr.send();
}
function startCountdown() {
countdown = refreshInterval;
if (timer) clearInterval(timer);
timer = setInterval(function() {
countdown--;
document.getElementById('refreshInfo').textContent =
'Auto-refresh in ' + countdown + 's';
if (countdown <= 0) {
countdown = refreshInterval;
fetchDashboard();
}
}, 1000);
}
// Initial load
fetchDashboard();
startCountdown();
})();
</script>
</body>
</html>

51
docs/supplier/storage.py Normal file
View File

@@ -0,0 +1,51 @@
"""Simple JSON file storage for supplier task history."""
import json
from pathlib import Path
from typing import List, Optional
DATA_DIR = Path(__file__).parent / "data"
TASKS_FILE = DATA_DIR / "tasks.json"
def _ensure_data_dir():
DATA_DIR.mkdir(parents=True, exist_ok=True)
if not TASKS_FILE.exists():
TASKS_FILE.write_text("[]", encoding="utf-8")
def save_task(task_data: dict):
"""Append a task record to tasks.json."""
_ensure_data_dir()
tasks = load_tasks()
tasks.append(task_data)
TASKS_FILE.write_text(json.dumps(tasks, indent=2), encoding="utf-8")
def load_tasks() -> List[dict]:
"""Read all tasks from tasks.json."""
_ensure_data_dir()
try:
raw = TASKS_FILE.read_text(encoding="utf-8")
return json.loads(raw)
except (json.JSONDecodeError, FileNotFoundError):
return []
def get_task(task_id: str) -> Optional[dict]:
"""Find a task by its task_id."""
tasks = load_tasks()
for t in tasks:
if t.get("task_id") == task_id:
return t
return None
def update_task(task_id: str, updates: dict):
"""Update fields of an existing task by task_id."""
_ensure_data_dir()
tasks = load_tasks()
for i, t in enumerate(tasks):
if t.get("task_id") == task_id:
tasks[i].update(updates)
break
TASKS_FILE.write_text(json.dumps(tasks, indent=2), encoding="utf-8")

View File

@@ -0,0 +1,161 @@
"""
Task runner — orchestrates the full supplier flow from request to on-chain settlement.
"""
import asyncio
import hashlib
import logging
import time
from pycardano import Address as PycAddress
from pycardano.hash import ScriptHash
from lib.blueprint import load_escrow_script
from lib.chain import wait_for_utxo
from lib.constants import TX_PROPAGATION_DELAY
from lib.protocol import TaskRequest, TaskResponse
from escrow_ops import verify_open_utxo, claim_escrow, submit_result
from llm import LLMProvider
from config import SupplierConfig
logger = logging.getLogger(__name__)
async def run_task(
task_request: TaskRequest,
supplier_agent,
llm_provider: LLMProvider,
config: SupplierConfig,
) -> TaskResponse:
"""Execute the full supplier workflow for a task request.
Steps:
1. Load script from blueprint
2. Verify the Open UTxO on-chain
3. Call LLM
4. Claim escrow (Open -> Claimed)
5. Wait for claim UTxO (sleep TX_PROPAGATION_DELAY, then wait_for_utxo)
6. Submit result hash (Claimed -> Submitted)
7. Return TaskResponse with all TX hashes
"""
task_id = task_request.task_id
prompt = task_request.prompt
# Compute hashes
task_hash = hashlib.sha256(f"{task_id}:{prompt}".encode()).hexdigest()
try:
# 1. Load script from blueprint
script, script_hash_str = load_escrow_script()
script_hash_val = ScriptHash(bytes.fromhex(script_hash_str))
script_address = PycAddress(script_hash_val, network=supplier_agent.context.network)
script_addr_str = str(script_address)
logger.info(f"[{task_id}] Script address: {script_addr_str}")
# Extract credentials
supplier_vkh = supplier_agent._wallet.payment_verification_key.hash().payload
buyer_addr = PycAddress.from_primitive(task_request.buyer_address)
buyer_vkh = buyer_addr.payment_part.payload
resolver_addr = PycAddress.from_primitive(config.resolver_address)
resolver_cred = resolver_addr.payment_part.payload
# 2. Verify the Open UTxO on-chain
logger.info(f"[{task_id}] Verifying Open escrow UTxO...")
utxo = await verify_open_utxo(
supplier_agent.context, script_addr_str, task_request.escrow_tx
)
# 3. Call LLM
logger.info(f"[{task_id}] Calling LLM...")
start_time = time.time()
llm_result = await llm_provider.complete(
prompt=prompt,
model_preference=[task_request.model_preference, "any"],
max_tokens=task_request.max_tokens,
temperature=task_request.temperature,
)
latency_ms = int((time.time() - start_time) * 1000)
logger.info(
f"[{task_id}] LLM response: model={llm_result.model}, "
f"tokens={llm_result.input_tokens}+{llm_result.output_tokens}, "
f"latency={latency_ms}ms"
)
response_text = llm_result.text
result_hash = hashlib.sha256(f"{task_id}{response_text}".encode()).hexdigest()
# 4. Claim escrow (Open -> Claimed)
logger.info(f"[{task_id}] Claiming escrow...")
claim_tx = await claim_escrow(
context=supplier_agent.context,
signer=supplier_agent,
utxo=utxo,
script=script,
script_address=script_address,
task_hash=task_hash,
buyer_vkh=buyer_vkh,
supplier_vkh=supplier_vkh,
payment_dfm=task_request.payment_dfm,
resolver_cred=resolver_cred,
deadline_posix_ms=task_request.deadline_posix_ms,
dispute_window_ms=task_request.dispute_window_ms,
)
logger.info(f"[{task_id}] Claim TX: {claim_tx}")
# 5. Wait for claim UTxO
logger.info(f"[{task_id}] Waiting for claim UTxO ({TX_PROPAGATION_DELAY}s)...")
await asyncio.sleep(TX_PROPAGATION_DELAY)
claimed_utxo = await wait_for_utxo(
supplier_agent.context, script_addr_str, claim_tx
)
logger.info(f"[{task_id}] Claim UTxO confirmed")
# 6. Submit result hash (Claimed -> Submitted)
logger.info(f"[{task_id}] Submitting result hash...")
submit_tx = await submit_result(
context=supplier_agent.context,
signer=supplier_agent,
utxo=claimed_utxo,
script=script,
script_address=script_address,
task_hash=task_hash,
buyer_vkh=buyer_vkh,
supplier_vkh=supplier_vkh,
payment_dfm=task_request.payment_dfm,
result_hash_hex=result_hash,
resolver_cred=resolver_cred,
deadline_posix_ms=task_request.deadline_posix_ms,
dispute_window_ms=task_request.dispute_window_ms,
)
logger.info(f"[{task_id}] Submit TX: {submit_tx}")
# 7. Return TaskResponse
return TaskResponse(
task_id=task_id,
status="completed",
response=response_text,
result_hash=result_hash,
model_used=llm_result.model,
input_tokens=llm_result.input_tokens,
output_tokens=llm_result.output_tokens,
claim_tx=claim_tx,
submit_tx=submit_tx,
)
except Exception as e:
logger.error(f"[{task_id}] Task failed: {e}", exc_info=True)
return TaskResponse(
task_id=task_id,
status="failed",
response="",
result_hash="",
model_used="",
input_tokens=0,
output_tokens=0,
claim_tx="",
submit_tx="",
error=str(e),
)

14
docs/supplier/wallet.py Normal file
View File

@@ -0,0 +1,14 @@
"""Supplier wallet -- initializes VectorAgent from mnemonic."""
from vector_agent import VectorAgent
from config import get_config
def get_supplier_agent() -> VectorAgent:
"""Create and return a VectorAgent from the configured supplier mnemonic."""
config = get_config()
return VectorAgent(
mnemonic=config.supplier_mnemonic,
ogmios_url=config.ogmios_url,
submit_url=config.submit_url,
)