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:
314
docs/ARCHITECTURE.md
Normal file
314
docs/ARCHITECTURE.md
Normal 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
412
docs/EXECUTIVE_SUMMARY.md
Normal 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
BIN
docs/EXECUTIVE_SUMMARY.pdf
Normal file
Binary file not shown.
BIN
docs/EXECUTIVE_SUMMARY.pptx
Normal file
BIN
docs/EXECUTIVE_SUMMARY.pptx
Normal file
Binary file not shown.
78
docs/build-deck.sh
Executable file
78
docs/build-deck.sh
Executable 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 ``;
|
||||
});
|
||||
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
13
docs/buyer/.env.example
Normal 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
220
docs/buyer/README.md
Normal 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
0
docs/buyer/__init__.py
Normal file
88
docs/buyer/api.py
Normal file
88
docs/buyer/api.py
Normal 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
35
docs/buyer/config.py
Normal 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")),
|
||||
)
|
||||
257
docs/buyer/contracts/escrow/plutus.json
Normal file
257
docs/buyer/contracts/escrow/plutus.json
Normal 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
29
docs/buyer/discovery.py
Normal 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
140
docs/buyer/escrow_ops.py
Normal 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
|
||||
1
docs/buyer/lib/__init__.py
Normal file
1
docs/buyer/lib/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
18
docs/buyer/lib/blueprint.py
Normal file
18
docs/buyer/lib/blueprint.py
Normal 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
33
docs/buyer/lib/chain.py
Normal 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
|
||||
6
docs/buyer/lib/constants.py
Normal file
6
docs/buyer/lib/constants.py
Normal 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
63
docs/buyer/lib/datum.py
Normal 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
197
docs/buyer/lib/escrow_tx.py
Normal 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)
|
||||
38
docs/buyer/lib/protocol.py
Normal file
38
docs/buyer/lib/protocol.py
Normal 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
37
docs/buyer/main.py
Normal 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)
|
||||
9
docs/buyer/requirements.txt
Normal file
9
docs/buyer/requirements.txt
Normal 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
502
docs/buyer/static/app.html
Normal 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
51
docs/buyer/storage.py
Normal 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")
|
||||
44
docs/buyer/supplier_client.py
Normal file
44
docs/buyer/supplier_client.py
Normal 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
167
docs/buyer/task_manager.py
Normal 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
15
docs/buyer/wallet.py
Normal 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
1
docs/img/.blocks.json
Normal 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
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
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
BIN
docs/img/diagram-003.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
412
docs/local-llm-marketplace.md
Normal file
412
docs/local-llm-marketplace.md
Normal 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/local-llm-marketplace.pdf
Normal file
BIN
docs/local-llm-marketplace.pdf
Normal file
Binary file not shown.
BIN
docs/local-llm-marketplace.pptx
Normal file
BIN
docs/local-llm-marketplace.pptx
Normal file
Binary file not shown.
3
docs/puppeteer.json
Normal file
3
docs/puppeteer.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"args": ["--no-sandbox", "--disable-setuid-sandbox"]
|
||||
}
|
||||
14
docs/supplier/.env.example
Normal file
14
docs/supplier/.env.example
Normal 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
185
docs/supplier/README.md
Normal 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.
|
||||
0
docs/supplier/__init__.py
Normal file
0
docs/supplier/__init__.py
Normal file
186
docs/supplier/api.py
Normal file
186
docs/supplier/api.py
Normal 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
33
docs/supplier/config.py
Normal 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"),
|
||||
)
|
||||
257
docs/supplier/contracts/escrow/plutus.json
Normal file
257
docs/supplier/contracts/escrow/plutus.json
Normal 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
174
docs/supplier/escrow_ops.py
Normal 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
|
||||
0
docs/supplier/lib/__init__.py
Normal file
0
docs/supplier/lib/__init__.py
Normal file
18
docs/supplier/lib/blueprint.py
Normal file
18
docs/supplier/lib/blueprint.py
Normal 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/supplier/lib/chain.py
Normal file
33
docs/supplier/lib/chain.py
Normal 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
|
||||
6
docs/supplier/lib/constants.py
Normal file
6
docs/supplier/lib/constants.py
Normal 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/supplier/lib/datum.py
Normal file
63
docs/supplier/lib/datum.py
Normal 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/supplier/lib/escrow_tx.py
Normal file
197
docs/supplier/lib/escrow_tx.py
Normal 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)
|
||||
38
docs/supplier/lib/protocol.py
Normal file
38
docs/supplier/lib/protocol.py
Normal 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
216
docs/supplier/llm.py
Normal 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
37
docs/supplier/main.py
Normal 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)
|
||||
10
docs/supplier/requirements.txt
Normal file
10
docs/supplier/requirements.txt
Normal 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
|
||||
552
docs/supplier/static/dashboard.html
Normal file
552
docs/supplier/static/dashboard.html
Normal 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
51
docs/supplier/storage.py
Normal 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")
|
||||
161
docs/supplier/task_runner.py
Normal file
161
docs/supplier/task_runner.py
Normal 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
14
docs/supplier/wallet.py
Normal 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,
|
||||
)
|
||||
Reference in New Issue
Block a user