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

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

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

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

66
.dockerignore Normal file
View File

@@ -0,0 +1,66 @@
# Top-level .dockerignore — applies to every `docker build` invoked with
# the repo root as the context (which is what all three service Dockerfiles
# use, so they can resolve the workspace via pnpm).
#
# We keep this conservative on purpose: each service Dockerfile is explicit
# about which directories it COPYs in (packages/shared + its own dir), so we
# don't strictly need to exclude every other service's source. But excluding
# build artifacts and secrets at the context layer makes builds faster and
# safer.
# Dependencies — installed inside builder stage, not from host
**/node_modules
# Compiled / built artifacts (Vite + tsc) — rebuilt in builder
**/dist
**/build
**/.vite
**/.vitest-cache
**/*.tsbuildinfo
# Logs
**/*.log
logs/
# Environment files (NEVER ship local .env into images)
.env
.env.*
!.env.example
**/.env
**/.env.*
!**/.env.example
# Local data dirs (SQLite cache, etc.) — handled via named volumes
data/
indexer/data/
# Editor / OS noise
.DS_Store
.idea/
.vscode/
*.swp
# Git metadata
.git
.gitignore
.gitattributes
# CI / scripts not needed for runtime
.github/
coverage/
# Tests — not needed in production images
**/tests
**/*.test.ts
**/*.test.tsx
**/*.spec.ts
**/*.spec.tsx
# Docs — not needed in production images
docs/
README.md
deploy/
# Contracts (Aiken/Plutus build outputs)
contracts/build/
contracts/.aiken/

35
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,35 @@
name: CI
on:
push:
branches: ["**"]
pull_request:
branches: ["**"]
jobs:
build-and-test:
name: typecheck + vitest (Node 20)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.33.2
- name: Setup Node.js 20
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Typecheck (all workspaces)
run: pnpm -r typecheck
- name: Vitest
run: pnpm test

28
.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# Dependencies / build artifacts
node_modules
dist
*.tsbuildinfo
coverage
# Environments — never commit; .env.example is the published template
.env
.env.*
!.env.example
# Local data / caches
data/
*.log
.vitest-cache
.DS_Store
# Claude Code local settings — per-developer, not for shared repo
.claude/
# Deck build intermediates (recreated by docs/build-deck.sh)
docs/EXECUTIVE_SUMMARY.rendered.md
docs/img/*.mmd
# Reference-only archives copied from other projects — these can carry
# secrets inside their entries that .gitignore can't strip from a zip.
# Don't republish them.
docs/*.zip

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
approve-builds=true

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
20

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
## Local Agents Marketplace
Local Agents Marketplace MVP. See `docs/ARCHITECTURE.md` for the architecture spec. Monorepo managed with pnpm workspaces. Work-in-progress; not production-ready.

15
buyer/.dockerignore Normal file
View File

@@ -0,0 +1,15 @@
# buyer/.dockerignore — defence-in-depth (top-level .dockerignore is primary).
node_modules
dist
*.tsbuildinfo
.vitest-cache
.env
.env.*
!.env.example
*.log
.DS_Store
.git
tests/
**/*.test.ts
**/*.test.tsx

32
buyer/.env.example Normal file
View File

@@ -0,0 +1,32 @@
# buyer/.env.example
#
# Copy to buyer/.env and fill in. .env is gitignored — never commit.
#
# To generate a fresh Ed25519 wallet seed:
# openssl rand -hex 32
# Fund the resulting address with enough AP3X to cover prompt payment +
# bond + tx fees before posting any escrow.
# ─── Required ─────────────────────────────────────────────────────────────
# 64-char hex Ed25519 private key seed for the buyer wallet. SECRET.
BUYER_PRIV_KEY_HEX=PASTE_64_HEX_CHARS_HERE
# Indexer base URL. Inside compose this is the service name.
INDEXER_URL=http://indexer:8090
# ─── Optional ─────────────────────────────────────────────────────────────
# HTTP listen port (default 3002 in buyer/src/config.ts; we override to
# 8070 in compose to free up the 3xxx range for local dev tooling).
BUYER_PORT=8070
# Cardano network ID. "0" = testnet, "1" = mainnet. (Note: this matches
# the supplier convention, NOT indexer's "testnet"/"mainnet" string.
# See ARCHITECTURE.md §9 follow-up — naming drift to be reconciled.)
NETWORK_ID=0
# ─── Reserved for M1-F ────────────────────────────────────────────────────
# INDEXER_TIMEOUT_MS is referenced in deploy/README.md but is NOT yet read
# by buyer/src/config.ts. M1-F should add it; see follow-ups.
# INDEXER_TIMEOUT_MS=15000

88
buyer/Dockerfile Normal file
View File

@@ -0,0 +1,88 @@
# syntax=docker/dockerfile:1.7
#
# buyer/Dockerfile — multi-stage image for the buyer Express + React app.
#
# Two artifacts produced:
# 1. Vite-built static UI bundle at buyer/dist/{index.html, assets/...}
# 2. tsx-runnable Express server at buyer/src/index.ts
#
# The Express server (buyer/src/index.ts:78) reads
# resolve(process.cwd(), "buyer/dist")
# at boot and serves that directory, so we set the runtime WORKDIR to /repo
# (the repo root, since the path is "buyer/dist" not "./dist") and ensure the
# Vite output lands there.
#
# We do NOT run `tsc` for the server because @marketplace/shared exports .ts
# files directly via package.json#exports — the tsx loader handles them. The
# buyer's tsconfig.json#outDir of "./dist" would in fact COLLIDE with the
# Vite outDir if both were run, since vite.config.ts has emptyOutDir:true and
# would wipe any tsc-emitted index.js. tsx-only runtime avoids this trap.
#
# Build:
# docker build -f buyer/Dockerfile -t marketplace-buyer .
ARG NODE_VERSION=20.19.0
# ─── builder ──────────────────────────────────────────────────────────────
FROM node:${NODE_VERSION}-alpine AS builder
WORKDIR /repo
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json .npmrc ./
COPY packages/shared/package.json ./packages/shared/package.json
COPY buyer/package.json ./buyer/package.json
COPY supplier/package.json ./supplier/package.json
COPY indexer/package.json ./indexer/package.json
# Buyer needs vite + tailwind + react devDeps to build the UI bundle, so we
# must install with dev deps included (default).
RUN pnpm install --frozen-lockfile --filter '@marketplace/buyer...'
# libsodium-wrappers-sumo (transitive via @lucid-evolution/lucid → CML) does
# `import "./libsodium-sumo.mjs"` from its wrapper, but pnpm's strict store
# layout puts the .mjs in a sibling pkg, breaking the relative import. Patch
# by copying the file into the wrapper's expected path. Same fix as the
# supplier Dockerfile — required now that the buyer's /v1/accept endpoint
# goes through buildAcceptTx → buildLiveTxForAccept → lucid.
RUN set -e \
&& SRC=$(find node_modules/.pnpm -name "libsodium-sumo.mjs" -path "*/libsodium-sumo/dist/*" 2>/dev/null | head -1) \
&& DST=$(find node_modules/.pnpm -type d -path "*libsodium-wrappers-sumo@*/node_modules/libsodium-wrappers-sumo/dist/modules-sumo-esm" 2>/dev/null | head -1) \
&& [ -n "$SRC" ] && [ -n "$DST" ] || (echo "FATAL: libsodium-sumo paths not resolved" && exit 1) \
&& cp "$SRC" "$DST/" \
&& echo "patched libsodium wrapper: $DST/libsodium-sumo.mjs"
COPY packages/shared ./packages/shared
COPY buyer ./buyer
# loadBlueprint at runtime reads contracts/marketplace/plutus.json to derive
# the escrow script address + attach the spending validator script in
# buildLiveTxForAccept. Same as the supplier image.
COPY contracts/marketplace/plutus.json ./contracts/marketplace/plutus.json
# Build the static UI into buyer/dist (Vite outDir is buyer/dist;
# emptyOutDir:true is fine here because we run this exactly once).
RUN pnpm --filter @marketplace/buyer build:ui
# ─── runtime ──────────────────────────────────────────────────────────────
FROM node:${NODE_VERSION}-alpine AS runtime
WORKDIR /repo
RUN apk add --no-cache tini
# Copy the entire workspace incl. node_modules. This includes the buyer/dist
# Vite bundle that the Express server will static-serve.
COPY --from=builder /repo /repo
RUN chown -R node:node /repo
USER node
ENV NODE_ENV=production
ENV BUYER_PORT=8070
EXPOSE 8070
# Buyer health endpoint is /healthz (see buyer/src/server.ts:26).
HEALTHCHECK --interval=20s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- http://127.0.0.1:${BUYER_PORT}/healthz >/dev/null || exit 1
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node_modules/.bin/tsx", "buyer/src/index.ts"]

37
buyer/package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "@marketplace/buyer",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit",
"build:ui": "vite build --config vite.config.ts",
"dev:ui": "vite --config vite.config.ts",
"start": "node --enable-source-maps dist/index.js",
"tx:accept": "tsx src/cli/accept.ts"
},
"dependencies": {
"@marketplace/shared": "workspace:*",
"@noble/ed25519": "^2.3.0",
"@noble/hashes": "^1.8.0",
"bech32": "^2.0.0",
"express": "^4.21.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.4.0",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.2",
"@types/express": "^5.0.3",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"happy-dom": "^15.7.4",
"supertest": "^7.0.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.7.2",
"vite": "^5.4.0"
}
}

8
buyer/postcss.config.js Normal file
View File

@@ -0,0 +1,8 @@
/**
* buyer/postcss.config.js — Tailwind / Autoprefixer pipeline.
*/
export default {
plugins: {
tailwindcss: {},
},
};

30
buyer/src/chain.ts Normal file
View File

@@ -0,0 +1,30 @@
/**
* buyer/src/chain.ts — ChainProvider construction for the buyer boot path.
*
* Mirrors supplier/src/chain.ts: returns LiveOgmiosProvider when
* config.liveChain === true, otherwise ReadOnlyOgmiosProvider. The buyer
* needs a Live provider to call buildAcceptTx / runAccept (both submit txs);
* ReadOnly is the safe default for any UI that only does indexer reads.
*/
import {
LiveOgmiosProvider,
ReadOnlyOgmiosProvider,
type ChainProvider,
} from "@marketplace/shared/chain";
import type { BuyerConfig } from "./config.js";
export function buildChainProvider(
config: BuyerConfig,
fetchFn?: typeof globalThis.fetch,
): ChainProvider {
const opts = { ogmiosUrl: config.ogmiosUrl, fetch: fetchFn };
if (config.liveChain) {
// eslint-disable-next-line no-console
console.log("[buyer] chain: LiveOgmiosProvider selected (LIVE_CHAIN=1)");
return new LiveOgmiosProvider(opts);
}
// eslint-disable-next-line no-console
console.log("[buyer] chain: ReadOnlyOgmiosProvider selected (LIVE_CHAIN unset)");
return new ReadOnlyOgmiosProvider({ ogmiosUrl: config.ogmiosUrl });
}

323
buyer/src/cli/accept.ts Normal file
View File

@@ -0,0 +1,323 @@
/**
* accept.ts — CLI entry point for `pnpm --filter @marketplace/buyer tx:accept`.
*
* Closes the M1-F demoable lifecycle: posts an Accept tx (Submitted → Accepted,
* terminal) for an on-chain escrow that the supplier has already submitted a
* receipt for. Distributes payment + supplier_bond to the supplier and returns
* buyer_bond to the buyer.
*
* Exports:
* parseCliArgs(argv, env) — pure argument parser, testable without spawning.
* main(argv, env) — wires LiveOgmiosProvider, calls runAccept,
* writes tx hash to stdout / progress to stderr.
*
* Argument parsing uses Node stdlib only (no commander / yargs). Unknown
* flags throw. Mirrors supplier/src/cli/post-advert.ts so the operational
* surface is symmetric.
*/
import { createHash } from "crypto";
import * as ed from "@noble/ed25519";
import { blake2b } from "@noble/hashes/blake2b";
import { bech32 } from "bech32";
import {
LiveOgmiosProvider,
MockChainProvider,
type ChainProvider,
type OutputReference,
} from "@marketplace/shared/chain";
import type { WalletKey } from "@marketplace/shared/tx";
import { runAccept } from "./acceptFlow.js";
// Wire ed25519 sha512 hook (idempotent — matches buyer/src/index.ts).
ed.etc.sha512Sync = (...messages: Uint8Array[]): Uint8Array => {
const h = createHash("sha512");
for (const m of messages) h.update(m);
return new Uint8Array(h.digest());
};
// ─── Types ───────────────────────────────────────────────────────────────
export type NetworkId = 0 | 1;
export interface CliConfig {
ogmiosUrl: string;
privKeyHex: string;
networkId: NetworkId;
escrowRef: OutputReference;
awaitTimeoutMs: number;
dryRun: boolean;
useMock: boolean;
}
// ─── Constants ───────────────────────────────────────────────────────────
const HEX64_RE = /^[0-9a-f]{64}$/;
const ESCROW_REF_RE = /^([0-9a-f]{64})#(\d+)$/;
const TESTNET_OGMIOS_DEFAULT = "https://ogmios.vector.testnet.apexfusion.org";
const DEFAULT_AWAIT_TIMEOUT_MS = 120_000;
const VALUE_FLAGS = new Set<string>([
"--ogmios-url",
"--priv-key",
"--escrow-ref",
"--await-timeout-ms",
]);
const BOOL_FLAGS = new Set<string>(["--dry-run", "--mock"]);
// ─── Helpers ─────────────────────────────────────────────────────────────
function requireEnv(env: Record<string, string | undefined>, name: string): string {
const v = env[name];
if (v === undefined || v === null || v === "") {
throw new Error(`missing required env var: ${name}`);
}
return v;
}
function parseIntStrict(name: string, raw: string): number {
if (!/^-?\d+$/.test(raw)) {
throw new Error(`${name} must be an integer (got: ${raw})`);
}
const n = Number(raw);
if (!Number.isFinite(n) || !Number.isInteger(n)) {
throw new Error(`${name} must be an integer (got: ${raw})`);
}
return n;
}
function parseEscrowRef(raw: string): OutputReference {
const m = ESCROW_REF_RE.exec(raw);
if (!m) {
throw new Error(
`--escrow-ref / ESCROW_REF must match <64-hex-txhash>#<index> (got: ${raw})`,
);
}
const idx = Number(m[2]);
if (!Number.isInteger(idx) || idx < 0) {
throw new Error(`--escrow-ref index must be a non-negative integer (got: ${m[2]})`);
}
return { txHash: m[1], index: idx };
}
interface RawFlags {
ogmiosUrl?: string;
privKey?: string;
escrowRef?: string;
awaitTimeoutMs?: string;
dryRun: boolean;
useMock: boolean;
}
function parseRawArgv(argv: string[]): RawFlags {
const out: RawFlags = { dryRun: false, useMock: false };
for (let i = 0; i < argv.length; i++) {
const tok = argv[i];
if (BOOL_FLAGS.has(tok)) {
if (tok === "--dry-run") out.dryRun = true;
else if (tok === "--mock") out.useMock = true;
continue;
}
if (VALUE_FLAGS.has(tok)) {
const val = argv[i + 1];
if (val === undefined) {
throw new Error(`flag ${tok} requires a value`);
}
switch (tok) {
case "--ogmios-url":
out.ogmiosUrl = val;
break;
case "--priv-key":
out.privKey = val;
break;
case "--escrow-ref":
out.escrowRef = val;
break;
case "--await-timeout-ms":
out.awaitTimeoutMs = val;
break;
}
i++;
continue;
}
throw new Error(`unknown flag: ${tok}`);
}
return out;
}
// ─── parseCliArgs ────────────────────────────────────────────────────────
/**
* parseCliArgs — pure: argv (process.argv.slice(2)) + env map → CliConfig.
*
* Rules (mirror post-advert.ts where possible):
* - Unknown flags throw Error("unknown flag: <flag>").
* - Missing required env vars throw Error naming the missing var.
* - --priv-key / BUYER_PRIV_KEY_HEX must be 64 hex chars.
* - --escrow-ref / ESCROW_REF must match <64-hex>#<int>.
* - NETWORK_ID not "0" or "1" → throws.
* - OGMIOS_URL absent with NETWORK_ID="0" → defaults to public Vector
* testnet Ogmios. Absent with NETWORK_ID="1" → throws.
*/
export function parseCliArgs(
argv: string[],
env: Record<string, string | undefined>,
): CliConfig {
const flags = parseRawArgv(argv);
const networkIdRaw = requireEnv(env, "NETWORK_ID");
if (networkIdRaw !== "0" && networkIdRaw !== "1") {
throw new Error(
`NETWORK_ID must be "0" (testnet) or "1" (mainnet), got: ${networkIdRaw}`,
);
}
const networkId: NetworkId = networkIdRaw === "1" ? 1 : 0;
const privKeyHex = flags.privKey ?? requireEnv(env, "BUYER_PRIV_KEY_HEX");
if (!HEX64_RE.test(privKeyHex)) {
throw new Error(
"BUYER_PRIV_KEY_HEX (or --priv-key) must be 64 lowercase hex chars",
);
}
let ogmiosUrl: string;
if (flags.ogmiosUrl !== undefined) {
ogmiosUrl = flags.ogmiosUrl;
} else if (env.OGMIOS_URL !== undefined && env.OGMIOS_URL !== "") {
ogmiosUrl = env.OGMIOS_URL;
} else if (networkId === 0) {
ogmiosUrl = TESTNET_OGMIOS_DEFAULT;
} else {
throw new Error(
"OGMIOS_URL is required when NETWORK_ID=1 (no public mainnet default)",
);
}
const escrowRefRaw = flags.escrowRef ?? requireEnv(env, "ESCROW_REF");
const escrowRef = parseEscrowRef(escrowRefRaw);
const awaitTimeoutMs =
flags.awaitTimeoutMs === undefined
? DEFAULT_AWAIT_TIMEOUT_MS
: parseIntStrict("--await-timeout-ms", flags.awaitTimeoutMs);
return {
ogmiosUrl,
privKeyHex,
networkId,
escrowRef,
awaitTimeoutMs,
dryRun: flags.dryRun,
useMock: flags.useMock,
};
}
// ─── Wallet derivation (matches buyer/src/index.ts) ──────────────────────
function hexToBytes(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
}
return bytes;
}
function bytesToHex(b: Uint8Array): string {
let out = "";
for (let i = 0; i < b.length; i++) out += b[i].toString(16).padStart(2, "0");
return out;
}
function deriveWalletKey(privHex: string, networkId: NetworkId): WalletKey {
const priv = hexToBytes(privHex);
const pub = ed.getPublicKey(priv);
const pubHex = bytesToHex(pub);
const pkh = blake2b(pub, { dkLen: 28 });
const pkhHex = bytesToHex(pkh);
const header = networkId === 0 ? 0x60 : 0x61;
const payload = new Uint8Array(29);
payload[0] = header;
payload.set(pkh, 1);
const words = bech32.toWords(payload);
const hrp = networkId === 0 ? "addr_test" : "addr";
const addr = bech32.encode(hrp, words, 1023);
return {
pubKeyHash: pkhHex,
pubKeyHex: pubHex,
privateKeyHex: privHex,
address: addr,
};
}
// ─── main ────────────────────────────────────────────────────────────────
/**
* main — CLI entry point.
*
* Success: prints "<txHash>\n" to stdout. All progress lines go to stderr.
* Failure: prints "error: <message>\n" to stderr; the caller decides exit code.
*/
export async function main(
argv: string[],
env: Record<string, string | undefined>,
): Promise<number> {
let cfg: CliConfig;
try {
cfg = parseCliArgs(argv, env);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`error: ${msg}\n`);
return 1;
}
let walletKey: WalletKey;
try {
walletKey = deriveWalletKey(cfg.privKeyHex, cfg.networkId);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`error: wallet derivation failed: ${msg}\n`);
return 1;
}
const chain: ChainProvider = cfg.useMock
? new MockChainProvider()
: new LiveOgmiosProvider({ ogmiosUrl: cfg.ogmiosUrl });
if (cfg.dryRun) {
process.stdout.write(
`dry-run: escrowRef=${cfg.escrowRef.txHash}#${cfg.escrowRef.index} buyer=${walletKey.address}\n`,
);
return 0;
}
try {
const result = await runAccept({
chain,
walletKey,
escrowRef: cfg.escrowRef,
awaitTimeoutMs: cfg.awaitTimeoutMs,
log: (line) => process.stderr.write(`${line}\n`),
});
process.stdout.write(`${result.txHash}\n`);
return 0;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`error: ${msg}\n`);
return 1;
}
}
// Auto-invoke when run directly (tsx src/cli/accept.ts).
if (
process.argv[1]?.endsWith("accept.ts") ||
process.argv[1]?.endsWith("accept.js")
) {
main(process.argv.slice(2), process.env)
.then((code) => process.exit(code))
.catch((err) => {
process.stderr.write(`accept: fatal: ${(err as Error).message}\n`);
process.exit(1);
});
}

View File

@@ -0,0 +1,95 @@
/**
* acceptFlow.ts — pure logic layer for the `tx:accept` CLI.
*
* Mirrors supplier/src/cli/postAdvertFlow.ts: validate, build, submit, await,
* return tx hash. Tests exercise the interface declared here; do not change
* exported types without updating tests first.
*/
import type { ChainProvider, OutputReference } from "@marketplace/shared/chain";
import {
buildAcceptTx,
TxConstructionError,
type WalletKey,
} from "@marketplace/shared/tx";
export interface AcceptFlowParams {
chain: ChainProvider;
walletKey: WalletKey;
escrowRef: OutputReference;
/** Milliseconds to wait for on-chain confirmation. Default: 120_000. */
awaitTimeoutMs?: number;
/**
* Progress logger. Called with human-readable status lines. Must be called
* at minimum with:
* "accepting escrow"
* "submitted tx <hash>"
* "awaiting confirmation"
* "confirmed"
* in that order. Defaults to console.log.
*/
log?: (line: string) => void;
}
export interface AcceptFlowResult {
/** Hex-encoded transaction hash (64 chars). */
txHash: string;
}
/**
* runAccept — build and submit an Accept transaction (Submitted → Accepted, terminal).
*
* Validates (via buildAcceptTx):
* - walletKey.pubKeyHash === datum.buyer_pkh (TxConstructionError "buyer signature mismatch")
* - datum.state === "Submitted" (TxConstructionError "wrong state")
* - tip ≤ submitted_at + ACCEPT_WINDOW_MS (TxConstructionError "accept window expired")
*
* On chain.submitTx rejection: re-throws underlying error as-is.
* On awaitTx timeout: rejects with a timeout error that includes the txHash so
* the operator can recover manually.
*
* Default awaitTimeoutMs: 120_000.
* Default log: console.log.
*/
export async function runAccept(
params: AcceptFlowParams,
): Promise<AcceptFlowResult> {
const { chain, walletKey, escrowRef } = params;
const writeLog =
params.log ??
((line: string) => {
// eslint-disable-next-line no-console
console.log(line);
});
const timeoutMs = params.awaitTimeoutMs ?? 120_000;
writeLog("accepting escrow");
const built = await buildAcceptTx({
chain,
buyerKey: walletKey,
escrowRef,
});
writeLog(`submitted tx ${built.expectedTxHash}`);
writeLog("awaiting confirmation");
try {
await chain.awaitTx(built.expectedTxHash, timeoutMs);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
const wrapped = new Error(
reason.includes(built.expectedTxHash)
? reason
: `awaitTx failed for txHash ${built.expectedTxHash}: ${reason}`,
);
throw wrapped;
}
writeLog("confirmed");
return { txHash: built.expectedTxHash };
}
/** Re-export for tests / consumers that want to discriminate. */
export { TxConstructionError };

74
buyer/src/config.ts Normal file
View File

@@ -0,0 +1,74 @@
/**
* buyer/src/config.ts — env loading for buyer app.
*
* Required env vars:
* BUYER_PRIV_KEY_HEX — 32-byte (64-char) hex Ed25519 private key
* INDEXER_URL — base URL of the indexer (e.g. http://localhost:3001)
*
* Optional:
* BUYER_PORT — port to listen on (default 3002)
* NETWORK_ID — "0" (testnet) or "1" (mainnet), default "0"
* OGMIOS_URL — Ogmios HTTP endpoint (required iff LIVE_CHAIN=1)
* LIVE_CHAIN — "1" opts in to LiveOgmiosProvider (real submitTx).
* Default off → ReadOnlyOgmiosProvider (safe).
*/
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
const POS_INT_RE = /^[1-9]\d*$/;
export interface BuyerConfig {
privKeyHex: string;
indexerUrl: string;
port: number;
networkId: 0 | 1;
ogmiosUrl: string;
liveChain: boolean;
}
function requireField(env: Record<string, string | undefined>, name: string): string {
const v = env[name];
if (v === undefined || v === null || v === "") {
throw new Error(`loadConfig: missing required env var ${name}`);
}
return v;
}
export function loadConfig(env: Record<string, string | undefined>): BuyerConfig {
const privKeyHex = requireField(env, "BUYER_PRIV_KEY_HEX");
if (!HEX64_RE.test(privKeyHex)) {
throw new Error("loadConfig: BUYER_PRIV_KEY_HEX must be 64 hex chars (32 bytes)");
}
const indexerUrl = requireField(env, "INDEXER_URL");
let port = 3002;
const portStr = env.BUYER_PORT;
if (portStr !== undefined && portStr !== "") {
if (!POS_INT_RE.test(portStr)) {
throw new Error("loadConfig: BUYER_PORT must be a positive integer");
}
port = Number(portStr);
}
let networkId: 0 | 1 = 0;
const networkIdStr = env.NETWORK_ID;
if (networkIdStr !== undefined && networkIdStr !== "") {
if (networkIdStr !== "0" && networkIdStr !== "1") {
throw new Error('loadConfig: NETWORK_ID must be "0" (testnet) or "1" (mainnet)');
}
networkId = networkIdStr === "1" ? 1 : 0;
}
// LIVE_CHAIN: only the literal "1" opts in. Mirrors supplier/src/config.ts
// semantics so the boot model is symmetric across the two services.
const liveChain = env.LIVE_CHAIN === "1";
// OGMIOS_URL is required iff LIVE_CHAIN=1 (the LiveOgmiosProvider needs an
// endpoint to submit/await against). In ReadOnly mode it's not strictly
// needed, but we still accept it so the config has a stable shape.
const ogmiosUrl = env.OGMIOS_URL ?? "";
if (liveChain && ogmiosUrl === "") {
throw new Error("loadConfig: OGMIOS_URL is required when LIVE_CHAIN=1");
}
return { privKeyHex, indexerUrl, port, networkId, ogmiosUrl, liveChain };
}

111
buyer/src/index.ts Normal file
View File

@@ -0,0 +1,111 @@
/**
* buyer/src/index.ts — buyer-app entry point.
*
* Wires config → WalletKey derivation → Marketplace SDK → Express server.
* Boot is wrapped in `runMain()` so importing this module (e.g. for tests)
* does NOT trigger network calls or process.exit.
*/
import { resolve } from "path";
import { createHash } from "crypto";
import * as ed from "@noble/ed25519";
import { blake2b } from "@noble/hashes/blake2b";
import { bech32 } from "bech32";
import { loadConfig } from "./config.js";
import { createApp } from "./server.js";
import { buildChainProvider } from "./chain.js";
import { Marketplace, MemoryTaskHistoryStore } from "./sdk/index.js";
import type { WalletKey } from "@marketplace/shared/tx";
// Wire ed25519 sha512 hook (idempotent — same as receipt/sign.ts).
ed.etc.sha512Sync = (...messages: Uint8Array[]): Uint8Array => {
const h = createHash("sha512");
for (const m of messages) h.update(m);
return new Uint8Array(h.digest());
};
function hexToBytes(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
}
return bytes;
}
function bytesToHex(b: Uint8Array): string {
let out = "";
for (let i = 0; i < b.length; i++) out += b[i].toString(16).padStart(2, "0");
return out;
}
export function deriveWalletKey(privHex: string, networkId: 0 | 1): WalletKey {
const priv = hexToBytes(privHex);
const pub = ed.getPublicKey(priv);
const pubHex = bytesToHex(pub);
const pkh = blake2b(pub, { dkLen: 28 });
const pkhHex = bytesToHex(pkh);
const header = networkId === 0 ? 0x60 : 0x61;
const payload = new Uint8Array(29);
payload[0] = header;
payload.set(pkh, 1);
const words = bech32.toWords(payload);
const hrp = networkId === 0 ? "addr_test" : "addr";
const addr = bech32.encode(hrp, words, 1023);
return {
pubKeyHash: pkhHex,
pubKeyHex: pubHex,
privateKeyHex: privHex,
address: addr,
};
}
export async function runMain(env: Record<string, string | undefined>): Promise<void> {
const config = loadConfig(env);
const walletKey = deriveWalletKey(config.privKeyHex, config.networkId);
const chain = buildChainProvider(config);
const marketplace = new Marketplace({
chain,
indexerUrl: config.indexerUrl,
walletKey,
networkParams: { networkId: config.networkId },
historyStore: new MemoryTaskHistoryStore(),
});
const distPath = resolve(process.cwd(), "buyer/dist");
const app = createApp({
distPath,
chain,
walletKey,
indexerUrl: config.indexerUrl,
marketplace,
});
const server = app.listen(config.port, () => {
// eslint-disable-next-line no-console
console.log(`[buyer] listening on :${config.port}, indexer=${config.indexerUrl}`);
});
const shutdown = (): void => {
// eslint-disable-next-line no-console
console.log("[buyer] shutting down...");
server.close(() => process.exit(0));
};
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
}
// Boot only when invoked directly (not when imported by tests).
const invokedDirectly =
typeof process !== "undefined" &&
process.argv?.[1] !== undefined &&
/buyer\/(src|dist)\/index\.(t|j)s$/.test(process.argv[1]);
if (invokedDirectly) {
runMain(process.env).catch((err) => {
// eslint-disable-next-line no-console
console.error("[buyer] boot failed:", err);
process.exit(1);
});
}

View File

@@ -0,0 +1,586 @@
/**
* buyer/src/sdk/Marketplace.ts — core SDK class.
*
* Implements:
* discoverSuppliers(opts?) → indexer GET /suppliers
* submitPrompt({advertRef, ...}) → full lifecycle (escrow → supplier → verify → record)
* acceptResult({escrowRef}) → buildAcceptTx
* reclaim({escrowRef}) → buildReclaimTx
* getTaskHistory(opts?) → reads from injected TaskHistoryStore
* on/off/close + emitProgress → EventEmitter helpers
*
* Receipt verification (M1-E discipline):
* The SDK has no path to a 32-byte supplier public key in the test harness
* (Caroline's mocks return only the chat-completion body, no /capability
* pub-key plumbing). For M1-E we therefore verify receipts by:
* (a) field-equality checks against the advert datum and posted escrow:
* supplier_pkh, escrow_ref, prompt_hash, request_spec_hash
* (b) a structural signature check: 128-char hex, not the all-zero
* placeholder.
* Cryptographic Ed25519 verification is wired here as a NO-OP fallback
* when no pub-key is available; M1-F adds /capability pub-key fetch and
* replaces the structural check with a real verifyReceipt call. See
* ARCHITECTURE.md §5 + §9 for the broader plan.
*/
// Namespace imports — Vite/Rollup resolves these to browser stubs when
// bundling the SPA. The SDK is also imported in Node-side code (buyer
// server, tests), where the namespace form works identically. See
// packages/shared/src/tx/blueprint.ts header for the full rationale.
import * as nodeEvents from "events";
import * as nodeCrypto from "crypto";
// Browser-safe EventEmitter shim. In the SPA bundle, Vite externalizes
// `events` to an empty stub, so `nodeEvents.EventEmitter` is `undefined` —
// `class Marketplace extends undefined` would throw at module load and
// stop React from ever mounting. We pick the real Node EventEmitter when
// available and fall back to a minimal listener-map class otherwise. The
// browser code path that uses the SDK only listens for "progress" events,
// which this fallback supports.
type Listener = (...args: unknown[]) => void;
class FallbackEventEmitter {
private listeners = new Map<string, Listener[]>();
on(event: string, cb: Listener): this {
const arr = this.listeners.get(event);
if (arr) arr.push(cb);
else this.listeners.set(event, [cb]);
return this;
}
off(event: string, cb: Listener): this {
const arr = this.listeners.get(event);
if (!arr) return this;
const idx = arr.indexOf(cb);
if (idx >= 0) arr.splice(idx, 1);
return this;
}
emit(event: string, ...args: unknown[]): boolean {
const arr = this.listeners.get(event);
if (!arr || arr.length === 0) return false;
for (const cb of [...arr]) cb(...args);
return true;
}
removeAllListeners(): this {
this.listeners.clear();
return this;
}
}
const EventEmitterBase: typeof FallbackEventEmitter =
((nodeEvents as unknown as { EventEmitter?: typeof FallbackEventEmitter }).EventEmitter)
?? FallbackEventEmitter;
import type { ChainProvider, OutputReference } from "@marketplace/shared/chain";
import type { WalletKey } from "@marketplace/shared/tx";
import {
buildPostEscrowTx,
buildAcceptTx,
buildReclaimTx,
TxConstructionError,
} from "@marketplace/shared/tx";
import { decodeAdvertDatum, decodeEscrowDatum, canonicalize } from "@marketplace/shared/cbor";
import type { AdvertDatum } from "@marketplace/shared/cbor";
import type {
SupplierView,
DiscoverSuppliersOptions,
SubmitPromptOptions,
SubmitPromptResult,
AcceptResultOptions,
ReclaimOptions,
TaskRecord,
GetTaskHistoryOptions,
ProgressEvent,
ProgressEventType,
} from "./types.js";
import { IndexerError, SupplierError, ReceiptVerificationError } from "./types.js";
import type { TaskHistoryStore } from "./history.js";
import { MemoryTaskHistoryStore } from "./history.js";
import { HttpClient, HttpError } from "./httpClient.js";
const ZERO_SIGNATURE = "0".repeat(128);
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
const SIG_RE = /^[0-9a-fA-F]{128}$/;
/** NetworkParams — minimal params needed for tx building (protocol params). */
export interface NetworkParams {
networkId: 0 | 1;
}
/** MarketplaceOpts — constructor arguments. */
export interface MarketplaceOpts {
chain: ChainProvider;
indexerUrl: string;
walletKey: WalletKey;
networkParams: NetworkParams;
/** Optional history store; defaults to MemoryTaskHistoryStore. */
historyStore?: TaskHistoryStore;
/** Injectable fetch for tests. Defaults to globalThis.fetch. */
_fetch?: typeof globalThis.fetch;
}
function sha256Hex(s: string): string {
// Prefer Node's createHash when available (server, tests). In the browser
// bundle Vite externalizes `crypto` to an empty stub, so fall back to the
// already-installed `@noble/hashes/sha256` (the buyer-app pulls noble in
// for ed25519 signing). Both produce the same 64-char lowercase hex.
const c = nodeCrypto as { createHash?: (a: string) => { update: (s: string, e: string) => { digest: (e: string) => string } } };
if (c && typeof c.createHash === "function") {
return c.createHash("sha256").update(s, "utf8").digest("hex");
}
// Browser fallback: dynamic require of noble-hashes is bundled via the
// top-level import below. Lazy-load so we don't pull noble into Node tests.
return browserSha256Hex(s);
}
import { sha256 as nobleSha256 } from "@noble/hashes/sha256";
function browserSha256Hex(s: string): string {
const bytes = nobleSha256(new TextEncoder().encode(s));
let out = "";
for (let i = 0; i < bytes.length; i++) out += bytes[i].toString(16).padStart(2, "0");
return out;
}
function refToString(ref: OutputReference): string {
return `${ref.txHash}#${ref.index}`;
}
function previewMessages(messages: { role: string; content: string }[]): string {
const firstUser = messages.find((m) => m.role === "user") ?? messages[0];
const text = firstUser?.content ?? "";
return text.length <= 100 ? text : text.slice(0, 100);
}
export class Marketplace extends EventEmitterBase {
private readonly chain: ChainProvider;
private readonly indexerUrl: string;
private readonly walletKey: WalletKey;
private readonly networkParams: NetworkParams;
private readonly historyStore: TaskHistoryStore;
private readonly fetchImpl: typeof globalThis.fetch;
private readonly indexerHttp: HttpClient;
constructor(opts: MarketplaceOpts) {
super();
this.chain = opts.chain;
this.indexerUrl = opts.indexerUrl;
this.walletKey = opts.walletKey;
this.networkParams = opts.networkParams;
this.historyStore = opts.historyStore ?? new MemoryTaskHistoryStore();
this.fetchImpl = opts._fetch ?? globalThis.fetch.bind(globalThis);
this.indexerHttp = new HttpClient({
baseUrl: this.indexerUrl,
fetch: this.fetchImpl,
});
void this.networkParams;
}
// ─── discovery ────────────────────────────────────────────────────────
async discoverSuppliers(opts?: DiscoverSuppliersOptions): Promise<SupplierView[]> {
let result;
try {
result = await this.indexerHttp.getJson("/suppliers", {
query: {
capability_id: opts?.capability_id,
sort: opts?.sort,
},
});
} catch (err) {
if (err instanceof HttpError) {
throw new IndexerError(err.kind, { message: err.message });
}
throw err;
}
if (!result.ok) {
throw new IndexerError("indexer_error", {
status: result.status,
message: `indexer returned ${result.status}`,
});
}
if (result.parseError) {
throw new IndexerError("malformed_response", {
status: result.status,
message: "indexer returned non-JSON body",
});
}
if (!Array.isArray(result.body)) {
throw new IndexerError("malformed_response", {
status: result.status,
message: "indexer /suppliers did not return an array",
});
}
return result.body as SupplierView[];
}
// ─── submitPrompt — happy path + every adversarial branch ──────────────
async submitPrompt(opts: SubmitPromptOptions): Promise<SubmitPromptResult> {
const { advertRef, messages, payment_lovelace, max_output_tokens } = opts;
let advertDatum: AdvertDatum | null = null;
let escrowOutputRef: OutputReference | null = null;
let escrowRefStr = "";
let postedAtMs = Date.now();
const recordFailure = (reason: string): void => {
this.historyStore.save({
escrow_ref: escrowRefStr || `${"0".repeat(64)}#0`,
supplier_pkh: advertDatum?.supplier_pkh ?? "",
capability_id: advertDatum?.capability_id ?? "",
prompt_preview: previewMessages(messages ?? []),
posted_at: postedAtMs,
status: "failed",
failure_reason: reason,
});
};
// ── 1. Resolve advert + post escrow tx via shared builder ────────
let escrowResult;
try {
// Pre-fetch advert datum for response/history bookkeeping. The shared
// builder repeats this query and enforces all invariants — we only peek
// here so failure paths can populate the history record with metadata.
const utxo = await this.chain.queryUtxo(advertRef);
if (utxo && utxo.datumHex) {
try {
advertDatum = decodeAdvertDatum(utxo.datumHex);
} catch {
/* swallow — builder will throw a structured error */
}
}
escrowResult = await buildPostEscrowTx({
chain: this.chain,
buyerKey: this.walletKey,
advertRef,
messages,
payment_lovelace,
});
} catch (err) {
if (err instanceof TxConstructionError) {
recordFailure(err.reason);
throw err;
}
// Chain submission failures bubble through here too.
this.emitProgress({ type: "chain_submit_failed", detail: (err as Error).message });
recordFailure((err as Error).message);
throw err;
}
escrowOutputRef = escrowResult.escrowOutputRef;
escrowRefStr = refToString(escrowOutputRef);
// ── 2. progress: escrow_posted ────────────────────────────────────
this.emitProgress({ type: "escrow_posted", escrow_ref: escrowRefStr });
// ── 2.5. Wait for the escrow UTxO to confirm on chain before calling
// the supplier. Without this, supplier.queryUtxo runs against a chain
// tip that doesn't yet contain the PostEscrow tx and the supplier 404s
// with escrow_not_found. Best-effort: if the chain provider doesn't
// implement awaitTx (mock paths in tests), swallow and proceed —
// queryUtxo retries on the next step provide adequate coverage there.
try {
await this.chain.awaitTx(escrowResult.expectedTxHash, 120_000);
} catch {
/* mock providers / test harnesses */
}
// ── 3. Re-fetch escrow datum to capture posted_at for history ────
try {
const escrowUtxo = await this.chain.queryUtxo(escrowOutputRef);
if (escrowUtxo && escrowUtxo.datumHex) {
const ed = decodeEscrowDatum(escrowUtxo.datumHex);
postedAtMs = ed.posted_at;
}
} catch {
/* posted_at is best-effort metadata */
}
if (!advertDatum) {
// Should never happen — buildPostEscrowTx throws before this on missing/bad advert.
const reason = "advert datum unavailable after escrow post";
recordFailure(reason);
throw new ReceiptVerificationError(reason);
}
// ── 4. Call supplier /v1/chat/completions ─────────────────────────
const supplierBaseUrl = advertDatum.endpoint_url;
const supplierHttp = new HttpClient({
baseUrl: supplierBaseUrl,
fetch: this.fetchImpl,
});
const requestedMax = max_output_tokens ?? advertDatum.max_output_tokens;
const cappedMax = Math.min(requestedMax, advertDatum.max_output_tokens);
const chatBody = {
model: advertDatum.model,
messages,
max_tokens: cappedMax,
};
let chatResult;
try {
chatResult = await supplierHttp.postJson("/v1/chat/completions", chatBody, {
headers: { "X-Escrow-Ref": escrowRefStr },
});
} catch (err) {
if (err instanceof HttpError) {
// ARCH §9 #10 (resolved 2026-04-27, M1-F-1): the previous
// `isSyncThrow` sentinel branch has been removed. All HttpError
// paths — including fetch implementations that throw synchronously —
// now propagate normally as SupplierError to the caller.
const reason = err.kind === "timeout" ? "timeout" : "network_error";
const sErr = new SupplierError(reason, { message: err.message });
recordFailure(reason);
throw sErr;
}
recordFailure((err as Error).message);
throw err;
}
// Supplier shipped 202 + poll in M1-F-async-chat: the initial POST
// returns `{job_id, status: "accepted"}` and clients poll
// `GET /v1/chat/completions/:jobId` until the status becomes "done"
// (200 with receipt) or terminal-failed (4xx/5xx). Older synchronous
// suppliers still return 200 with the receipt immediately, so we
// dispatch on `chatResult.status === 202` to keep both supported.
if (chatResult.status === 202 && chatResult.body && typeof chatResult.body === "object") {
const jobId = (chatResult.body as { job_id?: string }).job_id;
if (typeof jobId !== "string" || jobId.length === 0) {
const sErr = new SupplierError("malformed_response", {
status: chatResult.status,
message: "supplier 202 response missing job_id",
});
recordFailure("malformed_response");
throw sErr;
}
const POLL_INTERVAL_MS = 2_000;
const POLL_TIMEOUT_MS = 180_000;
const pollDeadline = Date.now() + POLL_TIMEOUT_MS;
let polled = chatResult;
// First poll runs immediately so a quickly-completed job doesn't pay
// the leading delay; subsequent polls wait POLL_INTERVAL_MS.
let firstIter = true;
while (Date.now() < pollDeadline) {
if (!firstIter) {
await new Promise<void>((r) => setTimeout(r, POLL_INTERVAL_MS));
}
firstIter = false;
try {
polled = await supplierHttp.getJson(`/v1/chat/completions/${jobId}`);
} catch (err) {
if (err instanceof HttpError) {
const reason = err.kind === "timeout" ? "timeout" : "network_error";
const sErr = new SupplierError(reason, { message: err.message });
recordFailure(reason);
throw sErr;
}
throw err;
}
if (polled.status === 200) break;
if (polled.status === 202) continue;
// 4xx/5xx on poll → terminal failure from supplier.
const failBody = (polled.body && typeof polled.body === "object")
? polled.body as { reason?: string; message?: string }
: {};
const sErr = new SupplierError(failBody.reason ?? "supplier_http_error", {
status: polled.status,
message: failBody.message ?? `supplier returned ${polled.status}`,
});
recordFailure(sErr.reason);
throw sErr;
}
if (polled.status !== 200) {
const sErr = new SupplierError("timeout", {
status: polled.status,
message: `supplier job ${jobId} not done within ${POLL_TIMEOUT_MS}ms`,
});
recordFailure("timeout");
throw sErr;
}
chatResult = polled;
}
if (!chatResult.ok) {
const bodyReason =
chatResult.body && typeof chatResult.body === "object"
? ((chatResult.body as { reason?: string }).reason ?? "supplier_http_error")
: "supplier_http_error";
const sErr = new SupplierError(bodyReason, {
status: chatResult.status,
message: `supplier returned ${chatResult.status}`,
});
recordFailure(bodyReason);
throw sErr;
}
if (chatResult.parseError || !chatResult.body || typeof chatResult.body !== "object") {
const sErr = new SupplierError("malformed_response", {
status: chatResult.status,
message: "supplier body is not valid JSON",
});
recordFailure("malformed_response");
throw sErr;
}
const responseBody = chatResult.body as {
choices?: Array<{ message?: { role?: string; content?: string } }>;
receipt?: import("@marketplace/shared/receipt").Receipt;
receipt_signature?: string;
};
if (!responseBody.receipt || !responseBody.receipt_signature) {
const sErr = new SupplierError("malformed_response", {
status: chatResult.status,
message: "supplier response is missing receipt or receipt_signature",
});
recordFailure("malformed_response");
throw sErr;
}
const receipt = responseBody.receipt;
const receiptSignature = responseBody.receipt_signature;
const responseContent = responseBody.choices?.[0]?.message?.content;
if (typeof responseContent !== "string") {
const sErr = new SupplierError("malformed_response", {
status: chatResult.status,
message: "supplier response missing choices[0].message.content",
});
recordFailure("malformed_response");
throw sErr;
}
// ── 5. progress: supplier_called ──────────────────────────────────
this.emitProgress({ type: "supplier_called", escrow_ref: escrowRefStr });
// ── 6. Receipt verification (field equality + signature shape) ───
if (receipt.supplier_pkh !== advertDatum.supplier_pkh) {
recordFailure("wrong_supplier");
throw new ReceiptVerificationError("wrong_supplier");
}
if (receipt.escrow_ref !== escrowRefStr) {
recordFailure("wrong_escrow_ref");
throw new ReceiptVerificationError("wrong_escrow_ref");
}
const expectedPromptHash = sha256Hex(canonicalize(messages));
if (receipt.prompt_hash !== expectedPromptHash) {
recordFailure("prompt_hash_mismatch");
throw new ReceiptVerificationError("prompt_hash_mismatch");
}
// Receipt does not carry the full request_spec_hash — only `model`. Other
// request_spec components (capability_id, max_output_tokens) are bound on
// chain via the escrow datum, which the supplier already validated against
// the advert. So the SDK only needs to assert the receipt's model matches
// the advert it referenced.
if (receipt.model !== advertDatum.model) {
recordFailure("request_spec_hash_mismatch");
throw new ReceiptVerificationError("request_spec_hash_mismatch");
}
if (typeof receiptSignature !== "string" || !SIG_RE.test(receiptSignature)) {
recordFailure("invalid_signature");
throw new ReceiptVerificationError("invalid_signature");
}
if (receiptSignature === ZERO_SIGNATURE) {
// Structurally well-formed but cryptographically void. M1-F replaces
// this with a real verifyReceipt() call once pub-key plumbing is wired.
recordFailure("invalid_signature");
throw new ReceiptVerificationError("invalid_signature");
}
// Defensive: receipt fields should be 32-byte hex.
if (!HEX64_RE.test(receipt.prompt_hash) || !HEX64_RE.test(receipt.response_hash)) {
recordFailure("malformed_receipt");
throw new ReceiptVerificationError("malformed_receipt");
}
// ── 7. progress: receipt_verified ─────────────────────────────────
this.emitProgress({ type: "receipt_verified", escrow_ref: escrowRefStr });
// ── 8. Persist completed task ─────────────────────────────────────
this.historyStore.save({
escrow_ref: escrowRefStr,
supplier_pkh: advertDatum.supplier_pkh,
capability_id: advertDatum.capability_id,
prompt_preview: previewMessages(messages),
posted_at: postedAtMs,
status: "completed",
response: responseContent,
receipt,
receipt_signature: receiptSignature,
});
return {
response: responseContent,
receipt,
receiptSignature,
escrowRef: escrowOutputRef,
};
}
// ─── acceptResult / reclaim ───────────────────────────────────────────
async acceptResult(opts: AcceptResultOptions): Promise<void> {
await buildAcceptTx({
chain: this.chain,
buyerKey: this.walletKey,
escrowRef: opts.escrowRef,
});
this.emitProgress({
type: "accept_submitted",
escrow_ref: refToString(opts.escrowRef),
});
const escrowRefStr = refToString(opts.escrowRef);
const existing = this.historyStore.get(escrowRefStr);
if (existing) {
this.historyStore.save({ ...existing, status: "completed" });
}
}
async reclaim(opts: ReclaimOptions): Promise<void> {
await buildReclaimTx({
chain: this.chain,
buyerKey: this.walletKey,
escrowRef: opts.escrowRef,
});
this.emitProgress({
type: "reclaim_submitted",
escrow_ref: refToString(opts.escrowRef),
});
const escrowRefStr = refToString(opts.escrowRef);
const existing = this.historyStore.get(escrowRefStr);
if (existing) {
this.historyStore.save({ ...existing, status: "reclaimed" });
}
}
// ─── history ──────────────────────────────────────────────────────────
getTaskHistory(opts?: GetTaskHistoryOptions): TaskRecord[] {
return this.historyStore.list(opts);
}
// ─── accessors ────────────────────────────────────────────────────────
/** Read the wallet key — UI surfaces this on /wallet. */
getWalletKey(): WalletKey {
return this.walletKey;
}
// ─── lifecycle ────────────────────────────────────────────────────────
emitProgress(event: ProgressEvent): void {
this.emit("progress", event);
}
close(): void {
this.removeAllListeners();
}
on(event: "progress" | ProgressEventType | string, listener: (...args: unknown[]) => void): this {
return super.on(event, listener);
}
off(event: "progress" | ProgressEventType | string, listener: (...args: unknown[]) => void): this {
return super.off(event, listener);
}
}

106
buyer/src/sdk/history.ts Normal file
View File

@@ -0,0 +1,106 @@
/**
* buyer/src/sdk/history.ts — TaskHistoryStore interface + two implementations.
*
* MemoryTaskHistoryStore — in-memory (default for SDK + tests)
* LocalStorageTaskHistoryStore — browser-side, JSON-encoded array under one key
*
* Records are returned ordered by posted_at DESCENDING. Filters apply additively.
*/
import type { TaskRecord, GetTaskHistoryOptions } from "./types.js";
const LOCAL_STORAGE_KEY = "marketplace:tasks";
export interface TaskHistoryStore {
/** Persist a new or updated task record. Latest write wins on same escrow_ref. */
save(record: TaskRecord): void;
/** Get all records, ordered by posted_at descending, with optional filters. */
list(opts?: GetTaskHistoryOptions): TaskRecord[];
/** Get a single record by escrow_ref, or null if not found. */
get(escrowRef: string): TaskRecord | null;
}
function applyFilters(records: TaskRecord[], opts?: GetTaskHistoryOptions): TaskRecord[] {
if (!opts) return records;
return records.filter((r) => {
if (opts.status !== undefined && r.status !== opts.status) return false;
if (opts.supplier !== undefined && r.supplier_pkh !== opts.supplier) return false;
return true;
});
}
function sortDesc(records: TaskRecord[]): TaskRecord[] {
return [...records].sort((a, b) => b.posted_at - a.posted_at);
}
export class MemoryTaskHistoryStore implements TaskHistoryStore {
private readonly records: Map<string, TaskRecord> = new Map();
save(record: TaskRecord): void {
this.records.set(record.escrow_ref, { ...record });
}
list(opts?: GetTaskHistoryOptions): TaskRecord[] {
const all = Array.from(this.records.values());
return sortDesc(applyFilters(all, opts));
}
get(escrowRef: string): TaskRecord | null {
const r = this.records.get(escrowRef);
return r ? { ...r } : null;
}
}
/**
* LocalStorageTaskHistoryStore — browser localStorage implementation.
* All records live in a single JSON-encoded array under "marketplace:tasks"
* to keep reads cheap and keep the API simple. Falls back to no-op if
* localStorage is unavailable.
*/
export class LocalStorageTaskHistoryStore implements TaskHistoryStore {
private readStorage(): TaskRecord[] {
try {
if (typeof globalThis === "undefined") return [];
const ls = (globalThis as { localStorage?: Storage }).localStorage;
if (!ls) return [];
const raw = ls.getItem(LOCAL_STORAGE_KEY);
if (raw === null) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as TaskRecord[]) : [];
} catch {
return [];
}
}
private writeStorage(records: TaskRecord[]): void {
try {
if (typeof globalThis === "undefined") return;
const ls = (globalThis as { localStorage?: Storage }).localStorage;
if (!ls) return;
ls.setItem(LOCAL_STORAGE_KEY, JSON.stringify(records));
} catch {
/* no-op — storage may be full or disabled */
}
}
save(record: TaskRecord): void {
const all = this.readStorage();
const idx = all.findIndex((r) => r.escrow_ref === record.escrow_ref);
if (idx >= 0) {
all[idx] = { ...record };
} else {
all.push({ ...record });
}
this.writeStorage(all);
}
list(opts?: GetTaskHistoryOptions): TaskRecord[] {
return sortDesc(applyFilters(this.readStorage(), opts));
}
get(escrowRef: string): TaskRecord | null {
const all = this.readStorage();
const r = all.find((x) => x.escrow_ref === escrowRef);
return r ? { ...r } : null;
}
}

172
buyer/src/sdk/httpClient.ts Normal file
View File

@@ -0,0 +1,172 @@
/**
* buyer/src/sdk/httpClient.ts — tiny fetch wrapper with timeout + error normalisation.
*
* Used by the SDK to call indexer and supplier endpoints. Errors are
* mapped to the SDK's IndexerError / SupplierError taxonomy by the caller;
* this module only provides a thin abstraction that:
* - applies an AbortController-based timeout
* - reads JSON bodies and reports parse failures
* - normalises non-2xx into a uniform { status, body, parseError } shape
*/
import * as mod from "./httpClient.js";
export interface HttpClientOpts {
baseUrl: string;
fetch?: typeof globalThis.fetch;
defaultTimeoutMs?: number;
}
export interface HttpResult {
status: number;
ok: boolean;
body: unknown;
parseError: boolean;
}
export class HttpError extends Error {
public readonly kind: "timeout" | "network" | "non_2xx" | "parse_error";
public readonly status?: number;
public readonly body?: unknown;
constructor(
kind: "timeout" | "network" | "non_2xx" | "parse_error",
message: string,
opts?: { status?: number; body?: unknown },
) {
super(message);
this.name = "HttpError";
this.kind = kind;
this.status = opts?.status;
this.body = opts?.body;
}
}
export class HttpClient {
private readonly baseUrl: string;
private readonly defaultTimeoutMs: number;
private readonly fetchImpl: typeof globalThis.fetch;
constructor(opts: HttpClientOpts) {
this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
this.defaultTimeoutMs = opts.defaultTimeoutMs ?? 30_000;
this.fetchImpl = opts.fetch ?? globalThis.fetch.bind(globalThis);
}
private buildUrl(path: string, query?: Record<string, string | undefined>): string {
const sep = path.startsWith("/") ? "" : "/";
let url = `${this.baseUrl}${sep}${path}`;
if (query) {
const params: string[] = [];
for (const [k, v] of Object.entries(query)) {
if (v === undefined) continue;
params.push(`${encodeURIComponent(k)}=${encodeURIComponent(v)}`);
}
if (params.length > 0) {
url += (url.includes("?") ? "&" : "?") + params.join("&");
}
}
return url;
}
async getJson(
path: string,
opts?: { timeoutMs?: number; query?: Record<string, string | undefined> },
): Promise<HttpResult> {
const url = this.buildUrl(path, opts?.query);
return mod._performRequest(this.fetchImpl, url, {
method: "GET",
timeoutMs: opts?.timeoutMs ?? this.defaultTimeoutMs,
});
}
async postJson(
path: string,
body: unknown,
opts?: { timeoutMs?: number; headers?: Record<string, string> },
): Promise<HttpResult> {
const url = this.buildUrl(path);
return mod._performRequest(this.fetchImpl, url, {
method: "POST",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json", ...(opts?.headers ?? {}) },
timeoutMs: opts?.timeoutMs ?? this.defaultTimeoutMs,
});
}
}
interface PerformRequestInit {
method: "GET" | "POST";
body?: string;
headers?: Record<string, string>;
timeoutMs: number;
}
/**
* _performRequest — exported for monkey-patch hooks but otherwise internal.
*
* Maps low-level fetch behaviour into a structured HttpResult:
* - AbortError or "abort" name → HttpError("timeout")
* - other thrown errors → HttpError("network")
* - non-2xx → HttpResult with ok=false; caller decides
* - JSON parse failure → HttpResult with parseError=true
*/
export async function _performRequest(
fetchImpl: typeof globalThis.fetch,
url: string,
init: PerformRequestInit,
): Promise<HttpResult> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), init.timeoutMs);
// Distinguish "fetch impl threw synchronously" (an unusual mock or a host
// bug) from "fetch impl returned a Promise that rejected" (real network /
// abort). We do this by wrapping ONLY the call expression — not the await —
// so that a synchronous throw is captured here, and asynchronous rejections
// fall through to the await/catch below. Either path produces a regular
// HttpError("network") that propagates to the caller — no special-casing.
let pending: Promise<Response>;
try {
pending = fetchImpl(url, {
method: init.method,
headers: init.headers,
body: init.body,
signal: controller.signal,
});
} catch (err) {
clearTimeout(timer);
const e = err as { name?: string; message?: string };
throw new HttpError("network", `network error: ${e?.message ?? String(err)}`);
}
let response: Response;
try {
response = await pending;
} catch (err) {
clearTimeout(timer);
const e = err as { name?: string; message?: string };
if (e?.name === "AbortError" || /abort/i.test(e?.message ?? "")) {
throw new HttpError("timeout", `request to ${url} timed out`);
}
throw new HttpError("network", `network error: ${e?.message ?? String(err)}`);
} finally {
clearTimeout(timer);
}
let parsed: unknown = undefined;
let parseError = false;
let text = "";
try {
text = await response.text();
} catch {
// ignore — keep text=""
}
if (text.length > 0) {
try {
parsed = JSON.parse(text);
} catch {
parseError = true;
parsed = text;
}
}
return { status: response.status, ok: response.ok, body: parsed, parseError };
}

22
buyer/src/sdk/index.ts Normal file
View File

@@ -0,0 +1,22 @@
/**
* buyer/src/sdk/index.ts — SDK barrel export.
*/
export { Marketplace } from "./Marketplace.js";
export type { MarketplaceOpts, NetworkParams } from "./Marketplace.js";
export type {
SubmitPromptResult,
TaskRecord,
TaskStatus,
ProgressEvent,
ProgressEventType,
SupplierView,
DiscoverSuppliersOptions,
SubmitPromptOptions,
AcceptResultOptions,
ReclaimOptions,
GetTaskHistoryOptions,
} from "./types.js";
export { ReceiptVerificationError, IndexerError, SupplierError } from "./types.js";
export type { TaskHistoryStore } from "./history.js";
export { MemoryTaskHistoryStore, LocalStorageTaskHistoryStore } from "./history.js";

177
buyer/src/sdk/types.ts Normal file
View File

@@ -0,0 +1,177 @@
/**
* buyer/src/sdk/types.ts — SDK-specific types for M1-E.
*
* Stub — all runtime values throw until M1-E-green.
*/
import type { Receipt } from "@marketplace/shared/receipt";
import type { SignedReceipt } from "@marketplace/shared/receipt";
import type { OutputReference } from "@marketplace/shared/chain";
// Re-export for consumers
export type { Receipt, SignedReceipt };
/**
* SubmitPromptResult — returned from Marketplace.submitPrompt() on success.
*/
export interface SubmitPromptResult {
/** The assistant content string from the supplier's response. */
response: string;
/** Full receipt object, validated and canonical. */
receipt: Receipt;
/** Ed25519 hex signature over canonical(receipt), 64 bytes. */
receiptSignature: string;
/** The escrow OutputReference created for this prompt. */
escrowRef: OutputReference;
}
/**
* TaskStatus — lifecycle states for a recorded task.
*/
export type TaskStatus = "pending" | "completed" | "failed" | "reclaimed";
/**
* TaskRecord — stored in TaskHistoryStore (localStorage-backed or memory).
*/
export interface TaskRecord {
escrow_ref: string; // "<txHash>#<index>"
supplier_pkh: string; // 28-byte hex
capability_id: string;
prompt_preview: string; // first 100 chars of first user message
posted_at: number; // POSIX ms
status: TaskStatus;
response?: string; // set on completed
receipt?: Receipt; // set on completed
receipt_signature?: string; // set on completed
failure_reason?: string; // set on failed
}
/**
* ProgressEventType — names of events emitted by Marketplace.
*/
export type ProgressEventType =
| "escrow_posted"
| "supplier_called"
| "receipt_verified"
| "accept_submitted"
| "reclaim_submitted"
| "chain_submit_failed";
/**
* ProgressEvent — payload emitted via EventEmitter on each step.
*/
export interface ProgressEvent {
type: ProgressEventType;
escrow_ref?: string;
detail?: string;
}
/**
* ReceiptVerificationError — thrown when receipt signature or fields are invalid.
* `reason` is a machine-readable snake_case identifier tests assert against.
*/
export class ReceiptVerificationError extends Error {
public readonly reason: string;
constructor(reason: string, message?: string) {
super(message ?? reason);
this.name = "ReceiptVerificationError";
this.reason = reason;
}
}
/**
* IndexerError — thrown when the indexer returns a non-2xx or unparseable response.
*/
export class IndexerError extends Error {
public readonly status?: number;
public readonly reason: string;
constructor(reason: string, opts?: { status?: number; message?: string }) {
super(opts?.message ?? reason);
this.name = "IndexerError";
this.reason = reason;
this.status = opts?.status;
}
}
/**
* SupplierError — thrown when the supplier returns a non-2xx, times out, or
* returns a malformed response body.
*/
export class SupplierError extends Error {
public readonly status?: number;
public readonly reason: string;
constructor(reason: string, opts?: { status?: number; message?: string }) {
super(opts?.message ?? reason);
this.name = "SupplierError";
this.reason = reason;
this.status = opts?.status;
}
}
/**
* SupplierView — the shape returned by GET /suppliers (indexer).
* Matches indexer/src/routes/suppliers.ts `SupplierView`.
*/
export interface SupplierView {
utxo_ref: string;
supplier_pkh: string;
capability_id: string;
model: string;
max_output_tokens: number;
max_processing_ms: number;
price_lovelace: string;
supplier_bond_lovelace: string;
buyer_bond_lovelace: string;
endpoint_url: string;
detail_uri: string;
detail_hash: string;
advertised_at: number;
status: string;
advert_status: string;
current_escrow_ref: string | null;
last_seen_iso: string | null;
created_slot: number;
}
/**
* DiscoverSuppliersOptions — optional filters for discoverSuppliers().
*/
export interface DiscoverSuppliersOptions {
capability_id?: string;
sort?: "price" | "last_seen";
}
/**
* SubmitPromptOptions — arguments to Marketplace.submitPrompt().
*/
export interface SubmitPromptOptions {
advertRef: OutputReference;
messages: import("@marketplace/shared/tx").ChatMessage[];
payment_lovelace: bigint;
max_output_tokens?: number;
}
/**
* AcceptResultOptions — arguments to Marketplace.acceptResult().
*/
export interface AcceptResultOptions {
escrowRef: OutputReference;
}
/**
* ReclaimOptions — arguments to Marketplace.reclaim().
*/
export interface ReclaimOptions {
escrowRef: OutputReference;
}
/**
* GetTaskHistoryOptions — optional filters for getTaskHistory().
*/
export interface GetTaskHistoryOptions {
status?: TaskStatus;
supplier?: string; // supplier_pkh
}

495
buyer/src/server.ts Normal file
View File

@@ -0,0 +1,495 @@
/**
* buyer/src/server.ts — createApp(deps) for the buyer web server.
*
* Routes:
* GET /healthz → { ok: true } (no chain or indexer touch)
* GET /v1/pending-receipts → list the buyer's currently-Submitted escrows
* from the indexer (pre-filtered by buyer_pkh)
* POST /v1/accept → resolve original escrow_ref → current Submitted
* UTxO via the indexer, run runAccept against
* the LiveOgmiosProvider, return the Accept tx hash
* * → static SPA bundle from `distPath`, with
* SPA fallback to index.html
*
* The Accept endpoint exists because the browser holds no private key and
* can't talk to Ogmios directly (no submit, CORS) — the server is the only
* place that can sign + submit. The /v1/pending-receipts endpoint is a thin
* convenience proxy; the browser could hit the indexer directly, but routing
* through the buyer-app keeps the buyer_pkh out of the page bundle.
*/
import express, { type Express, type Request, type Response } from "express";
import { existsSync, readFileSync, statSync } from "fs";
import { bech32 } from "bech32";
import type { ChainProvider } from "@marketplace/shared/chain";
import type { WalletKey } from "@marketplace/shared/tx";
import type { Marketplace } from "./sdk/Marketplace.js";
import type { ChatMessage } from "@marketplace/shared/tx";
import { runAccept } from "./cli/acceptFlow.js";
const ESCROW_REF_RE = /^([0-9a-f]{64})#(\d+)$/;
export interface IndexerEscrowRow {
utxo_ref: string;
buyer_pkh: string;
supplier_pkh: string;
state: string;
posted_at: number;
submitted_at: number | null;
result_receipt_hash: string | null;
capability_id: string;
prompt_hash: string;
payment_lovelace: string;
buyer_bond_lovelace: string;
supplier_bond_lovelace: string;
deliver_by: number;
created_slot: number;
}
export interface AppDeps {
/** Absolute path to the Vite-built static directory (buyer/dist). */
distPath?: string;
/** ChainProvider; must be Live for /v1/accept to work. */
chain?: ChainProvider;
/** Buyer wallet key — used by runAccept to sign the Accept tx. */
walletKey?: WalletKey;
/** Indexer base URL (no trailing slash). */
indexerUrl?: string;
/** Marketplace SDK instance — used by /v1/submit-prompt to drive the
* full PostEscrow → supplier → receipt-verify lifecycle server-side.
* The browser SPA calls fetch('/v1/submit-prompt') instead of touching
* the SDK directly, so the chain stub in the SPA never has to fire. */
marketplace?: Marketplace;
/** Injectable fetch for tests. Defaults to globalThis.fetch. */
fetchImpl?: typeof globalThis.fetch;
}
interface ResolvedDeps {
chain: ChainProvider;
walletKey: WalletKey;
indexerUrl: string;
fetchImpl: typeof globalThis.fetch;
}
function jsonError(
res: Response,
status: number,
reason: string,
message: string,
): Response {
return res.status(status).json({ error: reason, message });
}
/** Coerce req.body to a plain object, treating null/undefined as empty. */
function readBody(reqBody: unknown): Record<string, unknown> {
if (typeof reqBody !== "object" || reqBody === null) return {};
return reqBody as Record<string, unknown>;
}
/** Mainnet enterprise (vkh) bech32 address for a given 28-byte PKH hex. The
* buyer-app exposes this for display only; it matches what lucid actually
* uses when building txs (lucid is configured with network "Mainnet" because
* Vector L2 shares mainnet's CIP-19 byte semantics). */
function pkhToMainnetAddress(pubKeyHashHex: string): string {
if (!/^[0-9a-fA-F]{56}$/.test(pubKeyHashHex)) {
return "";
}
const pkh = new Uint8Array(28);
for (let i = 0; i < 28; i++) {
pkh[i] = parseInt(pubKeyHashHex.substring(i * 2, i * 2 + 2), 16);
}
const payload = new Uint8Array(29);
payload[0] = 0x61; // mainnet enterprise vkh header
payload.set(pkh, 1);
return bech32.encode("addr", bech32.toWords(payload), 1023);
}
export function createApp(deps: AppDeps): Express {
const app = express();
app.disable("x-powered-by");
app.use(express.json({ limit: "1mb" }));
app.get("/healthz", (_req: Request, res: Response) => {
res.status(200).json({ ok: true });
});
// chain/walletKey/indexerUrl are required for /v1/* routes; if any is
// missing we still mount the static bundle but the API endpoints respond
// 503 service_unavailable so the SPA can render a clear "boot misconfigured"
// banner instead of the process crashing.
const apiReady =
deps.chain !== undefined &&
deps.walletKey !== undefined &&
deps.indexerUrl !== undefined &&
deps.indexerUrl !== "";
const resolved: ResolvedDeps | null = apiReady
? {
chain: deps.chain!,
walletKey: deps.walletKey!,
indexerUrl: deps.indexerUrl!.replace(/\/+$/, ""),
fetchImpl: deps.fetchImpl ?? globalThis.fetch,
}
: null;
app.get("/v1/pending-receipts", async (_req: Request, res: Response) => {
if (!resolved) {
return jsonError(
res,
503,
"service_unavailable",
"buyer-app booted without chain/indexer deps; /v1/* routes disabled",
);
}
try {
const url = `${resolved.indexerUrl}/escrows?buyer=${resolved.walletKey.pubKeyHash}`;
const response = await resolved.fetchImpl(url);
if (!response.ok) {
return jsonError(
res,
502,
"indexer_error",
`indexer responded ${response.status}: ${response.statusText}`,
);
}
const rows = (await response.json()) as IndexerEscrowRow[];
// Three filters, applied in order:
// (1) state === "Submitted" — only Submitted rows are Accept-able.
// (2) submitted_at + ACCEPT_WINDOW_MS > Date.now() — drop rows whose
// Accept window has already expired. The validator's
// handle_accept rejects past-window txs, so listing them in the
// UI just produces "expired" placeholders the user can't action.
// Deterministic time math: avoids the keep-on-error race in
// the chain probe below.
// (3) chain.queryUtxo returns non-null — the indexer doesn't update
// an escrow row's `state` when AcceptEscrow / ReleaseEscrow /
// ReclaimEscrow spend the UTxO, so already-terminated
// lifecycles linger as state="Submitted" until that's fixed.
const ACCEPT_WINDOW_MS = 600_000;
const now = Date.now();
const submitted = (Array.isArray(rows) ? rows : [])
.filter((r) => r.state === "Submitted")
.filter((r) => typeof r.submitted_at === "number"
&& (r.submitted_at + ACCEPT_WINDOW_MS) > now);
const m = ESCROW_REF_RE;
const checks = await Promise.all(
submitted.map(async (row) => {
const match = m.exec(row.utxo_ref);
if (!match) return null;
try {
const utxo = await resolved.chain.queryUtxo({
txHash: match[1],
index: Number(match[2]),
});
return utxo === null ? null : row;
} catch {
return row; // on chain query error, keep the row (avoids hiding good ones)
}
}),
);
const live = checks.filter((r): r is IndexerEscrowRow => r !== null);
return res.status(200).json({ escrows: live });
} catch (err) {
return jsonError(
res,
502,
"indexer_unreachable",
err instanceof Error ? err.message : String(err),
);
}
});
// ── /v1/indexer/* — generic GET passthrough to the internal indexer.
// The SPA's SDK is configured with `indexerUrl = ${origin}/v1/indexer`,
// so calls like `/suppliers`, `/escrows?buyer=…`, `/capabilities` land
// here and get forwarded to http://marketplace-indexer:8090/<rest>. This
// avoids CORS and keeps the indexer host name out of the page bundle.
// GET-only on purpose: the indexer has no write endpoints, and the
// browser must never push state through this proxy.
app.get(/^\/v1\/indexer(\/.*)?$/, async (req: Request, res: Response) => {
if (!resolved) {
return jsonError(
res,
503,
"service_unavailable",
"buyer-app booted without chain/indexer deps; /v1/indexer disabled",
);
}
const subpath = (req.params as { 0?: string })[0] ?? "/";
const qs = req.url.includes("?") ? req.url.slice(req.url.indexOf("?")) : "";
const url = `${resolved.indexerUrl}${subpath}${qs}`;
try {
const upstream = await resolved.fetchImpl(url);
const ct = upstream.headers.get("content-type") ?? "application/json";
res.status(upstream.status);
res.setHeader("Content-Type", ct);
const body = await upstream.text();
res.send(body);
return;
} catch (err) {
return jsonError(
res,
502,
"indexer_unreachable",
err instanceof Error ? err.message : String(err),
);
}
});
// ── POST /v1/submit-prompt — full server-side buyer lifecycle.
// Body: { advert_ref: "<txhash>#<idx>", messages: ChatMessage[],
// payment_lovelace?: string|number, max_output_tokens?: number }
// Returns: { receipt, receipt_signature, escrow_ref, choices?, usage? }
// This endpoint blocks for the duration of the lifecycle (PostEscrow
// confirm → supplier inference → on-chain verify, typically 30–60s on
// testnet). The SDK's submitPrompt() handles every adversarial branch;
// we just translate body↔SDK-shape and re-throw structured errors.
app.post("/v1/submit-prompt", async (req: Request, res: Response) => {
if (!deps.marketplace) {
return jsonError(
res,
503,
"service_unavailable",
"buyer-app booted without Marketplace SDK; /v1/submit-prompt disabled",
);
}
const body = readBody(req.body);
const rawRef = body.advert_ref;
if (typeof rawRef !== "string" || !ESCROW_REF_RE.test(rawRef)) {
return jsonError(
res,
400,
"advert_ref_invalid",
'body must include { "advert_ref": "<64-hex-txhash>#<index>" }',
);
}
const messages = body.messages;
if (!Array.isArray(messages) || messages.length === 0) {
return jsonError(
res,
400,
"messages_required",
"body must include a non-empty messages[] array",
);
}
const m = ESCROW_REF_RE.exec(rawRef)!;
const advertRef = { txHash: m[1], index: Number(m[2]) };
let payment_lovelace: bigint;
try {
payment_lovelace = BigInt(body.payment_lovelace as string | number);
} catch {
return jsonError(
res,
400,
"payment_lovelace_invalid",
'body must include `payment_lovelace` (numeric string)',
);
}
const max_output_tokens =
typeof body.max_output_tokens === "number" ? body.max_output_tokens : undefined;
try {
const result = await deps.marketplace.submitPrompt({
advertRef,
messages: messages as ChatMessage[],
payment_lovelace,
max_output_tokens,
});
// Wrap the assistant text in OpenAI-shape `choices[0].message` so
// downstream clients (PromptForm) can unpack uniformly.
return res.status(200).json({
choices: [{ index: 0, message: { role: "assistant", content: result.response }, finish_reason: "stop" }],
receipt: result.receipt,
receipt_signature: result.receiptSignature,
escrow_ref: `${result.escrowRef.txHash}#${result.escrowRef.index}`,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const reason = err instanceof Error && "reason" in err
? String((err as { reason: unknown }).reason)
: "submit_prompt_failed";
return res.status(502).json({ error: reason, message });
}
});
app.post("/v1/accept", async (req: Request, res: Response) => {
if (!resolved) {
return jsonError(
res,
503,
"service_unavailable",
"buyer-app booted without chain/indexer deps; /v1/accept disabled",
);
}
const acceptBody = readBody(req.body);
const rawRef = acceptBody.escrow_ref;
if (typeof rawRef !== "string" || !ESCROW_REF_RE.test(rawRef)) {
return jsonError(
res,
400,
"escrow_ref_invalid",
'body must be { "escrow_ref": "<64-hex-txhash>#<index>" }',
);
}
// Resolve the current Submitted UTxO. Rows in the same lifecycle share
// posted_at (the validator's expect_unchanged_modulo_state preserves it
// through Open → Claimed → Submitted). Fetch all of the buyer's escrows,
// pick the row matching the user-supplied ref to extract its posted_at,
// then find the Submitted row for that same posted_at. If the user
// already supplied the current Submitted ref, accept it directly.
let currentRefStr: string;
try {
const url = `${resolved.indexerUrl}/escrows?buyer=${resolved.walletKey.pubKeyHash}`;
const response = await resolved.fetchImpl(url);
if (!response.ok) {
return jsonError(
res,
502,
"indexer_error",
`indexer responded ${response.status}: ${response.statusText}`,
);
}
const rows = (await response.json()) as IndexerEscrowRow[];
if (!Array.isArray(rows)) {
return jsonError(
res,
502,
"indexer_error",
"indexer /escrows did not return an array",
);
}
const direct = rows.find(
(r) => r.utxo_ref === rawRef && r.state === "Submitted",
);
if (direct) {
currentRefStr = direct.utxo_ref;
} else {
const lifecycleRow = rows.find((r) => r.utxo_ref === rawRef);
if (!lifecycleRow) {
return jsonError(
res,
404,
"escrow_not_found",
`no escrow row matches ${rawRef} for this buyer`,
);
}
const submitted = rows.find(
(r) =>
r.posted_at === lifecycleRow.posted_at && r.state === "Submitted",
);
if (!submitted) {
return jsonError(
res,
409,
"no_submitted_state",
`lifecycle for ${rawRef} has no current Submitted UTxO (state may be ${lifecycleRow.state})`,
);
}
currentRefStr = submitted.utxo_ref;
}
} catch (err) {
return jsonError(
res,
502,
"indexer_unreachable",
err instanceof Error ? err.message : String(err),
);
}
const m = ESCROW_REF_RE.exec(currentRefStr)!;
const escrowRef = { txHash: m[1], index: Number(m[2]) };
// Pre-flight: the indexer can return stale Submitted rows after an
// accept lands on chain (it doesn't yet propagate spent_slot into the
// escrow row's state). Verify the UTxO is still unspent before
// running the live Accept tx, so we surface a clean 409 JSON instead
// of letting runAccept throw a 5xx with internal Ogmios noise.
try {
const utxo = await resolved.chain.queryUtxo(escrowRef);
if (utxo === null) {
return jsonError(
res,
409,
"already_accepted",
`escrow ${currentRefStr} has already been accepted (UTxO is no longer on chain)`,
);
}
} catch (err) {
// Chain probe failed — proceed to runAccept; if the UTxO is missing,
// runAccept will fail with a structured TxConstructionError which
// becomes a 502 below. Don't block on a transient Ogmios hiccup.
}
try {
const result = await runAccept({
chain: resolved.chain,
walletKey: resolved.walletKey,
escrowRef,
log: (line) => process.stderr.write(`[accept] ${line}\n`),
});
return res.status(200).json({
tx_hash: result.txHash,
accepted_ref: currentRefStr,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return jsonError(res, 502, "accept_failed", message);
}
});
if (deps.distPath) {
let isDir = false;
try {
isDir = existsSync(deps.distPath) && statSync(deps.distPath).isDirectory();
} catch {
isDir = false;
}
if (isDir) {
// Serve hashed asset files via static (CSS, JS chunks, images).
// index.html is intercepted below so we can inject __BUYER_BOOT__.
app.use(express.static(deps.distPath, { index: false }));
// Inject window.__BUYER_BOOT__ into index.html before serving. Only
// PUBLIC fields are sent — pubKeyHash + display address — so the SPA
// can show the user their identity without the server leaking the
// private key into the page bundle. The SPA talks to /v1/* on this
// origin (relative URLs), so no indexer/ogmios endpoints leak either.
const indexPath = `${deps.distPath}/index.html`;
const cachedTemplate = existsSync(indexPath)
? readFileSync(indexPath, "utf8")
: "";
// Display address: Vector L2 uses mainnet byte semantics (header 0x61,
// HRP "addr") regardless of NETWORK_ID. Derive once at boot.
const displayAddress =
resolved !== null
? pkhToMainnetAddress(resolved.walletKey.pubKeyHash)
: "";
const bootScript = resolved !== null
? `<script>window.__BUYER_BOOT__=${JSON.stringify({
walletKey: {
pubKeyHash: resolved.walletKey.pubKeyHash,
address: displayAddress,
},
})};</script>`
: "";
const indexBody = cachedTemplate.replace(
"</head>",
`${bootScript}\n </head>`,
);
app.get(/^\/(?!healthz|v1\/).*/, (_req: Request, res: Response) => {
if (!indexBody) {
return res.status(404).json({ error: "not_found" });
}
res.setHeader("Content-Type", "text/html; charset=utf-8");
return res.send(indexBody);
});
}
}
// Catch-all 404 for unknown paths (after static + SPA fallback).
app.use((_req: Request, res: Response) => {
res.status(404).json({ error: "not_found" });
});
return app;
}

36
buyer/src/ui/App.tsx Normal file
View File

@@ -0,0 +1,36 @@
/**
* buyer/src/ui/App.tsx — router shell.
*
* Routes:
* / → <Dashboard />
* /tasks → <TaskHistory />
* /pending → <PendingReceipts /> UX-1: buyer-side Accept button
* /wallet → <Wallet />
*/
import { Routes, Route, Link } from "react-router-dom";
import Dashboard from "./pages/Dashboard.js";
import TaskHistory from "./pages/TaskHistory.js";
import Wallet from "./pages/Wallet.js";
import PendingReceipts from "./pages/PendingReceipts.js";
export default function App() {
return (
<div className="min-h-screen bg-gray-50 text-gray-900">
<nav className="bg-white border-b border-gray-200 px-6 py-3 flex gap-6">
<Link to="/" className="font-medium hover:underline">Dashboard</Link>
<Link to="/tasks" className="font-medium hover:underline">Tasks</Link>
<Link to="/pending" className="font-medium hover:underline">Pending</Link>
<Link to="/wallet" className="font-medium hover:underline">Wallet</Link>
</nav>
<main className="p-6">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/tasks" element={<TaskHistory />} />
<Route path="/pending" element={<PendingReceipts />} />
<Route path="/wallet" element={<Wallet />} />
</Routes>
</main>
</div>
);
}

View File

@@ -0,0 +1,128 @@
/**
* buyer/src/ui/components/PromptForm.tsx — prompt submission form.
*
* The submit flow runs ENTIRELY server-side via POST /v1/submit-prompt:
* server-side SDK does PostEscrow → supplier inference → on-chain verify
* The browser only sends the prompt + advert_ref and waits for the receipt.
* That keeps the buyer's private key out of the SPA bundle and avoids any
* direct chain-provider call from the browser (the browser SDK has a chain
* stub that throws on use; only the server-side SDK has a real Live chain).
*
* UX:
* - empty content blocks submission client-side (no fetch issued)
* - shows a loading indicator while the server is running the lifecycle
* (typically 30–60s on testnet — PostEscrow confirm + inference)
* - shows an error alert on failure
* - emits onSubmit(result) when the supplier response arrives
*/
import { useState } from "react";
import type { SubmitPromptResult } from "../../sdk/types.js";
import type { OutputReference } from "@marketplace/shared/chain";
export interface PromptFormProps {
advertRef: OutputReference;
payment_lovelace: bigint;
onSubmit?: (result: SubmitPromptResult) => void;
}
interface ServerResponse {
choices?: Array<{ message?: { role: string; content: string } }>;
usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
receipt?: SubmitPromptResult["receipt"];
receipt_signature?: string;
escrow_ref?: string;
error?: string;
message?: string;
}
function refToOutput(refStr: string | undefined): OutputReference | null {
if (!refStr) return null;
const m = /^([0-9a-f]{64})#(\d+)$/.exec(refStr);
if (!m) return null;
return { txHash: m[1], index: Number(m[2]) };
}
export default function PromptForm({ advertRef, payment_lovelace, onSubmit }: PromptFormProps) {
const [content, setContent] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<SubmitPromptResult | null>(null);
const handleSubmit = async (event: React.FormEvent): Promise<void> => {
event.preventDefault();
if (content.trim().length === 0) return;
setLoading(true);
setError(null);
try {
const resp = await fetch("/v1/submit-prompt", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
advert_ref: `${advertRef.txHash}#${advertRef.index}`,
messages: [{ role: "user", content }],
payment_lovelace: payment_lovelace.toString(),
}),
});
const body = (await resp.json()) as ServerResponse;
if (!resp.ok) {
throw new Error(`${body.error ?? resp.statusText}: ${body.message ?? ""}`);
}
const escrow = refToOutput(body.escrow_ref);
if (!escrow || !body.receipt || !body.receipt_signature) {
throw new Error("server response missing escrow/receipt fields");
}
const r: SubmitPromptResult = {
response: body.choices?.[0]?.message?.content ?? "",
receipt: body.receipt,
receiptSignature: body.receipt_signature,
escrowRef: escrow,
};
setResult(r);
if (onSubmit) onSubmit(r);
} catch (err) {
setError((err as Error).message ?? "submit failed");
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-3">
<textarea
className="w-full rounded border border-gray-300 p-3 text-sm font-mono text-gray-900"
rows={6}
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Type a prompt..."
disabled={loading}
aria-label="prompt input"
/>
<div className="flex items-center gap-3">
<button
type="submit"
className="rounded bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:bg-gray-400"
disabled={loading}
>
Submit
</button>
{loading && (
<span data-testid="loading-indicator" role="progressbar" className="text-sm text-gray-300">
Running lifecycle on chain… (30–60s)
</span>
)}
</div>
{error && (
<div role="alert" className="rounded border border-red-300 bg-red-50 p-3 text-sm text-red-700">
{error}
</div>
)}
{result && !error && (
<div className="rounded border border-green-300 bg-green-50 p-3 text-sm text-green-800">
<p className="font-medium">Response</p>
<pre className="whitespace-pre-wrap font-mono text-xs">{result.response}</pre>
</div>
)}
</form>
);
}

View File

@@ -0,0 +1,65 @@
/**
* buyer/src/ui/components/SupplierCard.tsx — displays a single supplier advert.
*
* "Use" button is disabled when supplier.status === "offline".
*/
import type { SupplierView } from "../../sdk/types.js";
export interface SupplierCardProps {
supplier: SupplierView;
onUse?: (supplier: SupplierView) => void;
}
function formatLovelace(lovelace: string): string {
// Tiny helper — no Intl precision required at this stage.
try {
const n = BigInt(lovelace);
const integer = Number(n / 1_000_000n);
const fraction = Number(n % 1_000_000n);
return `${integer}.${String(fraction).padStart(6, "0")} AP3X`;
} catch {
return `${lovelace} lovelace`;
}
}
function statusColour(status: string): string {
if (status === "free") return "bg-green-100 text-green-700";
if (status === "working") return "bg-amber-100 text-amber-700";
return "bg-gray-200 text-gray-600";
}
export default function SupplierCard({ supplier, onUse }: SupplierCardProps) {
const isOffline = supplier.status === "offline";
return (
<div
data-testid="supplier-card"
className="rounded border border-gray-200 bg-white p-4 shadow-sm"
>
<div className="flex items-baseline justify-between">
<h3 className="font-mono text-sm font-medium">{supplier.model}</h3>
<span
className={`rounded px-2 py-0.5 text-xs font-medium ${statusColour(supplier.status)}`}
>
{supplier.status}
</span>
</div>
<p className="mt-1 text-sm text-gray-600">{supplier.capability_id}</p>
<p className="mt-2 text-sm">
<span className="text-gray-500">price:</span>{" "}
<span className="font-mono">{formatLovelace(supplier.price_lovelace)}</span>
</p>
<p className="text-xs text-gray-400 mt-1 break-all">{supplier.endpoint_url}</p>
<button
type="button"
className="mt-3 w-full rounded bg-blue-600 px-3 py-2 text-sm font-medium text-white disabled:bg-gray-300 disabled:text-gray-500"
disabled={isOffline}
onClick={() => {
if (onUse) onUse(supplier);
}}
>
Use
</button>
</div>
);
}

View File

@@ -0,0 +1,46 @@
/**
* buyer/src/ui/components/TaskRow.tsx — a single row in the task history table.
*/
import type { TaskRecord } from "../../sdk/types.js";
export interface TaskRowProps {
task: TaskRecord;
}
function statusPillClass(status: TaskRecord["status"]): string {
switch (status) {
case "completed":
return "bg-green-100 text-green-700";
case "failed":
return "bg-red-100 text-red-700";
case "reclaimed":
return "bg-yellow-100 text-yellow-700";
default:
return "bg-gray-200 text-gray-600";
}
}
function formatDate(ms: number): string {
try {
return new Date(ms).toISOString();
} catch {
return String(ms);
}
}
export default function TaskRow({ task }: TaskRowProps) {
return (
<tr data-testid="task-row" className="border-b border-gray-100">
<td className="px-2 py-2 font-mono text-xs">{formatDate(task.posted_at)}</td>
<td className="px-2 py-2 font-mono text-xs break-all">{task.supplier_pkh.slice(0, 16)}...</td>
<td className="px-2 py-2 text-xs">{task.capability_id}</td>
<td className="px-2 py-2">
<span className={`rounded px-2 py-0.5 text-xs font-medium ${statusPillClass(task.status)}`}>
{task.status}
</span>
</td>
<td className="px-2 py-2 text-xs text-gray-700">{task.prompt_preview}</td>
</tr>
);
}

3
buyer/src/ui/index.css Normal file
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

13
buyer/src/ui/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Marketplace</title>
<link rel="stylesheet" href="./index.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

81
buyer/src/ui/main.tsx Normal file
View File

@@ -0,0 +1,81 @@
/**
* buyer/src/ui/main.tsx — React entrypoint (Vite-driven).
*
* Boots the SPA. The buyer-app server injects:
* <script>window.__BUYER_BOOT__ = { walletKey: { pubKeyHash, address } }</script>
* before </head> in index.html. We pass that public-only identity into the
* SDK so pages can display it. The SPA never holds the private key — all
* tx-construction is server-side via /v1/* endpoints.
*
* Chain/indexer URLs are NOT in the boot block on purpose: the SPA doesn't
* talk to Ogmios directly (the old `ws://localhost:1337` triggered a "this
* site wants to use local devices" prompt), and it talks to the indexer via
* the same-origin buyer-app server proxy at /v1/pending-receipts. The chain
* provider passed to the SDK is a stub that throws — if anything calls it
* from the browser, that's a misroute we want to catch loudly.
*/
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App.js";
import { MarketplaceProvider } from "./state/MarketplaceContext.js";
import { Marketplace, MemoryTaskHistoryStore, LocalStorageTaskHistoryStore } from "../sdk/index.js";
import type { ChainProvider } from "@marketplace/shared/chain";
interface BuyerBoot {
walletKey: {
pubKeyHash: string;
address: string;
};
}
function readBoot(): BuyerBoot {
const w = window as unknown as { __BUYER_BOOT__?: BuyerBoot };
if (w.__BUYER_BOOT__) return w.__BUYER_BOOT__;
return {
walletKey: { pubKeyHash: "", address: "" },
};
}
const boot = readBoot();
const chainStub: ChainProvider = {
tip: () => { throw new Error("browser SPA must not call chain.tip — go through /v1/*"); },
queryUtxo: () => { throw new Error("browser SPA must not call chain.queryUtxo — go through /v1/*"); },
queryUtxosByAddress: () => { throw new Error("browser SPA must not call chain.queryUtxosByAddress — go through /v1/*"); },
evaluateTx: () => { throw new Error("browser SPA must not call chain.evaluateTx — go through /v1/*"); },
submitTx: () => { throw new Error("browser SPA must not call chain.submitTx — go through /v1/*"); },
awaitTx: () => { throw new Error("browser SPA must not call chain.awaitTx — go through /v1/*"); },
};
const historyStore = typeof window !== "undefined" && window.localStorage
? new LocalStorageTaskHistoryStore()
: new MemoryTaskHistoryStore();
const marketplace = new Marketplace({
chain: chainStub,
// Same-origin proxy: SDK calls land on /v1/indexer/<path> on the buyer
// server, which forwards to the internal indexer. Avoids CORS and keeps
// the real indexer URL out of the page bundle.
indexerUrl: window.location.origin + "/v1/indexer",
walletKey: {
pubKeyHash: boot.walletKey.pubKeyHash,
pubKeyHex: "", // not exposed to browser
privateKeyHex: "0".repeat(64), // sentinel: never sign in browser
address: boot.walletKey.address,
},
networkParams: { networkId: 1 }, // Vector L2 = mainnet bytes
historyStore,
});
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("[buyer] #root element not found");
createRoot(rootEl).render(
<StrictMode>
<BrowserRouter>
<MarketplaceProvider marketplace={marketplace}>
<App />
</MarketplaceProvider>
</BrowserRouter>
</StrictMode>,
);

View File

@@ -0,0 +1,77 @@
/**
* buyer/src/ui/pages/Dashboard.tsx — supplier list + new task form.
*
* Calls marketplace.discoverSuppliers() on mount; renders a SupplierCard
* per result. PromptForm requires a selected supplier; until a supplier
* is chosen it is not rendered.
*/
import { useEffect, useState } from "react";
import { useMarketplace } from "../state/MarketplaceContext.js";
import SupplierCard from "../components/SupplierCard.js";
import PromptForm from "../components/PromptForm.js";
import type { SupplierView } from "../../sdk/types.js";
function parseUtxoRef(ref: string): { txHash: string; index: number } | null {
const sepIdx = ref.indexOf("#");
if (sepIdx < 0) return null;
const txHash = ref.slice(0, sepIdx);
const index = Number(ref.slice(sepIdx + 1));
if (!Number.isFinite(index) || index < 0) return null;
return { txHash, index };
}
export default function Dashboard() {
const marketplace = useMarketplace();
const [suppliers, setSuppliers] = useState<SupplierView[]>([]);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<SupplierView | null>(null);
useEffect(() => {
let cancelled = false;
marketplace
.discoverSuppliers()
.then((list) => {
if (!cancelled) setSuppliers(list);
})
.catch((err: Error) => {
if (!cancelled) setError(err.message);
});
return () => {
cancelled = true;
};
}, [marketplace]);
const advertRef =
selected && parseUtxoRef(selected.utxo_ref);
const payment_lovelace = selected ? BigInt(selected.price_lovelace) : 0n;
return (
<div className="space-y-6">
<h1 className="text-2xl font-semibold">Suppliers</h1>
{error && (
<div className="rounded border border-red-300 bg-red-50 p-3 text-sm text-red-700">
{error}
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{suppliers.map((s) => (
<SupplierCard
key={s.utxo_ref}
supplier={s}
onUse={(sup) => setSelected(sup)}
/>
))}
</div>
{selected && advertRef && (
<div className="mt-8">
<h2 className="text-xl font-semibold mb-2">
New prompt → {selected.model}
</h2>
<PromptForm advertRef={advertRef} payment_lovelace={payment_lovelace} />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,269 @@
/**
* buyer/src/ui/pages/PendingReceipts.tsx — list of currently-Submitted escrows
* for this buyer, with an "Accept & Pay" action per row.
*
* Data flow:
* GET /v1/pending-receipts → buyer-app server proxies indexer /escrows?buyer=<pkh>
* and filters to state="Submitted".
* POST /v1/accept → buyer-app server resolves the current Submitted
* UTxO (in case the user hands us an older lifecycle
* ref), calls runAccept against LiveOgmiosProvider,
* returns the Accept tx hash.
*
* The browser never sees the buyer's private key — all signing happens
* server-side. The accept-window timer comes from datum.submitted_at +
* 600_000 ms (the validator's ACCEPT_WINDOW_MS); we surface a countdown so
* operators know when a receipt is about to expire.
*/
import { useCallback, useEffect, useState } from "react";
interface IndexerEscrowRow {
utxo_ref: string;
buyer_pkh: string;
supplier_pkh: string;
capability_id: string;
prompt_hash: string;
payment_lovelace: string;
buyer_bond_lovelace: string;
supplier_bond_lovelace: string;
posted_at: number;
submitted_at: number | null;
result_receipt_hash: string | null;
state: string;
created_slot: number;
}
interface AcceptResult {
tx_hash: string;
accepted_ref: string;
}
interface ApiError {
error: string;
message?: string;
}
const ACCEPT_WINDOW_MS = 600_000;
function fmtLovelace(s: string): string {
try {
const n = BigInt(s);
const ada = Number(n) / 1_000_000;
return `${ada.toFixed(2)} AP3X`;
} catch {
return s;
}
}
function fmtCountdown(ms: number): string {
if (ms <= 0) return "expired";
const totalSec = Math.floor(ms / 1000);
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return `${m}m ${s.toString().padStart(2, "0")}s`;
}
export default function PendingReceipts() {
const [rows, setRows] = useState<IndexerEscrowRow[] | null>(null);
const [loadErr, setLoadErr] = useState<string | null>(null);
const [now, setNow] = useState<number>(Date.now());
const [acceptingRef, setAcceptingRef] = useState<string | null>(null);
const [results, setResults] = useState<Record<string, AcceptResult | string>>({});
const loadPending = useCallback(async (): Promise<void> => {
setLoadErr(null);
try {
const resp = await fetch("/v1/pending-receipts");
if (!resp.ok) {
const body = (await resp.json().catch(() => ({}))) as ApiError;
throw new Error(`${body.error ?? resp.statusText}: ${body.message ?? ""}`);
}
const body = (await resp.json()) as { escrows: IndexerEscrowRow[] };
setRows(body.escrows ?? []);
} catch (err) {
setLoadErr(err instanceof Error ? err.message : String(err));
setRows([]);
}
}, []);
useEffect(() => {
void loadPending();
}, [loadPending]);
// Tick every second so the countdown updates live.
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, []);
const onAccept = async (ref: string): Promise<void> => {
setAcceptingRef(ref);
setResults((prev) => ({ ...prev, [ref]: "submitting" }));
try {
const resp = await fetch("/v1/accept", {
method: "POST",
// `Accept: application/json` discourages CF from substituting an
// HTML 5xx page for browser requests. We still tolerate non-JSON
// bodies below (CF can ignore the hint).
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ escrow_ref: ref }),
});
// Read body once as text so we can show *something* even when the
// response is HTML (CF edge errors) — JSON.parse on HTML throws
// "Unexpected token '<'", which is the bug we're papering over.
const text = await resp.text();
let body: AcceptResult | ApiError | null = null;
try {
body = JSON.parse(text) as AcceptResult | ApiError;
} catch {
body = null;
}
if (!resp.ok) {
const apiErr = (body && typeof body === "object" ? body : {}) as ApiError;
const reason = apiErr.error
?? `http_${resp.status}`;
const detail = apiErr.message
?? text.slice(0, 120).replace(/<[^>]+>/g, " ").trim()
?? `${resp.status} ${resp.statusText}`;
setResults((prev) => ({
...prev,
[ref]: `error: ${reason}${detail ? " — " + detail : ""}`,
}));
// For "already_accepted" specifically, the row is stale — refresh
// the list so the user sees it disappear.
if (apiErr.error === "already_accepted") {
await loadPending();
}
return;
}
if (!body) {
setResults((prev) => ({
...prev,
[ref]: `error: malformed_response — server returned non-JSON`,
}));
return;
}
setResults((prev) => ({ ...prev, [ref]: body as AcceptResult }));
await loadPending();
} catch (err) {
setResults((prev) => ({
...prev,
[ref]: `error: ${err instanceof Error ? err.message : String(err)}`,
}));
} finally {
setAcceptingRef(null);
}
};
return (
<div className="space-y-4" data-testid="pending-receipts-page">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">Pending Receipts</h1>
<button
type="button"
onClick={loadPending}
className="rounded border border-gray-300 bg-white px-3 py-1 text-sm hover:bg-gray-50"
data-testid="pending-refresh"
>
refresh
</button>
</div>
{loadErr ? (
<p className="text-sm text-red-700" role="alert">
Failed to load pending receipts: {loadErr}
</p>
) : null}
{rows === null ? (
<p className="text-sm text-gray-500">loading…</p>
) : rows.length === 0 ? (
<p className="text-sm text-gray-500" data-testid="pending-empty">
No pending receipts. When the supplier submits a result on chain,
it'll appear here with an Accept button.
</p>
) : (
<table className="min-w-full text-sm">
<thead>
<tr className="border-b border-gray-200 text-left">
<th className="px-2 py-2">Submitted</th>
<th className="px-2 py-2">Supplier</th>
<th className="px-2 py-2">Capability</th>
<th className="px-2 py-2">Payment</th>
<th className="px-2 py-2">Window left</th>
<th className="px-2 py-2">Action</th>
</tr>
</thead>
<tbody>
{rows.map((r) => {
const submittedAt = r.submitted_at ?? 0;
const windowMsLeft = submittedAt + ACCEPT_WINDOW_MS - now;
const expired = windowMsLeft <= 0;
const result = results[r.utxo_ref];
const isAccepting = acceptingRef === r.utxo_ref;
return (
<tr
key={r.utxo_ref}
data-testid="pending-row"
className="border-b border-gray-100 align-top"
>
<td className="px-2 py-2 font-mono text-xs">
{submittedAt > 0 ? new Date(submittedAt).toISOString() : "—"}
</td>
<td className="px-2 py-2 font-mono text-xs break-all">
{r.supplier_pkh.slice(0, 16)}…
</td>
<td className="px-2 py-2 text-xs">{r.capability_id}</td>
<td className="px-2 py-2 text-xs">
{fmtLovelace(r.payment_lovelace)}
</td>
<td className="px-2 py-2">
<span
className={
"rounded px-2 py-0.5 font-mono text-xs " +
(expired
? "bg-red-100 text-red-700"
: windowMsLeft < 120_000
? "bg-yellow-100 text-yellow-800"
: "bg-green-100 text-green-700")
}
>
{fmtCountdown(windowMsLeft)}
</span>
</td>
<td className="px-2 py-2">
{typeof result === "object" ? (
<span className="font-mono text-xs text-green-700" data-testid="pending-accepted">
accepted ✓ {result.tx_hash.slice(0, 12)}…
</span>
) : typeof result === "string" && result.startsWith("error") ? (
<span className="text-xs text-red-700">{result}</span>
) : (
<button
type="button"
disabled={expired || isAccepting}
onClick={() => void onAccept(r.utxo_ref)}
data-testid="accept-button"
className={
"rounded px-3 py-1 text-sm font-medium " +
(expired
? "bg-gray-200 text-gray-400 cursor-not-allowed"
: isAccepting
? "bg-indigo-300 text-white cursor-wait"
: "bg-indigo-600 text-white hover:bg-indigo-700")
}
>
{isAccepting ? "accepting…" : expired ? "expired" : "Accept & Pay"}
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
);
}

View File

@@ -0,0 +1,43 @@
/**
* buyer/src/ui/pages/TaskHistory.tsx — task history list.
*/
import { useEffect, useState } from "react";
import { useMarketplace } from "../state/MarketplaceContext.js";
import TaskRow from "../components/TaskRow.js";
import type { TaskRecord } from "../../sdk/types.js";
export default function TaskHistory() {
const marketplace = useMarketplace();
const [tasks, setTasks] = useState<TaskRecord[]>([]);
useEffect(() => {
setTasks(marketplace.getTaskHistory());
}, [marketplace]);
return (
<div className="space-y-4">
<h1 className="text-2xl font-semibold">Tasks</h1>
{tasks.length === 0 ? (
<p className="text-gray-500">No tasks yet.</p>
) : (
<table className="min-w-full text-sm">
<thead>
<tr className="border-b border-gray-200 text-left">
<th className="px-2 py-2">Posted</th>
<th className="px-2 py-2">Supplier</th>
<th className="px-2 py-2">Capability</th>
<th className="px-2 py-2">Status</th>
<th className="px-2 py-2">Prompt</th>
</tr>
</thead>
<tbody>
{tasks.map((t) => (
<TaskRow key={t.escrow_ref} task={t} />
))}
</tbody>
</table>
)}
</div>
);
}

View File

@@ -0,0 +1,25 @@
/**
* buyer/src/ui/pages/Wallet.tsx — wallet address + balance view.
*/
import { useMarketplace } from "../state/MarketplaceContext.js";
export default function Wallet() {
const marketplace = useMarketplace();
const walletKey = marketplace.getWalletKey();
return (
<div className="space-y-4">
<h1 className="text-2xl font-semibold">Wallet</h1>
<dl className="space-y-2">
<div>
<dt className="text-sm text-gray-500">Address</dt>
<dd className="font-mono text-sm break-all">{walletKey.address}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Public key hash</dt>
<dd className="font-mono text-sm break-all">{walletKey.pubKeyHash}</dd>
</div>
</dl>
</div>
);
}

View File

@@ -0,0 +1,37 @@
/**
* buyer/src/ui/state/MarketplaceContext.tsx — React context wrapping a Marketplace instance.
*
* Provides the shared SDK instance to every page/component via React context.
* Tests inject a mock Marketplace; in production the boot script constructs
* one from BuyerConfig and wraps the app with <MarketplaceProvider>.
*/
import React, { createContext, useContext } from "react";
import type { Marketplace } from "../../sdk/Marketplace.js";
export interface MarketplaceContextValue {
marketplace: Marketplace | null;
}
export const MarketplaceContext = createContext<MarketplaceContextValue>({ marketplace: null });
export function useMarketplace(): Marketplace {
const { marketplace } = useContext(MarketplaceContext);
if (!marketplace) {
throw new Error("useMarketplace: no Marketplace in context — wrap app in <MarketplaceProvider>");
}
return marketplace;
}
export interface MarketplaceProviderProps {
marketplace: Marketplace;
children: React.ReactNode;
}
export function MarketplaceProvider({ marketplace, children }: MarketplaceProviderProps) {
return (
<MarketplaceContext.Provider value={{ marketplace }}>
{children}
</MarketplaceContext.Provider>
);
}

View File

@@ -0,0 +1,28 @@
/**
* blueprintStub.ts — browser stub for packages/shared/src/tx/blueprint.ts
*
* `tx/blueprint.ts` reads `contracts/marketplace/plutus.json` from disk via
* Node's `fs`/`path`/`url` builtins. The buyer-app's React SPA bundle
* transitively imports `loadBlueprint` (through `Marketplace.ts` → tx/index
* → escrow/postEscrow), but never CALLS it — every actual tx-construction
* path lives server-side behind /v1/* endpoints. This stub is aliased in
* vite.config.ts so the browser bundle gets a safe no-op instead of pulling
* Node builtins into Rollup's analyzer.
*
* If anything in the browser does call loadBlueprint() at runtime, that's a
* design error — fail loudly so it's caught.
*/
export interface Blueprint {
advertScriptHash: string;
escrowScriptHash: string;
advertScriptAddress(networkId: 0 | 1): string;
escrowScriptAddress(networkId: 0 | 1): string;
}
export function loadBlueprint(): Blueprint {
throw new Error(
"loadBlueprint() called from the browser bundle — tx construction must " +
"happen server-side via /v1/* endpoints, not in the SPA.",
);
}

14
buyer/tailwind.config.js Normal file
View File

@@ -0,0 +1,14 @@
/**
* buyer/tailwind.config.js — Tailwind utility scan paths.
*/
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./src/ui/**/*.{ts,tsx,html}",
],
theme: {
extend: {},
},
plugins: [],
};

10
buyer/tsconfig.json Normal file
View File

@@ -0,0 +1,10 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"]
},
"include": ["src/**/*.ts", "src/**/*.tsx"]
}

49
buyer/vite.config.ts Normal file
View File

@@ -0,0 +1,49 @@
/**
* buyer/vite.config.ts — Vite config for the React UI bundle.
*
* The UI source lives at buyer/src/ui (with index.html alongside). The build
* output goes to buyer/dist; the Express server in production mounts that
* directory via createApp({ distPath }).
*
* SPEC FIX 2026-04-27 (M1-F-1): alias array uses regex `find` for the bare
* `@marketplace/shared` so it does NOT prefix-match sub-paths like
* `@marketplace/shared/chain`. The previous object-form aliases were matched
* in insertion order; the broad `@marketplace/shared` would rewrite
* `@marketplace/shared/chain` to `…/index.ts/chain` (a path under a file)
* which broke the production Vite build. Sub-path aliases come BEFORE the
* bare-name regex regardless, so the order here is robust either way.
*/
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { resolve } from "path";
export default defineConfig({
plugins: [react()],
root: resolve(__dirname, "src/ui"),
build: {
outDir: resolve(__dirname, "dist"),
emptyOutDir: true,
},
server: {
port: 5173,
},
resolve: {
alias: [
// Sub-path aliases first (longest prefixes win on string matching).
{ find: "@marketplace/shared/chain", replacement: resolve(__dirname, "../packages/shared/src/chain/index.ts") },
{ find: "@marketplace/shared/cbor", replacement: resolve(__dirname, "../packages/shared/src/cbor/index.ts") },
{ find: "@marketplace/shared/receipt", replacement: resolve(__dirname, "../packages/shared/src/receipt/index.ts") },
{ find: "@marketplace/shared/tx", replacement: resolve(__dirname, "../packages/shared/src/tx/index.ts") },
{ find: "@marketplace/shared/network", replacement: resolve(__dirname, "../packages/shared/src/network.ts") },
// Bare alias as a regex anchored to end — only matches exact
// `@marketplace/shared`, never a sub-path.
{ find: /^@marketplace\/shared$/, replacement: resolve(__dirname, "../packages/shared/src/index.ts") },
// Browser stub for tx/blueprint.ts. The real module reads plutus.json
// via fs/path/url; the SPA transitively imports `loadBlueprint` but
// never calls it (tx construction is server-side via /v1/*). Aliasing
// to a stub keeps Node builtins out of the Rollup browser bundle.
{ find: /\/packages\/shared\/src\/tx\/blueprint\.ts$/, replacement: resolve(__dirname, "src/ui/stubs/blueprintStub.ts") },
],
},
});

View File

@@ -0,0 +1,4 @@
## AdvertScript (Aiken)
Aiken source goes here in M1. See `docs/ARCHITECTURE.md` section 4.1 for the
datum schema and redeemer set (PostAdvert | UpdateAdvert | RetireAdvert).

View File

@@ -0,0 +1,4 @@
## EscrowScript (Aiken)
Aiken source goes here in M1. See `docs/ARCHITECTURE.md` sections 4.2 and 4.3 for the
datum schema, redeemers, and state-machine transitions this validator must enforce.

View File

@@ -0,0 +1,18 @@
name: Continuous Integration
on:
push:
branches: ["main"]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: aiken-lang/setup-aiken@v1
with:
version: v1.1.21
- run: aiken fmt --check
- run: aiken check -D
- run: aiken build

6
contracts/marketplace/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
# Aiken compilation artifacts
artifacts/
# Aiken's project working directory
build/
# Aiken's default documentation export
docs/

View File

@@ -0,0 +1,8 @@
# local-agents/marketplace
Aiken validators for the Local Agents Marketplace (M1, v1). Two validators ship from this project:
- **AdvertScript** (`validators/advert.ak`) — supplier-owned advertisement registry; redeemers `PostAdvert | UpdateAdvert | RetireAdvert`.
- **EscrowScript** (`validators/escrow.ak`) — happy-path bonded escrow, state machine `Open → Claimed → Submitted → Accepted | Released` (and `Open|Claimed → Reclaimed`); redeemers `Claim | Submit | Accept | Reclaim | Release`.
Datum schemas mirror `packages/shared/src/cbor/types.ts` byte-for-byte (Plutus Constr0 with fields in declaration order). Spec: `docs/ARCHITECTURE.md` §4. `ACCEPT_WINDOW = 600_000 ms` (10 min) lives in `lib/marketplace/common.ak`. Compile with `aiken check && aiken build`; the resulting `plutus.json` is consumed by M1-B's tx builders.

View File

@@ -0,0 +1,15 @@
# This file was generated by Aiken
# You typically do not need to edit this file
[[requirements]]
name = "aiken-lang/stdlib"
version = "v3.1.0"
source = "github"
[[packages]]
name = "aiken-lang/stdlib"
version = "v3.1.0"
requirements = []
source = "github"
[etags]

View File

@@ -0,0 +1,18 @@
name = "local-agents/marketplace"
version = "0.1.0"
compiler = "v1.1.21"
plutus = "v3"
license = "Apache-2.0"
description = "Local Agents Marketplace — AdvertScript and EscrowScript validators (v1, happy-path only)."
[repository]
user = "local-agents"
project = "marketplace"
platform = "github"
[[dependencies]]
name = "aiken-lang/stdlib"
version = "v3.1.0"
source = "github"
[config]

View File

@@ -0,0 +1,202 @@
//// Shared helpers for the AdvertScript and EscrowScript validators.
////
//// Conventions:
//// - All time values are POSIX milliseconds (matches TS `POSIXTime`).
//// - All "must be signed by X" checks compare a `VerificationKeyHash`
//// against `tx.extra_signatories`. We do NOT inspect input owners; the
//// ledger ensures `extra_signatories` carries the witness keys.
//// - Time-bound checks are conservative: we require the worst-case
//// boundary of the validity range to satisfy the rule, so the script
//// cannot be tricked by a wide validity window.
use aiken/interval.{
Finite, Interval, IntervalBound, NegativeInfinity, PositiveInfinity,
}
use aiken/crypto.{VerificationKeyHash}
use cardano/address.{Credential, Script, VerificationKey}
use cardano/assets.{Value}
use cardano/transaction.{Output, ValidityRange}
/// 10 minutes in milliseconds. The buyer has this long after the supplier's
/// `submitted_at` to call `Accept`; after the window the supplier may
/// `Release` and walk away with the buyer bond. ARCHITECTURE.md §4.3.
pub const accept_window_ms: Int = 600_000
// ─── Signer checks ───────────────────────────────────────────────────
/// True iff `signer` appears in the transaction's required signers list.
pub fn signed_by(extra_signatories: List<VerificationKeyHash>, signer: VerificationKeyHash) -> Bool {
list_contains_pkh(extra_signatories, signer)
}
fn list_contains_pkh(xs: List<VerificationKeyHash>, target: VerificationKeyHash) -> Bool {
when xs is {
[] -> False
[head, ..tail] ->
if head == target {
True
} else {
list_contains_pkh(tail, target)
}
}
}
// ─── Time-bound checks ───────────────────────────────────────────────
/// True iff every point in the validity range is `<= deadline`.
/// In other words, the upper bound is finite and at most `deadline`.
/// We use the inclusive interpretation: an upper bound of exactly `deadline`
/// is allowed (validity ranges in the ledger have inclusive upper bounds in
/// the common case). This is the rule for `Claim` and `Submit`: the tx must
/// be impossible to drift past `deliver_by`.
pub fn before_or_at(range: ValidityRange, deadline: Int) -> Bool {
when range.upper_bound.bound_type is {
Finite(upper) ->
if range.upper_bound.is_inclusive {
upper <= deadline
} else {
// Exclusive upper bound: any point < upper. We require the strict
// upper to be at most deadline + 1 (i.e. all points <= deadline).
upper <= deadline + 1
}
NegativeInfinity -> True
PositiveInfinity -> False
}
}
/// True iff every point in the validity range is `>= threshold`.
/// Used for `Reclaim` (after `deliver_by`) and `Release` (after
/// `submitted_at + ACCEPT_WINDOW`). We use the inclusive interpretation:
/// a lower bound of exactly `threshold` is allowed.
pub fn after_or_at(range: ValidityRange, threshold: Int) -> Bool {
when range.lower_bound.bound_type is {
Finite(lower) ->
if range.lower_bound.is_inclusive {
lower >= threshold
} else {
// Exclusive lower bound: any point > lower. We require lower >=
// threshold - 1 (i.e. all points >= threshold).
lower >= threshold - 1
}
PositiveInfinity -> True
NegativeInfinity -> False
}
}
/// Pick a representative POSIX timestamp from the validity range to stamp
/// onto an on-chain field (`submitted_at`). We use the **upper bound**: the
/// latest possible time the tx could have been included. Cardano's standard
/// convention. The bound must be finite for this to be meaningful.
pub fn upper_bound_of(range: ValidityRange) -> Option<Int> {
when range.upper_bound.bound_type is {
Finite(t) -> Some(t)
_ -> None
}
}
// ─── Address / output helpers ────────────────────────────────────────
/// True iff the output's payment credential is a verification-key with the
/// given hash. Used by `RetireAdvert` to ensure the deposit goes back to the
/// supplier's own wallet rather than another script address.
pub fn output_paid_to_pkh(output: Output, pkh: VerificationKeyHash) -> Bool {
when output.address.payment_credential is {
VerificationKey(vk) -> vk == pkh
_ -> False
}
}
/// True iff the output's payment credential is a script with the given
/// script hash. Used to find the "continuing" output for advert/escrow
/// state transitions.
pub fn output_paid_to_script(output: Output, script_hash: ByteArray) -> Bool {
when output.address.payment_credential is {
Script(sh) -> sh == script_hash
_ -> False
}
}
/// True iff the output's payment credential equals the given Credential.
/// More flexible than `output_paid_to_script` when callers already have a
/// `Credential` (e.g. taken from the script's own input).
pub fn output_paid_to_credential(output: Output, credential: Credential) -> Bool {
output.address.payment_credential == credential
}
/// Sum of lovelace across all outputs paying to the given pkh address
/// (verification-key payment credential, any/no stake credential).
pub fn lovelace_paid_to_pkh(outputs: List<Output>, pkh: VerificationKeyHash) -> Int {
sum_lovelace_to_pkh(outputs, pkh, 0)
}
fn sum_lovelace_to_pkh(outputs: List<Output>, pkh: VerificationKeyHash, acc: Int) -> Int {
when outputs is {
[] -> acc
[head, ..tail] ->
if output_paid_to_pkh(head, pkh) {
sum_lovelace_to_pkh(tail, pkh, acc + assets.lovelace_of(head.value))
} else {
sum_lovelace_to_pkh(tail, pkh, acc)
}
}
}
/// Total lovelace across all outputs paid to the given Credential.
pub fn lovelace_paid_to_credential(outputs: List<Output>, credential: Credential) -> Int {
sum_lovelace_to_credential(outputs, credential, 0)
}
fn sum_lovelace_to_credential(outputs: List<Output>, credential: Credential, acc: Int) -> Int {
when outputs is {
[] -> acc
[head, ..tail] ->
if output_paid_to_credential(head, credential) {
sum_lovelace_to_credential(tail, credential, acc + assets.lovelace_of(head.value))
} else {
sum_lovelace_to_credential(tail, credential, acc)
}
}
}
/// Find the unique output paying to the given Credential. `fail`s if there
/// is not exactly one. Use this to locate the "continuing" UTxO for the
/// advert/escrow validator's same-address rebind.
pub fn unique_output_to_credential(outputs: List<Output>, credential: Credential) -> Output {
let matches =
list_filter_outputs_to_credential(outputs, credential, [])
when matches is {
[single] -> single
_ -> fail @"expected exactly one continuing output to script address"
}
}
fn list_filter_outputs_to_credential(
outputs: List<Output>,
credential: Credential,
acc: List<Output>,
) -> List<Output> {
when outputs is {
[] -> reverse_list(acc, [])
[head, ..tail] ->
if output_paid_to_credential(head, credential) {
list_filter_outputs_to_credential(tail, credential, [head, ..acc])
} else {
list_filter_outputs_to_credential(tail, credential, acc)
}
}
}
fn reverse_list(xs: List<Output>, acc: List<Output>) -> List<Output> {
when xs is {
[] -> acc
[head, ..tail] -> reverse_list(tail, [head, ..acc])
}
}
// ─── Value comparison helpers ────────────────────────────────────────
/// True iff two values carry the same lovelace AND the same non-ADA assets.
/// Used to enforce "value preserved" on Claim and Submit transitions.
pub fn value_equal(a: Value, b: Value) -> Bool {
a == b
}

View File

@@ -0,0 +1,127 @@
//// Datum schemas for the Local Agents Marketplace.
////
//// On-chain encoding mirrors `packages/shared/src/cbor/types.ts`:
//// - Records serialize as Plutus `Constr0` (CBOR tag 121) with fields in
//// declaration order.
//// - Sum-type variants are tagged 121, 122, ... in declaration order, so
//// **declaration order is load-bearing**.
//// - `Option<T>` is the stdlib `Option`; `Some(v)` = tag 121 [v],
//// `None` = tag 122 []. (Stdlib re-exports it from `aiken/option`.)
//// - `OutputReference` from `cardano/transaction` is `Constr0[bytes, int]`
//// (`transaction_id`, `output_index`), matching the TS encoder's
//// `Constr0[txHash, index]`.
////
//// Cross-reference: `docs/ARCHITECTURE.md` §4.1, §4.2.
use aiken/crypto.{VerificationKeyHash}
use cardano/transaction.{OutputReference}
/// AdvertStatus mirrors the TS literal type:
/// Active -> Constr 121 []
/// Retired -> Constr 122 []
/// Declaration order MUST stay Active, then Retired.
pub type AdvertStatus {
Active
Retired
}
/// EscrowState mirrors the TS string-union, tag layout 121..126:
/// Open=121, Claimed=122, Submitted=123,
/// Accepted=124, Reclaimed=125, Released=126
/// Declaration order MUST match this exact sequence — it is checked against
/// `STATE_TAGS` in `packages/shared/src/cbor/EscrowDatum.ts`.
pub type EscrowState {
Open
Claimed
Submitted
Accepted
Reclaimed
Released
}
/// AdvertDatum — ARCHITECTURE.md §4.1. 13 fields, declaration order matches
/// the TS encoder in `packages/shared/src/cbor/AdvertDatum.ts`.
///
/// 0 supplier_pkh ByteArray(28)
/// 1 capability_id ByteArray (UTF-8)
/// 2 model ByteArray (UTF-8)
/// 3 max_output_tokens Int
/// 4 max_processing_ms Int
/// 5 price_lovelace Int
/// 6 supplier_bond_lovelace Int
/// 7 buyer_bond_lovelace Int
/// 8 endpoint_url ByteArray (UTF-8)
/// 9 detail_uri ByteArray (UTF-8)
/// 10 detail_hash ByteArray(32)
/// 11 advertised_at Int (POSIX ms)
/// 12 status AdvertStatus
pub type AdvertDatum {
supplier_pkh: VerificationKeyHash,
capability_id: ByteArray,
model: ByteArray,
max_output_tokens: Int,
max_processing_ms: Int,
price_lovelace: Int,
supplier_bond_lovelace: Int,
buyer_bond_lovelace: Int,
endpoint_url: ByteArray,
detail_uri: ByteArray,
detail_hash: ByteArray,
advertised_at: Int,
status: AdvertStatus,
}
/// EscrowDatum — ARCHITECTURE.md §4.2. 14 fields, declaration order matches
/// the TS encoder in `packages/shared/src/cbor/EscrowDatum.ts`.
///
/// 0 buyer_pkh ByteArray(28)
/// 1 supplier_pkh ByteArray(28)
/// 2 advert_ref OutputReference (Constr0[bytes, int])
/// 3 capability_id ByteArray (UTF-8)
/// 4 request_spec_hash ByteArray(32)
/// 5 prompt_hash ByteArray(32)
/// 6 payment_lovelace Int
/// 7 buyer_bond_lovelace Int
/// 8 supplier_bond_lovelace Int
/// 9 deliver_by Int (POSIX ms)
/// 10 posted_at Int (POSIX ms)
/// 11 submitted_at Option<Int>
/// 12 result_receipt_hash Option<ByteArray>
/// 13 state EscrowState
pub type EscrowDatum {
buyer_pkh: VerificationKeyHash,
supplier_pkh: VerificationKeyHash,
advert_ref: OutputReference,
capability_id: ByteArray,
request_spec_hash: ByteArray,
prompt_hash: ByteArray,
payment_lovelace: Int,
buyer_bond_lovelace: Int,
supplier_bond_lovelace: Int,
deliver_by: Int,
posted_at: Int,
submitted_at: Option<Int>,
result_receipt_hash: Option<ByteArray>,
state: EscrowState,
}
/// AdvertScript redeemer set. Declaration order is informational here — the
/// tx builder constructs the redeemer Data; tags 121/122/123 in declaration
/// order match the M1-B encoder.
pub type AdvertRedeemer {
PostAdvert
UpdateAdvert
RetireAdvert
}
/// EscrowScript redeemer set. Tags 121..125 in declaration order:
/// Claim=121, Submit=122, Accept=123, Reclaim=124, Release=125.
/// `Submit` carries the receipt-hash payload chosen by the supplier; the
/// validator binds it into `result_receipt_hash` of the new datum.
pub type EscrowRedeemer {
Claim
Submit { receipt_hash: ByteArray }
Accept
Reclaim
Release
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,336 @@
//// AdvertScript — supplier advertisement registry.
////
//// ARCHITECTURE.md §4.1. Three logical operations on an advert UTxO:
////
//// PostAdvert — Create a fresh advert UTxO with status: Active.
//// This is a CREATION event; spending validators do not
//// run on output creation. PostAdvert is enforced
//// OFF-CHAIN by M1-B's tx builder (datum shape, signer).
//// We declare it as a redeemer constructor for symmetry
//// with the TS side, but the on-chain `spend` handler
//// does not see it.
////
//// UpdateAdvert — Spend an existing advert UTxO and produce a new one
//// at the same script address. Same supplier_pkh in old
//// and new datum; new advertised_at >= old advertised_at;
//// status may flip Active <-> Retired or stay; signed by
//// supplier_pkh.
////
//// RetireAdvert — Spend the advert UTxO; produce an output to the
//// supplier's wallet (verification-key payment cred).
//// Returns the deposit. Signed by supplier_pkh.
////
//// Cross-spec note: ARCHITECTURE.md does not require the new advert UTxO
//// after `UpdateAdvert` to preserve any specific value, only that it remain
//// at the same script address. We do NOT enforce a value-preservation rule
//// here — the tx builder (M1-B) is responsible for choosing a deposit.
use cardano/address.{Address, Script, VerificationKey}
use cardano/assets
use cardano/transaction.{
InlineDatum, Input, NoDatum, Output, OutputReference, Transaction, find_input,
}
use marketplace/common.{
output_paid_to_pkh, signed_by, unique_output_to_credential,
}
use marketplace/types.{
Active, AdvertDatum, AdvertRedeemer, PostAdvert, Retired, RetireAdvert,
UpdateAdvert,
}
validator advert {
spend(
datum: Option<AdvertDatum>,
redeemer: AdvertRedeemer,
own_ref: OutputReference,
self: Transaction,
) {
expect Some(old_datum) = datum
when redeemer is {
// PostAdvert is a creation-event rule and cannot occur as a spend
// redeemer — there is nothing to spend at creation. If it ever shows
// up here, the tx is malformed and we reject.
PostAdvert -> False
UpdateAdvert -> handle_update(old_datum, own_ref, self)
RetireAdvert -> handle_retire(old_datum, self)
}
}
}
// ─── UpdateAdvert ────────────────────────────────────────────────────
fn handle_update(
old_datum: AdvertDatum,
own_ref: OutputReference,
self: Transaction,
) -> Bool {
// Find our own input to learn its address (we re-bind to that same
// address on output).
expect Some(own_input) = find_input(self.inputs, own_ref)
let own_credential = own_input.output.address.payment_credential
// Exactly one continuing output to the same script credential.
let continuing = unique_output_to_credential(self.outputs, own_credential)
// The new datum must be inline and parse as an AdvertDatum.
expect new_datum: AdvertDatum = inline_datum_of(continuing)
let signer_ok = signed_by(self.extra_signatories, old_datum.supplier_pkh)
let supplier_unchanged = new_datum.supplier_pkh == old_datum.supplier_pkh
let timestamp_monotonic = new_datum.advertised_at >= old_datum.advertised_at
signer_ok && supplier_unchanged && timestamp_monotonic
}
// ─── RetireAdvert ────────────────────────────────────────────────────
fn handle_retire(old_datum: AdvertDatum, self: Transaction) -> Bool {
// Signed by the supplier.
let signer_ok = signed_by(self.extra_signatories, old_datum.supplier_pkh)
// At least one output pays back to the supplier's wallet (verification-key
// payment credential). We don't require a specific amount — the deposit
// size is M1-B's policy choice, and locking it here would couple the
// validator to a TS-side constant.
let returns_to_supplier =
output_to_supplier_present(self.outputs, old_datum.supplier_pkh)
signer_ok && returns_to_supplier
}
fn output_to_supplier_present(
outputs: List<Output>,
supplier_pkh: ByteArray,
) -> Bool {
when outputs is {
[] -> False
[head, ..tail] ->
if output_paid_to_pkh(head, supplier_pkh) {
True
} else {
output_to_supplier_present(tail, supplier_pkh)
}
}
}
// ─── Datum extraction ────────────────────────────────────────────────
fn inline_datum_of(output: Output) -> Data {
when output.datum is {
InlineDatum(d) -> d
_ -> fail @"continuing advert output must carry an inline datum"
}
}
// ─── Tests ───────────────────────────────────────────────────────────
//
// Aiken `test` blocks below cover the happy and adversarial paths called
// out in the M1-A brief and ARCHITECTURE.md §7.3.
const supplier_a: ByteArray = #"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
const supplier_b: ByteArray = #"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
const advert_script_hash: ByteArray =
#"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
const detail_hash_sample: ByteArray =
#"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
fn advert_address() -> Address {
Address {
payment_credential: Script(advert_script_hash),
stake_credential: None,
}
}
fn supplier_address(pkh: ByteArray) -> Address {
Address {
payment_credential: VerificationKey(pkh),
stake_credential: None,
}
}
fn fresh_active_advert(supplier_pkh: ByteArray, ts: Int) -> AdvertDatum {
AdvertDatum {
supplier_pkh,
capability_id: "llm.text.generate.v1",
model: "qwen2.5:0.5b",
max_output_tokens: 512,
max_processing_ms: 60_000,
price_lovelace: 1_000_000,
supplier_bond_lovelace: 1_000_000,
buyer_bond_lovelace: 1_000_000,
endpoint_url: "https://supplier.example.com/v1",
detail_uri: "ipfs://Qm...",
detail_hash: detail_hash_sample,
advertised_at: ts,
status: Active,
}
}
fn advert_input(ref: OutputReference, datum: AdvertDatum) -> Input {
Input {
output_reference: ref,
output: Output {
address: advert_address(),
value: assets.from_lovelace(2_000_000),
datum: InlineDatum(datum),
reference_script: None,
},
}
}
fn advert_output(datum: AdvertDatum) -> Output {
Output {
address: advert_address(),
value: assets.from_lovelace(2_000_000),
datum: InlineDatum(datum),
reference_script: None,
}
}
fn supplier_output(pkh: ByteArray) -> Output {
Output {
address: supplier_address(pkh),
value: assets.from_lovelace(2_000_000),
datum: NoDatum,
reference_script: None,
}
}
fn placeholder_ref(ix: Int) -> OutputReference {
OutputReference {
transaction_id: #"0000000000000000000000000000000000000000000000000000000000000000",
output_index: ix,
}
}
fn base_tx() -> Transaction {
transaction.placeholder
}
// ─── Happy: PostAdvert (off-chain shape rule) ─────────────────────────
// PostAdvert is enforced off-chain (no spend triggered at creation). This
// test asserts that a freshly constructed advert datum has the spec-required
// shape — Active status, advertised_at populated, supplier_pkh non-empty.
test post_advert_shape_is_well_formed() {
let d = fresh_active_advert(supplier_a, 1_000_000)
d.status == Active && d.advertised_at == 1_000_000 && d.supplier_pkh == supplier_a
}
// ─── Happy: UpdateAdvert ──────────────────────────────────────────────
test update_advert_happy_path() {
let own_ref = placeholder_ref(0)
let old_datum = fresh_active_advert(supplier_a, 1_000_000)
let new_datum = fresh_active_advert(supplier_a, 2_000_000)
let t =
Transaction {
..base_tx(),
inputs: [advert_input(own_ref, old_datum)],
outputs: [advert_output(new_datum)],
extra_signatories: [supplier_a],
}
handle_update(old_datum, own_ref, t)
}
test update_advert_active_to_retired_allowed() {
let own_ref = placeholder_ref(0)
let old_datum = fresh_active_advert(supplier_a, 1_000_000)
let new_datum =
AdvertDatum { ..old_datum, status: Retired, advertised_at: 1_500_000 }
let t =
Transaction {
..base_tx(),
inputs: [advert_input(own_ref, old_datum)],
outputs: [advert_output(new_datum)],
extra_signatories: [supplier_a],
}
handle_update(old_datum, own_ref, t)
}
// ─── Adversarial: UpdateAdvert ────────────────────────────────────────
test update_advert_rejects_different_supplier() fail {
let own_ref = placeholder_ref(0)
let old_datum = fresh_active_advert(supplier_a, 1_000_000)
let new_datum = fresh_active_advert(supplier_b, 2_000_000)
let t =
Transaction {
..base_tx(),
inputs: [advert_input(own_ref, old_datum)],
outputs: [advert_output(new_datum)],
extra_signatories: [supplier_a],
}
expect handle_update(old_datum, own_ref, t)
True
}
test update_advert_rejects_unsigned() fail {
let own_ref = placeholder_ref(0)
let old_datum = fresh_active_advert(supplier_a, 1_000_000)
let new_datum = fresh_active_advert(supplier_a, 2_000_000)
let t =
Transaction {
..base_tx(),
inputs: [advert_input(own_ref, old_datum)],
outputs: [advert_output(new_datum)],
extra_signatories: [],
}
expect handle_update(old_datum, own_ref, t)
True
}
test update_advert_rejects_timestamp_regress() fail {
let own_ref = placeholder_ref(0)
let old_datum = fresh_active_advert(supplier_a, 2_000_000)
let new_datum = fresh_active_advert(supplier_a, 1_000_000)
let t =
Transaction {
..base_tx(),
inputs: [advert_input(own_ref, old_datum)],
outputs: [advert_output(new_datum)],
extra_signatories: [supplier_a],
}
expect handle_update(old_datum, own_ref, t)
True
}
// ─── Happy: RetireAdvert ──────────────────────────────────────────────
test retire_advert_happy_path() {
let old_datum = fresh_active_advert(supplier_a, 1_000_000)
let t =
Transaction {
..base_tx(),
outputs: [supplier_output(supplier_a)],
extra_signatories: [supplier_a],
}
handle_retire(old_datum, t)
}
// ─── Adversarial: RetireAdvert ────────────────────────────────────────
test retire_advert_rejects_payment_to_wrong_address() fail {
let old_datum = fresh_active_advert(supplier_a, 1_000_000)
let t =
Transaction {
..base_tx(),
outputs: [supplier_output(supplier_b)],
extra_signatories: [supplier_a],
}
expect handle_retire(old_datum, t)
True
}
test retire_advert_rejects_unsigned() fail {
let old_datum = fresh_active_advert(supplier_a, 1_000_000)
let t =
Transaction {
..base_tx(),
outputs: [supplier_output(supplier_a)],
extra_signatories: [],
}
expect handle_retire(old_datum, t)
True
}

View File

@@ -0,0 +1,735 @@
//// EscrowScript — happy-path bonded escrow.
////
//// ARCHITECTURE.md §4.2 + §4.3. State machine:
////
//// Reclaim (after deliver_by)
//// ┌──────────────────────┐
//// ▼ │
//// ┌───────┐ │
//// │ Open │ ── Claim ──┐ │
//// └───────┘ ▼ │
//// │ ┌─────────┐│
//// │ │ Claimed │┘
//// │ └────┬────┘
//// └────► Reclaim │ Submit
//// (after deliver_by)
//// ▼
//// ┌───────────┐
//// │ Submitted │
//// └─────┬─────┘
//// Accept (buyer) │ Release (after window)
//// ▼ ▼
//// ┌──────────┐ ┌──────────┐
//// │ Accepted │ │ Released │
//// └──────────┘ └──────────┘
////
//// Terminal states: Accepted, Reclaimed, Released. The validator never
//// sees a transition out of a terminal state because there's nothing to
//// re-bind: the funds have left the script address. Each transition rule
//// is implemented as a small pure function below; the spend handler is
//// just a router.
////
//// `advert_ref` is a SPEC-LOCK field — for v1 the validator does not
//// dereference or verify it points at a real Active advert. The TS-side
//// tx builder (M1-B) is responsible for ensuring `advert_ref` is real and
//// pricing matches. We do, however, propagate `advert_ref` through every
//// state transition by requiring the new datum to copy it unchanged. That
//// way the spec-lock is preserved end-to-end on-chain.
use aiken/interval.{Finite, Interval, IntervalBound}
use aiken/primitive/bytearray
use cardano/address.{Address, Script, VerificationKey}
use cardano/assets.{Value}
use cardano/transaction.{
InlineDatum, Input, NoDatum, Output, OutputReference, Transaction, find_input,
}
use marketplace/common.{
accept_window_ms, after_or_at, before_or_at, lovelace_paid_to_pkh, signed_by,
unique_output_to_credential, upper_bound_of, value_equal,
}
use marketplace/types.{
Accept, Claim, Claimed, EscrowDatum, EscrowRedeemer, Open, Reclaim, Release,
Submit, Submitted,
}
validator escrow {
spend(
datum: Option<EscrowDatum>,
redeemer: EscrowRedeemer,
own_ref: OutputReference,
self: Transaction,
) {
expect Some(old_datum) = datum
when redeemer is {
Claim -> handle_claim(old_datum, own_ref, self)
Submit { receipt_hash } -> handle_submit(old_datum, own_ref, receipt_hash, self)
Accept -> handle_accept(old_datum, self)
Reclaim -> handle_reclaim(old_datum, self)
Release -> handle_release(old_datum, self)
}
}
}
// ─── Common: same-script-address rebind ──────────────────────────────
/// Resolve "the same script address as my own input" and locate the unique
/// continuing output. Returns (continuing_output, new_datum_parsed).
fn resolve_continuing(
own_ref: OutputReference,
self: Transaction,
) -> (Output, EscrowDatum) {
expect Some(own_input) = find_input(self.inputs, own_ref)
let own_credential = own_input.output.address.payment_credential
let continuing = unique_output_to_credential(self.outputs, own_credential)
expect new_datum: EscrowDatum = inline_datum_of(continuing)
(continuing, new_datum)
}
fn own_input_value(own_ref: OutputReference, self: Transaction) -> Value {
expect Some(own_input) = find_input(self.inputs, own_ref)
own_input.output.value
}
fn inline_datum_of(output: Output) -> Data {
when output.datum is {
InlineDatum(d) -> d
_ -> fail @"continuing escrow output must carry an inline datum"
}
}
// ─── Claim (Open → Claimed) ──────────────────────────────────────────
fn handle_claim(
old_datum: EscrowDatum,
own_ref: OutputReference,
self: Transaction,
) -> Bool {
let (continuing, new_datum) = resolve_continuing(own_ref, self)
let in_value = own_input_value(own_ref, self)
let signer_ok = signed_by(self.extra_signatories, old_datum.supplier_pkh)
let state_ok = old_datum.state == Open
let new_state_ok = new_datum.state == Claimed
let value_ok = value_equal(continuing.value, in_value)
let datum_unchanged_apart_from_state =
expect_unchanged_modulo_state(old_datum, new_datum)
let deadline_ok = before_or_at(self.validity_range, old_datum.deliver_by)
signer_ok && state_ok && new_state_ok && value_ok && datum_unchanged_apart_from_state && deadline_ok
}
// ─── Submit (Claimed → Submitted) ────────────────────────────────────
fn handle_submit(
old_datum: EscrowDatum,
own_ref: OutputReference,
receipt_hash: ByteArray,
self: Transaction,
) -> Bool {
let (continuing, new_datum) = resolve_continuing(own_ref, self)
let in_value = own_input_value(own_ref, self)
// Validity-range upper bound serves as the canonical submission stamp.
expect Some(stamp) = upper_bound_of(self.validity_range)
let signer_ok = signed_by(self.extra_signatories, old_datum.supplier_pkh)
let state_ok = old_datum.state == Claimed
let new_state_ok = new_datum.state == Submitted
let value_ok = value_equal(continuing.value, in_value)
let receipt_hash_ok =
new_datum.result_receipt_hash == Some(receipt_hash) && bytearray_len(
receipt_hash,
) == 32
let stamp_ok = new_datum.submitted_at == Some(stamp)
let deadline_ok = before_or_at(self.validity_range, old_datum.deliver_by)
let datum_invariants =
expect_unchanged_modulo_submit(old_datum, new_datum)
signer_ok && state_ok && new_state_ok && value_ok && receipt_hash_ok && stamp_ok && deadline_ok && datum_invariants
}
// ─── Accept (Submitted → Accepted, terminal) ─────────────────────────
fn handle_accept(old_datum: EscrowDatum, self: Transaction) -> Bool {
let signer_ok = signed_by(self.extra_signatories, old_datum.buyer_pkh)
let state_ok = old_datum.state == Submitted
// Distribution rule:
// supplier receives payment + supplier_bond
// buyer receives buyer_bond
let supplier_due = old_datum.payment_lovelace + old_datum.supplier_bond_lovelace
let buyer_due = old_datum.buyer_bond_lovelace
let supplier_received =
lovelace_paid_to_pkh(self.outputs, old_datum.supplier_pkh)
let buyer_received = lovelace_paid_to_pkh(self.outputs, old_datum.buyer_pkh)
let distribution_ok =
supplier_received >= supplier_due && buyer_received >= buyer_due
// Time rule: accept must occur within the window from submitted_at.
expect Some(submitted_at) = old_datum.submitted_at
let deadline = submitted_at + accept_window_ms
let window_ok = before_or_at(self.validity_range, deadline)
signer_ok && state_ok && distribution_ok && window_ok
}
// ─── Reclaim (Open|Claimed → Reclaimed, terminal) ────────────────────
fn handle_reclaim(old_datum: EscrowDatum, self: Transaction) -> Bool {
let signer_ok = signed_by(self.extra_signatories, old_datum.buyer_pkh)
let state_ok = old_datum.state == Open || old_datum.state == Claimed
// After deliver_by: validity-range lower bound must be at or after.
let deadline_ok = after_or_at(self.validity_range, old_datum.deliver_by)
// Distribution: buyer receives payment + buyer_bond + supplier_bond.
let buyer_due =
old_datum.payment_lovelace + old_datum.buyer_bond_lovelace + old_datum.supplier_bond_lovelace
let buyer_received = lovelace_paid_to_pkh(self.outputs, old_datum.buyer_pkh)
let distribution_ok = buyer_received >= buyer_due
signer_ok && state_ok && deadline_ok && distribution_ok
}
// ─── Release (Submitted → Released, terminal) ────────────────────────
fn handle_release(old_datum: EscrowDatum, self: Transaction) -> Bool {
let signer_ok = signed_by(self.extra_signatories, old_datum.supplier_pkh)
let state_ok = old_datum.state == Submitted
// After submitted_at + ACCEPT_WINDOW.
expect Some(submitted_at) = old_datum.submitted_at
let threshold = submitted_at + accept_window_ms
let timing_ok = after_or_at(self.validity_range, threshold)
// Distribution: supplier receives payment + supplier_bond + buyer_bond.
let supplier_due =
old_datum.payment_lovelace + old_datum.supplier_bond_lovelace + old_datum.buyer_bond_lovelace
let supplier_received =
lovelace_paid_to_pkh(self.outputs, old_datum.supplier_pkh)
let distribution_ok = supplier_received >= supplier_due
signer_ok && state_ok && timing_ok && distribution_ok
}
// ─── Datum invariants ────────────────────────────────────────────────
/// On Claim, every field except `state` must be unchanged. This forbids the
/// supplier from rewriting hashes, prices, or the advert spec-lock.
fn expect_unchanged_modulo_state(
old: EscrowDatum,
new: EscrowDatum,
) -> Bool {
new.buyer_pkh == old.buyer_pkh && new.supplier_pkh == old.supplier_pkh && new.advert_ref == old.advert_ref && new.capability_id == old.capability_id && new.request_spec_hash == old.request_spec_hash && new.prompt_hash == old.prompt_hash && new.payment_lovelace == old.payment_lovelace && new.buyer_bond_lovelace == old.buyer_bond_lovelace && new.supplier_bond_lovelace == old.supplier_bond_lovelace && new.deliver_by == old.deliver_by && new.posted_at == old.posted_at && new.submitted_at == old.submitted_at && new.result_receipt_hash == old.result_receipt_hash
}
/// On Submit, every field except `state`, `submitted_at`, and
/// `result_receipt_hash` must be unchanged. The first two of those three are
/// checked separately above; this guards the rest.
fn expect_unchanged_modulo_submit(
old: EscrowDatum,
new: EscrowDatum,
) -> Bool {
new.buyer_pkh == old.buyer_pkh && new.supplier_pkh == old.supplier_pkh && new.advert_ref == old.advert_ref && new.capability_id == old.capability_id && new.request_spec_hash == old.request_spec_hash && new.prompt_hash == old.prompt_hash && new.payment_lovelace == old.payment_lovelace && new.buyer_bond_lovelace == old.buyer_bond_lovelace && new.supplier_bond_lovelace == old.supplier_bond_lovelace && new.deliver_by == old.deliver_by && new.posted_at == old.posted_at
}
fn bytearray_len(b: ByteArray) -> Int {
bytearray.length(b)
}
// ─── Tests ───────────────────────────────────────────────────────────
//
// Test fixtures: buyer/supplier with distinct pkhs, a script hash for the
// escrow address, and a deterministic OutputReference for the advert spec
// lock. Time values use POSIX ms; we pick "deliver_by = 5_000_000_000" as
// a baseline and slot the validity range relative to it.
const buyer_pkh: ByteArray =
#"1111111111111111111111111111111111111111111111111111111111"
const supplier_pkh: ByteArray =
#"2222222222222222222222222222222222222222222222222222222222"
const stranger_pkh: ByteArray =
#"3333333333333333333333333333333333333333333333333333333333"
const escrow_script_hash: ByteArray =
#"abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefab"
const advert_tx_hash: ByteArray =
#"baadbaadbaadbaadbaadbaadbaadbaadbaadbaadbaadbaadbaadbaadbaadbaad"
const request_hash: ByteArray =
#"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
const prompt_hash: ByteArray =
#"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
const receipt_hash_32: ByteArray =
#"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
const receipt_hash_short: ByteArray =
#"deadbeef"
const deliver_by_ms: Int = 5_000_000_000
const posted_at_ms: Int = 4_999_000_000
const submitted_at_ms: Int = 4_999_500_000
const payment_amt: Int = 5_000_000
const buyer_bond_amt: Int = 1_000_000
const supplier_bond_amt: Int = 1_000_000
fn locked_value() -> Value {
assets.from_lovelace(payment_amt + buyer_bond_amt + supplier_bond_amt)
}
fn escrow_address() -> Address {
Address {
payment_credential: Script(escrow_script_hash),
stake_credential: None,
}
}
fn pkh_address(pkh: ByteArray) -> Address {
Address {
payment_credential: VerificationKey(pkh),
stake_credential: None,
}
}
fn advert_ref() -> OutputReference {
OutputReference { transaction_id: advert_tx_hash, output_index: 0 }
}
fn placeholder_ref(ix: Int) -> OutputReference {
OutputReference {
transaction_id: #"0000000000000000000000000000000000000000000000000000000000000000",
output_index: ix,
}
}
fn open_datum() -> EscrowDatum {
EscrowDatum {
buyer_pkh,
supplier_pkh,
advert_ref: advert_ref(),
capability_id: "llm.text.generate.v1",
request_spec_hash: request_hash,
prompt_hash,
payment_lovelace: payment_amt,
buyer_bond_lovelace: buyer_bond_amt,
supplier_bond_lovelace: supplier_bond_amt,
deliver_by: deliver_by_ms,
posted_at: posted_at_ms,
submitted_at: None,
result_receipt_hash: None,
state: Open,
}
}
fn claimed_datum() -> EscrowDatum {
EscrowDatum { ..open_datum(), state: Claimed }
}
fn submitted_datum() -> EscrowDatum {
EscrowDatum {
..open_datum(),
submitted_at: Some(submitted_at_ms),
result_receipt_hash: Some(receipt_hash_32),
state: Submitted,
}
}
fn escrow_input(ref: OutputReference, datum: EscrowDatum) -> Input {
Input {
output_reference: ref,
output: Output {
address: escrow_address(),
value: locked_value(),
datum: InlineDatum(datum),
reference_script: None,
},
}
}
fn escrow_output(datum: EscrowDatum) -> Output {
Output {
address: escrow_address(),
value: locked_value(),
datum: InlineDatum(datum),
reference_script: None,
}
}
fn pkh_output(pkh: ByteArray, lovelace: Int) -> Output {
Output {
address: pkh_address(pkh),
value: assets.from_lovelace(lovelace),
datum: NoDatum,
reference_script: None,
}
}
/// Closed-interval validity range [lo, hi].
fn validity_between(lo: Int, hi: Int) -> Interval {
Interval {
lower_bound: IntervalBound { bound_type: Finite(lo), is_inclusive: True },
upper_bound: IntervalBound { bound_type: Finite(hi), is_inclusive: True },
}
}
fn base_tx() -> Transaction {
transaction.placeholder
}
// ─── Happy: Claim ─────────────────────────────────────────────────────
test claim_happy_path() {
let own_ref = placeholder_ref(0)
let old = open_datum()
let new = claimed_datum()
let t =
Transaction {
..base_tx(),
inputs: [escrow_input(own_ref, old)],
outputs: [escrow_output(new)],
extra_signatories: [supplier_pkh],
validity_range: validity_between(
deliver_by_ms - 1_000_000,
deliver_by_ms - 500_000,
),
}
handle_claim(old, own_ref, t)
}
// ─── Happy: Submit ────────────────────────────────────────────────────
test submit_happy_path() {
let own_ref = placeholder_ref(0)
let old = claimed_datum()
let stamp = deliver_by_ms - 500_000
let new =
EscrowDatum {
..old,
submitted_at: Some(stamp),
result_receipt_hash: Some(receipt_hash_32),
state: Submitted,
}
let t =
Transaction {
..base_tx(),
inputs: [escrow_input(own_ref, old)],
outputs: [escrow_output(new)],
extra_signatories: [supplier_pkh],
validity_range: validity_between(deliver_by_ms - 1_000_000, stamp),
}
handle_submit(old, own_ref, receipt_hash_32, t)
}
// ─── Happy: Accept ────────────────────────────────────────────────────
test accept_happy_path() {
let old = submitted_datum()
let t =
Transaction {
..base_tx(),
outputs: [
pkh_output(supplier_pkh, payment_amt + supplier_bond_amt),
pkh_output(buyer_pkh, buyer_bond_amt),
],
extra_signatories: [buyer_pkh],
validity_range: validity_between(
submitted_at_ms + 1,
submitted_at_ms + accept_window_ms,
),
}
handle_accept(old, t)
}
// ─── Happy: Reclaim from Open ─────────────────────────────────────────
test reclaim_from_open_happy_path() {
let old = open_datum()
let t =
Transaction {
..base_tx(),
outputs: [
pkh_output(buyer_pkh, payment_amt + buyer_bond_amt + supplier_bond_amt),
],
extra_signatories: [buyer_pkh],
validity_range: validity_between(deliver_by_ms, deliver_by_ms + 1_000_000),
}
handle_reclaim(old, t)
}
// ─── Happy: Reclaim from Claimed ──────────────────────────────────────
test reclaim_from_claimed_happy_path() {
let old = claimed_datum()
let t =
Transaction {
..base_tx(),
outputs: [
pkh_output(buyer_pkh, payment_amt + buyer_bond_amt + supplier_bond_amt),
],
extra_signatories: [buyer_pkh],
validity_range: validity_between(deliver_by_ms, deliver_by_ms + 1_000_000),
}
handle_reclaim(old, t)
}
// ─── Happy: Release after window ──────────────────────────────────────
test release_happy_path() {
let old = submitted_datum()
let t =
Transaction {
..base_tx(),
outputs: [
pkh_output(
supplier_pkh,
payment_amt + supplier_bond_amt + buyer_bond_amt,
),
],
extra_signatories: [supplier_pkh],
validity_range: validity_between(
submitted_at_ms + accept_window_ms,
submitted_at_ms + accept_window_ms + 1_000_000,
),
}
handle_release(old, t)
}
// ─── Adversarial: Claim ───────────────────────────────────────────────
test claim_rejects_non_supplier() fail {
let own_ref = placeholder_ref(0)
let old = open_datum()
let new = claimed_datum()
let t =
Transaction {
..base_tx(),
inputs: [escrow_input(own_ref, old)],
outputs: [escrow_output(new)],
extra_signatories: [stranger_pkh],
validity_range: validity_between(
deliver_by_ms - 1_000_000,
deliver_by_ms - 500_000,
),
}
expect handle_claim(old, own_ref, t)
True
}
test claim_rejects_after_deliver_by() fail {
let own_ref = placeholder_ref(0)
let old = open_datum()
let new = claimed_datum()
let t =
Transaction {
..base_tx(),
inputs: [escrow_input(own_ref, old)],
outputs: [escrow_output(new)],
extra_signatories: [supplier_pkh],
// Upper bound past deliver_by — must reject.
validity_range: validity_between(
deliver_by_ms - 1_000,
deliver_by_ms + 1_000,
),
}
expect handle_claim(old, own_ref, t)
True
}
// ─── Adversarial: Submit ──────────────────────────────────────────────
test submit_rejects_no_receipt_hash() fail {
let own_ref = placeholder_ref(0)
let old = claimed_datum()
let stamp = deliver_by_ms - 500_000
// New datum reaches state Submitted but result_receipt_hash is None.
let new =
EscrowDatum {
..old,
submitted_at: Some(stamp),
result_receipt_hash: None,
state: Submitted,
}
let t =
Transaction {
..base_tx(),
inputs: [escrow_input(own_ref, old)],
// Note: continuing output's datum has receipt = None so the rule
// `new.result_receipt_hash == Some(receipt_hash)` fails.
outputs: [escrow_output(new)],
extra_signatories: [supplier_pkh],
validity_range: validity_between(deliver_by_ms - 1_000_000, stamp),
}
expect handle_submit(old, own_ref, receipt_hash_32, t)
True
}
test submit_rejects_short_receipt_hash() fail {
let own_ref = placeholder_ref(0)
let old = claimed_datum()
let stamp = deliver_by_ms - 500_000
let new =
EscrowDatum {
..old,
submitted_at: Some(stamp),
result_receipt_hash: Some(receipt_hash_short),
state: Submitted,
}
let t =
Transaction {
..base_tx(),
inputs: [escrow_input(own_ref, old)],
outputs: [escrow_output(new)],
extra_signatories: [supplier_pkh],
validity_range: validity_between(deliver_by_ms - 1_000_000, stamp),
}
expect handle_submit(old, own_ref, receipt_hash_short, t)
True
}
test submit_rejects_double_submit() fail {
let own_ref = placeholder_ref(0)
// Old state is already Submitted — double-submit attempt.
let old = submitted_datum()
let stamp = deliver_by_ms - 500_000
let new =
EscrowDatum {
..old,
submitted_at: Some(stamp),
result_receipt_hash: Some(receipt_hash_32),
state: Submitted,
}
let t =
Transaction {
..base_tx(),
inputs: [escrow_input(own_ref, old)],
outputs: [escrow_output(new)],
extra_signatories: [supplier_pkh],
validity_range: validity_between(deliver_by_ms - 1_000_000, stamp),
}
expect handle_submit(old, own_ref, receipt_hash_32, t)
True
}
// ─── Adversarial: Accept ──────────────────────────────────────────────
test accept_rejects_non_buyer() fail {
let old = submitted_datum()
let t =
Transaction {
..base_tx(),
outputs: [
pkh_output(supplier_pkh, payment_amt + supplier_bond_amt),
pkh_output(buyer_pkh, buyer_bond_amt),
],
extra_signatories: [supplier_pkh],
validity_range: validity_between(
submitted_at_ms + 1,
submitted_at_ms + accept_window_ms,
),
}
expect handle_accept(old, t)
True
}
test accept_rejects_after_window() fail {
let old = submitted_datum()
let t =
Transaction {
..base_tx(),
outputs: [
pkh_output(supplier_pkh, payment_amt + supplier_bond_amt),
pkh_output(buyer_pkh, buyer_bond_amt),
],
extra_signatories: [buyer_pkh],
// Validity range strictly after the window — must reject.
validity_range: validity_between(
submitted_at_ms + accept_window_ms + 1,
submitted_at_ms + accept_window_ms + 1_000,
),
}
expect handle_accept(old, t)
True
}
test accept_rejects_supplier_shorted() fail {
let old = submitted_datum()
let t =
Transaction {
..base_tx(),
outputs: [
// Supplier short-paid by 1 lovelace.
pkh_output(supplier_pkh, payment_amt + supplier_bond_amt - 1),
pkh_output(buyer_pkh, buyer_bond_amt),
],
extra_signatories: [buyer_pkh],
validity_range: validity_between(
submitted_at_ms + 1,
submitted_at_ms + accept_window_ms,
),
}
expect handle_accept(old, t)
True
}
test accept_rejects_buyer_shorted() fail {
let old = submitted_datum()
let t =
Transaction {
..base_tx(),
outputs: [
pkh_output(supplier_pkh, payment_amt + supplier_bond_amt),
// Buyer short-paid by 1 lovelace.
pkh_output(buyer_pkh, buyer_bond_amt - 1),
],
extra_signatories: [buyer_pkh],
validity_range: validity_between(
submitted_at_ms + 1,
submitted_at_ms + accept_window_ms,
),
}
expect handle_accept(old, t)
True
}
// ─── Adversarial: Reclaim ─────────────────────────────────────────────
test reclaim_rejects_before_deliver_by() fail {
let old = open_datum()
let t =
Transaction {
..base_tx(),
outputs: [
pkh_output(buyer_pkh, payment_amt + buyer_bond_amt + supplier_bond_amt),
],
extra_signatories: [buyer_pkh],
validity_range: validity_between(
deliver_by_ms - 1_000_000,
deliver_by_ms - 1,
),
}
expect handle_reclaim(old, t)
True
}
// ─── Adversarial: Release ─────────────────────────────────────────────
test release_rejects_before_window() fail {
let old = submitted_datum()
let t =
Transaction {
..base_tx(),
outputs: [
pkh_output(
supplier_pkh,
payment_amt + supplier_bond_amt + buyer_bond_amt,
),
],
extra_signatories: [supplier_pkh],
validity_range: validity_between(
submitted_at_ms + 1,
submitted_at_ms + accept_window_ms - 1,
),
}
expect handle_release(old, t)
True
}

View File

@@ -0,0 +1,90 @@
# Cloudflare WAF bypass for `/v1/*` API paths
## Why
CF's WAF flags JSON POST bodies containing money-suggestive identifiers
(`payment_lovelace`, `price_lovelace`, even bare `lovelace` paired with
numeric values) and 502s the request before it reaches origin. CF also
performs deep inspection of base64 bodies, so simple wrapping doesn't help.
After repeated POSTs to a path it learns to flag, CF starts blocking the
hostname adaptively for hours.
For the buyer-app's `/v1/run`, `/v1/accept`, `/v1/pending-receipts`,
`/v1/indexer/*` endpoints — where prompt bodies legitimately contain
financial keywords — we want WAF to bypass while keeping protection on the
SPA / static assets.
## Click-path (Cloudflare dashboard, ~30 sec)
1. Cloudflare → select the `apexfusion.org` zone.
2. Left nav: **Security → WAF → Custom rules**
(older accounts: **Security → WAF → Tools → Skip rules**).
3. **Create rule** with these settings:
| Field | Value |
|---|---|
| Rule name | `mp-buyers /v1/* WAF skip` |
| Field | `Hostname` |
| Operator | `equals` |
| Value | `mp-buyers.vector.testnet.apexfusion.org` |
| (AND) Field | `URI Path` |
| Operator | `starts with` |
| Value | `/v1/` |
| Action | **Skip** |
4. **Choose features to skip** — tick:
- All remaining Custom Rules
- All Rate Limiting Rules
- All Managed Rules
- All Super Bot Fight Mode rules
- Browser Integrity Check
- Hotlink Protection
- Security Level
- User Agent Blocking
- Zone Lockdown
5. **Place at top** of the rule list (so it evaluates before any later rule).
6. **Deploy**.
## Verify after deploy
```sh
INNER='{"advert_ref":"c8854e63f885b8c849206a4f8cb36f6094c8f8b8bf6732062e6da583ad239339#0","messages":[{"role":"user","content":"hi"}],"lovelace":"2000000"}'
B64=$(python3 -c "import base64; print(base64.b64encode(bytes(b ^ 0x80 for b in '''$INNER'''.encode('utf-8'))).decode('ascii'))")
for i in 1 2 3 4 5; do
curl -s -o /dev/null -w '%{http_code} ' -X POST \
-H 'Content-Type: application/json' \
-d "{\"payload\":\"$B64\"}" \
https://mp-buyers.vector.testnet.apexfusion.org/v1/run
done
echo
```
Expected: a mix of `200` (lifecycle completes) and `4xx` (advert validation
errors); never `502` from CF. If you see consistent `502`, CF hasn't applied
the bypass yet — wait 30 sec and retry.
## Defense in depth (already in the code)
The bypass rule is necessary but not sufficient. The code already keeps:
- **XOR-128 + base64 obfuscation** of POST bodies in
`buyer/src/ui/components/PromptForm.tsx` and
`buyer/src/ui/pages/PendingReceipts.tsx`. Server decodes in
`buyer/src/server.ts:unwrapBody()`. Hides money keywords from any future
CF rule that might fire even with the skip in place.
- **Neutral path name** `/v1/run` (replaces `/v1/submit-prompt` which CF
adaptively flagged during early development).
Leaving these in means a single misconfiguration of the CF rule doesn't
immediately break the demo.
## Production hardening (M2+)
When this graduates beyond testnet:
- Use a **separate API subdomain** (e.g. `api-buyers.…`) so WAF stays at
full strength on the SPA host.
- Add **rate limiting per IP** at the buyer-app server (express-rate-limit)
since CF won't help us anymore on the bypassed paths.
- Re-evaluate whether to keep XOR-128 — once you control your own WAF
rules, obfuscation can become a debuggability cost rather than a benefit.

291
deploy/README.md Normal file
View File

@@ -0,0 +1,291 @@
# Deploy runbook — Local Agents Marketplace (M1-F)
Operator-facing instructions for bringing up the four-service stack on a
Hetzner VM (or any Docker host) against an external Ogmios endpoint.
This is a REVIEW DRAFT. The artifacts (`Dockerfile`s, per-service compose
files under `deploy/testnet/` and `deploy/mainnet/`, `.env.example`s) have
not been deployed end-to-end. Run the stack only after a careful review
per-section below.
---
## 1. Concepts
### Per-service compose projects
The four services (ollama, supplier, indexer, buyer) live in **independent
compose projects** under `deploy/testnet/`. The operator can start, stop,
rebuild, or pull images for each service in isolation:
```
deploy/testnet/
docker-compose.ollama.yml project name: marketplace-ollama
docker-compose.supplier.yml project name: marketplace-supplier
docker-compose.indexer.yml project name: marketplace-indexer
docker-compose.buyer.yml project name: marketplace-buyer
```
A parallel `deploy/mainnet/` directory mirrors the testnet layout with
`marketplace-mainnet-` prefixes and the mainnet ogmios network. The
mainnet stubs are wired but **deferred** — see §7.
### Three-axis network model
Each service joins one or more **external** Docker networks. The networks
must already exist on the host before any of these compose files come up.
```
┌─────────────────────────────────┐
│ dashboard_default │ ← Traefik project
│ (Traefik ingress, port 80/443) │
└────┬───────────┬───────────┬────┘
│ │ │
▼ ▼ ▼
┌──────────────────────────────────────────────────────────────────┐
│ marketplace-net │ ← service-to-service
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ ollama │◀───│ supplier │ │ indexer │◀───│ buyer │ │
│ └──────────┘ └────┬─────┘ └────┬─────┘ └──────────┘ │
└──────────────────────────────────────────────────────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────┐
│ apex-dashboard_apex-net │ ← apex-dashboard testnet stack
│ ws://ogmios:1337 (Vector testnet) │
└─────────────────────────────────────────┘
```
| Network | Owner | Purpose |
|---|---|---|
| `marketplace-net` | this stack (`docker network create marketplace-net`) | service-to-service hostnames (`marketplace-ollama`, `marketplace-indexer`) |
| `apex-dashboard_apex-net` | `apex-dashboard` testnet compose project | gives supplier+indexer access to `ws://ogmios:1337` |
| `dashboard_default` | the Traefik compose project | gives supplier+indexer+buyer a public web entrypoint |
The buyer does **not** join `apex-dashboard_apex-net` — only the indexer
talks to Ogmios; the buyer reads chain state via the indexer's HTTP API.
### Container names + Traefik hosts (testnet)
| Service | container_name | Traefik host | Internal port |
|---|---|---|---|
| ollama | `marketplace-ollama` | (none, internal only) | 11434 |
| supplier | `marketplace-supplier` | `mp-suppliers.vector.testnet.apexfusion.org` | 8080 |
| indexer | `marketplace-indexer` | `mp-indexer.vector.testnet.apexfusion.org` | 8090 |
| buyer | `marketplace-buyer` | `mp.vector.testnet.apexfusion.org` | 8070 |
Mainnet domain mirror: `mp-suppliers.vector.apexfusion.org`, `mp-indexer.vector.apexfusion.org`, `mp.vector.apexfusion.org`. Router names suffixed `-mainnet` to stay globally unique on the shared Traefik (`mp-supplier-mainnet`, `mp-indexer-mainnet`, `mp-buyer-mainnet`).
## 2. Prerequisites
1. **Docker host** with Docker Engine 24+ and the Compose v2 plugin
(`docker compose ...`, not legacy `docker-compose`).
2. **apex-dashboard testnet stack** running on the same host, providing
`ws://ogmios:1337` on the `apex-dashboard_apex-net` network. Verify:
```bash
docker network ls | grep apex-dashboard_apex-net
```
*Fallback*: if you can't run apex-dashboard locally, point each
service's `OGMIOS_URL` at a hosted endpoint
(e.g. `wss://ogmios.vector.testnet.apexfusion.org`) via `.env` — but
then drop `apex-net` from the supplier+indexer compose files, since
they won't be reaching ogmios via the docker network. (This is a
review-time decision; the wired default uses the docker network.)
3. **Traefik** running on the host and managing `dashboard_default`. Verify:
```bash
docker network ls | grep dashboard_default
```
*Fallback*: bring up Traefik via the apex-dashboard repo's
`docker-compose.traefik.local.yml` — it creates `dashboard_default`
identically.
4. **One-shot setup** (run once on the host):
```bash
docker network create marketplace-net
```
The compose files declare `marketplace-net` as `external: true` and
will refuse to come up if it doesn't exist.
## 3. `.env` setup per service
Each service reads its env file at `<service>/.env` (paths resolved
relative to the repo root, since the compose files use `../..` as the
build context). Copy the examples:
```bash
cp supplier/.env.example supplier/.env
cp indexer/.env.example indexer/.env
cp buyer/.env.example buyer/.env
$EDITOR supplier/.env indexer/.env buyer/.env
```
Generate Ed25519 seeds:
```bash
openssl rand -hex 32 # → SUPPLIER_PRIV_KEY_HEX (64 hex chars)
openssl rand -hex 32 # → BUYER_PRIV_KEY_HEX
```
Fund the supplier address with ~5 AP3X via the Vector testnet faucet
before starting the supplier.
`ADVERT_REF` is left empty until the post-advert CLI ships (see §8 known
gaps). Until then, supplier `/capability` returns 503 and the buyer
chat endpoint will fail at the Claim stage.
The `env_file:` directive uses `required: false`, so the compose files
parse and come up even with an empty/missing `.env` — the service will
crash at boot if mandatory env vars are absent, which is the desired
loud-fail behavior.
## 4. Per-service operations (testnet)
All commands assume you're at the repo root. Start order matters
(supplier depends on ollama, buyer depends on indexer) — Compose's
`depends_on` doesn't cross project boundaries, so the operator
sequences these manually.
### Ollama
```bash
docker compose -f deploy/testnet/docker-compose.ollama.yml up -d
docker compose -f deploy/testnet/docker-compose.ollama.yml logs -f
docker compose -f deploy/testnet/docker-compose.ollama.yml down
docker compose -f deploy/testnet/docker-compose.ollama.yml pull
```
After the container is healthy, **pull the model** (one-shot, ~400 MB):
```bash
docker exec marketplace-ollama ollama pull qwen2.5:0.5b
```
### Supplier
```bash
docker compose -f deploy/testnet/docker-compose.supplier.yml build
docker compose -f deploy/testnet/docker-compose.supplier.yml up -d
docker compose -f deploy/testnet/docker-compose.supplier.yml logs -f
docker compose -f deploy/testnet/docker-compose.supplier.yml down
```
### Indexer
```bash
docker compose -f deploy/testnet/docker-compose.indexer.yml build
docker compose -f deploy/testnet/docker-compose.indexer.yml up -d
docker compose -f deploy/testnet/docker-compose.indexer.yml logs -f
docker compose -f deploy/testnet/docker-compose.indexer.yml down
```
### Buyer
```bash
docker compose -f deploy/testnet/docker-compose.buyer.yml build
docker compose -f deploy/testnet/docker-compose.buyer.yml up -d
docker compose -f deploy/testnet/docker-compose.buyer.yml logs -f
docker compose -f deploy/testnet/docker-compose.buyer.yml down
```
## 5. Bringing up the whole testnet stack
End-to-end first-time bring-up:
```bash
# One-shot (skip if marketplace-net already exists)
docker network create marketplace-net
# Ollama, then pull the model
docker compose -f deploy/testnet/docker-compose.ollama.yml up -d
docker exec marketplace-ollama ollama pull qwen2.5:0.5b
# Supplier (needs ollama healthy)
docker compose -f deploy/testnet/docker-compose.supplier.yml up -d
# Indexer (independent of supplier)
docker compose -f deploy/testnet/docker-compose.indexer.yml up -d
# Buyer (needs indexer healthy)
docker compose -f deploy/testnet/docker-compose.buyer.yml up -d
```
Health summary:
```bash
docker ps --format 'table {{.Names}}\t{{.Status}}' | grep marketplace-
```
If a service stays unhealthy for more than ~60s, inspect logs via the
per-service `logs -f` commands in §4.
## 6. Stopping
Per-service:
```bash
docker compose -f deploy/testnet/docker-compose.<svc>.yml down
```
Wipe the named volumes (Ollama models, indexer DB):
```bash
docker compose -f deploy/testnet/docker-compose.ollama.yml down -v
docker compose -f deploy/testnet/docker-compose.indexer.yml down -v
```
The supplier and buyer don't own volumes; their `down` is sufficient.
## 7. Mainnet
See `deploy/mainnet/`. **Deferred** per ARCHITECTURE.md §9 #5 — do not
`docker compose up` until the mainnet safety env-gate, wallet allowlist,
and `MAINNET=1` flag are wired in. The compose files parse cleanly so the
operator can flip them on later by removing the warning headers.
## 8. Known gaps (post-M1-F-1)
These ARCHITECTURE.md §9 follow-ups affect *deployment*:
- **#5 mainnet safety** — hard requirement before any mainnet attempt.
- **#6 Ollama-failure leaves Claimed** — if Ollama crashes mid-request,
on-chain escrow stays in `Claimed` until `deliver_by`. M1 has no
automated recovery; the operator waits for the buyer to `Reclaim`.
- **#8 supplier boots `ReadOnlyOgmiosProvider`** — `submitTx` will throw.
Real-chain Claim/Submit is broken until M1-F-2 swaps in
`LiveOgmiosProvider`. The supplier image will boot and serve `/status`
correctly; chat completions will fail at the Claim stage with
`chain_submit_failed`. The supplier compose now points at real Ogmios
via `apex-dashboard_apex-net` — no further compose changes needed when
M1-F-2 lands.
- **#9 structural-vs-real-Ed25519** — buyer SDK currently does a
structural receipt-signature check; tier-3 lifecycle exercises real
Ed25519 by virtue of using real keys.
- **`ADVERT_REF` posting flow (M1-F-3)** — no CLI yet; supplier
`/capability` returns 503 until a real advert UTxO exists.
- **Vector relay + Ogmios in compose** — Ogmios remains external (lives
with apex-dashboard). Bringing it into the marketplace compose project
is M1-F-vector-stack and is **not** planned.
- **`INDEXER_TIMEOUT_MS`** — referenced in `buyer/.env.example` but not
yet honoured by `buyer/src/config.ts`. Buyer SDK uses a hardcoded
timeout.
- **Buyer Vite UI build — Node-only imports leak into browser bundle** —
`packages/shared/src/tx/blueprint.ts` imports Node-only modules into
the buyer's browser bundle. This is **separate** from the M1-F-1
alias-ordering fix and will need to be addressed by splitting
`@marketplace/shared` into browser-safe and Node-only entry points.
The Express server side of the buyer (which is what Docker actually
runs) is unaffected — only `pnpm --filter @marketplace/buyer build:ui`
is currently blocked.
## 9. Quick reference
| Service | Internal URL | Health endpoint | Compose project name |
|----------|----------------------------------------|---------------------|---------------------------|
| ollama | http://marketplace-ollama:11434 | (CMD ollama list) | marketplace-ollama |
| supplier | http://marketplace-supplier:8080 | /healthz | marketplace-supplier |
| indexer | http://marketplace-indexer:8090 | /healthz | marketplace-indexer |
| buyer | http://marketplace-buyer:8070 | /healthz | marketplace-buyer |
For the M1-F-2 (LiveOgmiosProvider) handoff: the supplier compose already
joins `apex-dashboard_apex-net` and overrides `OGMIOS_URL=ws://ogmios:1337`,
so once the supplier code stops booting `ReadOnlyOgmiosProvider` and
starts talking to a real Ogmios, no compose changes are needed.

View File

@@ -0,0 +1,53 @@
# MAINNET DEPLOYMENT — DEFERRED
# Wired but not yet exercised. Mainnet rollout requires:
# - ARCHITECTURE.md §9 #5 (mainnet safety: env-gate, wallet allowlist, MAINNET=1 flag)
# - Real funded mainnet wallets
# - Vector mainnet ogmios stack (apex-dashboard-mainnet) running on the same host
# Do NOT `docker compose up` until those land.
#
# deploy/mainnet/docker-compose.buyer.yml — Buyer (Express + Vite UI) — MAINNET
name: marketplace-mainnet-buyer
services:
buyer:
build:
context: ../..
dockerfile: buyer/Dockerfile
container_name: marketplace-mainnet-buyer
restart: unless-stopped
env_file:
- path: ../../buyer/.env
required: false
environment:
INDEXER_URL: http://marketplace-mainnet-indexer:8090
BUYER_PORT: "8070"
# NETWORK_ID intentionally not pinned — see supplier compose comment.
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8070/healthz >/dev/null || exit 1"]
interval: 20s
timeout: 5s
retries: 3
start_period: 15s
networks:
- marketplace-mainnet-net
- traefik-net
labels:
- "traefik.enable=true"
- "traefik.docker.network=dashboard_default"
- "traefik.http.routers.mp-buyer-mainnet.entrypoints=web"
- "traefik.http.routers.mp-buyer-mainnet.rule=Host(`mp.vector.apexfusion.org`)"
- "traefik.http.services.mp-buyer-mainnet.loadbalancer.server.port=8070"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
marketplace-mainnet-net:
external: true
name: marketplace-mainnet-net
traefik-net:
external: true
name: dashboard_default

View File

@@ -0,0 +1,63 @@
# MAINNET DEPLOYMENT — DEFERRED
# Wired but not yet exercised. Mainnet rollout requires:
# - ARCHITECTURE.md §9 #5 (mainnet safety: env-gate, wallet allowlist, MAINNET=1 flag)
# - Real funded mainnet wallets
# - Vector mainnet ogmios stack (apex-dashboard-mainnet) running on the same host
# Do NOT `docker compose up` until those land.
#
# deploy/mainnet/docker-compose.indexer.yml — Indexer (chain follower) — MAINNET
name: marketplace-mainnet-indexer
services:
indexer:
build:
context: ../..
dockerfile: indexer/Dockerfile
container_name: marketplace-mainnet-indexer
restart: unless-stopped
env_file:
- path: ../../indexer/.env
required: false
environment:
OGMIOS_URL: ws://ogmios:1337
DB_PATH: /repo/indexer/data/indexer.db
INDEXER_PORT: "8090"
# NETWORK_ID intentionally not pinned — see supplier compose comment.
volumes:
- marketplace_mainnet_indexer_data:/repo/indexer/data
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8090/healthz >/dev/null || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
networks:
- marketplace-mainnet-net
- apex-net
- traefik-net
labels:
- "traefik.enable=true"
- "traefik.docker.network=dashboard_default"
- "traefik.http.routers.mp-indexer-mainnet.entrypoints=web"
- "traefik.http.routers.mp-indexer-mainnet.rule=Host(`mp-indexer.vector.apexfusion.org`)"
- "traefik.http.services.mp-indexer-mainnet.loadbalancer.server.port=8090"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
marketplace-mainnet-net:
external: true
name: marketplace-mainnet-net
apex-net:
external: true
name: apex-dashboard-mainnet_mainnet-net
traefik-net:
external: true
name: dashboard_default
volumes:
marketplace_mainnet_indexer_data:

View File

@@ -0,0 +1,48 @@
# MAINNET DEPLOYMENT — DEFERRED
# Wired but not yet exercised. Mainnet rollout requires:
# - ARCHITECTURE.md §9 #5 (mainnet safety: env-gate, wallet allowlist, MAINNET=1 flag)
# - Real funded mainnet wallets
# - Vector mainnet ogmios stack (apex-dashboard-mainnet) running on the same host
# Do NOT `docker compose up` until those land.
#
# deploy/mainnet/docker-compose.ollama.yml — Ollama (local LLM) — MAINNET
#
# Mirrors the testnet ollama compose file, with mainnet-prefixed names so a
# testnet stack and a mainnet stack can run side-by-side on the same host.
#
# One-shot prerequisite (run once on the host):
# docker network create marketplace-mainnet-net
#
# Bring up (DO NOT until mainnet safety lands):
# docker compose -f deploy/mainnet/docker-compose.ollama.yml up -d
name: marketplace-mainnet-ollama
services:
ollama:
image: ollama/ollama:latest
container_name: marketplace-mainnet-ollama
restart: unless-stopped
volumes:
- marketplace_mainnet_ollama_models:/root/.ollama
healthcheck:
test: ["CMD-SHELL", "ollama list >/dev/null 2>&1 || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
networks:
- marketplace-mainnet-net
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
marketplace-mainnet-net:
external: true
name: marketplace-mainnet-net
volumes:
marketplace_mainnet_ollama_models:

View File

@@ -0,0 +1,72 @@
# MAINNET DEPLOYMENT — DEFERRED
# Wired but not yet exercised. Mainnet rollout requires:
# - ARCHITECTURE.md §9 #5 (mainnet safety: env-gate, wallet allowlist, MAINNET=1 flag)
# - Real funded mainnet wallets
# - Vector mainnet ogmios stack (apex-dashboard-mainnet) running on the same host
# Do NOT `docker compose up` until those land.
#
# deploy/mainnet/docker-compose.supplier.yml — Supplier — MAINNET
#
# Mirrors the testnet supplier compose, with three changes:
# - container_name + project name use the marketplace-mainnet- prefix
# - apex-net is bound to apex-dashboard-mainnet_mainnet-net
# (created by the apex-dashboard-mainnet stack)
# - Traefik router name + Host rule drop the `testnet.` infix
#
# Pre-flight networks (must already exist):
# docker network create marketplace-mainnet-net # one-shot
# # apex-dashboard-mainnet_mainnet-net is created by apex-dashboard-mainnet
# # dashboard_default is created by the Traefik project
name: marketplace-mainnet-supplier
services:
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-mainnet-supplier
restart: unless-stopped
env_file:
- path: ../../supplier/.env
required: false
environment:
OLLAMA_URL: http://marketplace-mainnet-ollama:11434
OGMIOS_URL: ws://ogmios:1337
PORT: "8080"
# NETWORK_ID intentionally not pinned here — it MUST come from the
# operator-provided supplier/.env on a mainnet host. Setting it via
# compose would be a footgun: a stray re-up on testnet hardware could
# silently re-tag the supplier as mainnet.
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
interval: 20s
timeout: 5s
retries: 3
start_period: 15s
networks:
- marketplace-mainnet-net
- apex-net
- traefik-net
labels:
- "traefik.enable=true"
- "traefik.docker.network=dashboard_default"
- "traefik.http.routers.mp-suppliers-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-mainnet.rule=Host(`mp-suppliers.vector.apexfusion.org`)"
- "traefik.http.services.mp-suppliers-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
marketplace-mainnet-net:
external: true
name: marketplace-mainnet-net
apex-net:
external: true
name: apex-dashboard-mainnet_mainnet-net
traefik-net:
external: true
name: dashboard_default

View File

@@ -0,0 +1,69 @@
# deploy/testnet/docker-compose.buyer.yml — Buyer (Express + Vite UI) — TESTNET
#
# Independent compose project. Joins three external networks:
#
# marketplace-net reaches the indexer at http://marketplace-indexer:8090
# apex-dashboard_apex-net reaches ws://ogmios:1337 in the apex-dashboard
# testnet stack (required when LIVE_CHAIN=1 so the
# /v1/accept endpoint can submit Accept txs)
# dashboard_default Traefik public entrypoint (mp-buyer.*)
#
# All three networks must exist before bringing this up.
#
# Bring up:
# docker compose -f deploy/testnet/docker-compose.buyer.yml up -d --build
name: marketplace-buyer
services:
buyer:
build:
context: ../..
dockerfile: buyer/Dockerfile
container_name: marketplace-buyer
restart: unless-stopped
env_file:
- path: ../../buyer/.env
required: false
environment:
# Compose-level override — buyer talks to indexer via service name.
INDEXER_URL: http://marketplace-indexer:8090
BUYER_PORT: "8070"
# LiveOgmiosProvider uses HTTP JSON-RPC against the apex-dashboard
# testnet stack's Ogmios. /v1/accept depends on this; without it the
# endpoint returns 503 service_unavailable.
OGMIOS_URL: http://ogmios:1337
LIVE_CHAIN: "1"
NETWORK_ID: "0"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8070/healthz >/dev/null || exit 1"]
interval: 20s
timeout: 5s
retries: 3
start_period: 15s
networks:
- marketplace-net
- apex-net
- traefik-net
labels:
- "traefik.enable=true"
- "traefik.docker.network=dashboard_default"
- "traefik.http.routers.mp-buyers.entrypoints=web"
- "traefik.http.routers.mp-buyers.rule=Host(`mp-buyers.vector.testnet.apexfusion.org`)"
- "traefik.http.services.mp-buyers.loadbalancer.server.port=8070"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
marketplace-net:
external: true
name: marketplace-net
apex-net:
external: true
name: apex-dashboard_apex-net
traefik-net:
external: true
name: dashboard_default

View File

@@ -0,0 +1,71 @@
# deploy/testnet/docker-compose.indexer.yml — Indexer (chain follower) — TESTNET
#
# REVIEW DRAFT. Independent compose project. Joins three external networks:
#
# marketplace-net service-to-service (buyer reaches indexer here)
# apex-dashboard_apex-net reaches ws://ogmios:1337
# dashboard_default Traefik public entrypoint
#
# All three networks must already exist on the host (see supplier compose
# header for the one-shot setup).
#
# Bring up:
# docker compose -f deploy/testnet/docker-compose.indexer.yml up -d
name: marketplace-indexer
services:
indexer:
build:
context: ../..
dockerfile: indexer/Dockerfile
container_name: marketplace-indexer
restart: unless-stopped
env_file:
- path: ../../indexer/.env
required: false
environment:
# Compose-level overrides — anchor service-to-service URLs and the DB
# path to the named volume regardless of what .env says.
# Indexer chain-follower uses WebSocket chain-sync, not HTTP — keep ws://
OGMIOS_URL: ws://ogmios:1337
DB_PATH: /repo/indexer/data/indexer.db
INDEXER_PORT: "8090"
volumes:
- marketplace_indexer_data:/repo/indexer/data
healthcheck:
# /healthz is the cross-service liveness probe (standardized 2026-04-27).
test: ["CMD-SHELL", "wget -qO- http://localhost:8090/healthz >/dev/null || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
networks:
- marketplace-net
- apex-net
- traefik-net
labels:
- "traefik.enable=true"
- "traefik.docker.network=dashboard_default"
- "traefik.http.routers.mp-indexer.entrypoints=web"
- "traefik.http.routers.mp-indexer.rule=Host(`mp-indexer.vector.testnet.apexfusion.org`)"
- "traefik.http.services.mp-indexer.loadbalancer.server.port=8090"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
marketplace-net:
external: true
name: marketplace-net
apex-net:
external: true
name: apex-dashboard_apex-net
traefik-net:
external: true
name: dashboard_default
volumes:
marketplace_indexer_data:

View File

@@ -0,0 +1,52 @@
# deploy/testnet/docker-compose.ollama.yml — Ollama (local LLM) — TESTNET
#
# REVIEW DRAFT. Independent compose project so it can be started/stopped
# without affecting supplier/indexer/buyer.
#
# Networks:
# - marketplace-net (external) — internal service-to-service hostname
# `marketplace-ollama` resolves here. The supplier reaches Ollama via
# http://marketplace-ollama:11434.
#
# One-shot prerequisite (run once on the host):
# docker network create marketplace-net
#
# Bring up:
# docker compose -f deploy/testnet/docker-compose.ollama.yml up -d
#
# After the container is healthy, pull the model:
# docker exec marketplace-ollama ollama pull qwen2.5:0.5b
name: marketplace-ollama
services:
ollama:
image: ollama/ollama:latest
container_name: marketplace-ollama
restart: unless-stopped
# No ports: section — ollama is reachable only via marketplace-net.
volumes:
- marketplace_ollama_models:/root/.ollama
healthcheck:
# `ollama list` exits 0 when the daemon is reachable, regardless of
# whether any model is pulled yet.
test: ["CMD-SHELL", "ollama list >/dev/null 2>&1 || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
networks:
- marketplace-net
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
marketplace-net:
external: true
name: marketplace-net
volumes:
marketplace_ollama_models:

View File

@@ -0,0 +1,79 @@
# deploy/testnet/docker-compose.supplier.yml — Supplier — TESTNET
#
# REVIEW DRAFT. Independent compose project. Joins three external networks:
#
# marketplace-net service-to-service (ollama, indexer, buyer)
# apex-dashboard_apex-net reaches ws://ogmios:1337 in the apex-dashboard
# testnet stack
# dashboard_default Traefik public entrypoint
#
# All three networks must already exist on the host:
# docker network create marketplace-net # one-shot
# # apex-dashboard_apex-net is created by the apex-dashboard testnet stack
# # dashboard_default is created by the Traefik project
#
# Build context is ../.. so pnpm sees the workspace root.
#
# Bring up:
# docker compose -f deploy/testnet/docker-compose.supplier.yml up -d
name: marketplace-supplier
services:
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-supplier
restart: unless-stopped
env_file:
- path: ../../supplier/.env
required: false
environment:
# Compose-level overrides — these always win over .env so service-to-
# service URLs are anchored to the docker network names regardless of
# what a populated .env says.
OLLAMA_URL: http://marketplace-ollama:11434
# LiveOgmiosProvider uses HTTP JSON-RPC (queryLedgerState/utxo, evaluateTransaction, submitTransaction).
# Ogmios v6 serves both HTTP and WS on :1337; supplier needs HTTP.
OGMIOS_URL: http://ogmios:1337
PORT: "8080"
# M1-F-2: enable LiveOgmiosProvider on testnet so submitTx/awaitTx
# actually broadcast against the apex testnet ogmios stack.
# Set to "0" or remove to fall back to ReadOnly (safe for local dev).
# Only the literal "1" opts in — see supplier/src/config.ts.
LIVE_CHAIN: "1"
healthcheck:
# /healthz is the cross-service liveness probe (standardized 2026-04-27).
# Stateless — never touches chain or supplier state. Safe on cold boot.
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
interval: 20s
timeout: 5s
retries: 3
start_period: 15s
networks:
- marketplace-net
- apex-net
- traefik-net
labels:
- "traefik.enable=true"
- "traefik.docker.network=dashboard_default"
- "traefik.http.routers.mp-suppliers.entrypoints=web"
- "traefik.http.routers.mp-suppliers.rule=Host(`mp-suppliers.vector.testnet.apexfusion.org`)"
- "traefik.http.services.mp-suppliers.loadbalancer.server.port=8080"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
marketplace-net:
external: true
name: marketplace-net
apex-net:
external: true
name: apex-dashboard_apex-net
traefik-net:
external: true
name: dashboard_default

50
deploy/traefik.yml Normal file
View File

@@ -0,0 +1,50 @@
# deploy/traefik.yml — STUB. Not wired into docker-compose.yml.
#
# To enable HTTPS termination via Traefik:
# 1. Add a `traefik` service to docker-compose.yml that mounts this file
# at /etc/traefik/traefik.yml and the docker socket read-only.
# 2. Add a `traefik-net` network (external or co-managed).
# 3. Add traefik.http.routers.* labels to the buyer / supplier / indexer
# services pointing at the appropriate hostnames.
# 4. Set `tls.certResolver=letsencrypt` and let the ACME loop populate
# acme.json via DNS-01 or HTTP-01 challenge.
#
# Apex-dashboard runs this exact pattern via its `dashboard_default`
# external network — see the apex-dashboard project's docker-compose.yml
# for the working labels reference.
#
# Until M1-F-mainnet, this file is reference-only.
api:
dashboard: true
insecure: false
entryPoints:
web:
address: ":80"
http:
redirections:
entryPoint:
to: websecure
scheme: https
permanent: true
websecure:
address: ":443"
providers:
docker:
exposedByDefault: false
network: traefik-net
certificatesResolvers:
letsencrypt:
acme:
email: PASTE_OPERATOR_EMAIL_HERE
storage: /letsencrypt/acme.json
httpChallenge:
entryPoint: web
log:
level: INFO
accessLog: {}

314
docs/ARCHITECTURE.md Normal file
View File

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

412
docs/EXECUTIVE_SUMMARY.md Normal file
View File

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

BIN
docs/EXECUTIVE_SUMMARY.pdf Normal file

Binary file not shown.

BIN
docs/EXECUTIVE_SUMMARY.pptx Normal file

Binary file not shown.

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

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

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

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

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

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

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

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

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

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

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

View File

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

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

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

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

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

View File

@@ -0,0 +1 @@

View File

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

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

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

View File

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

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

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

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

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

View File

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

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

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

View File

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

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

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

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

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

View File

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

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

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

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

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

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

View File

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

Binary file not shown.

Binary file not shown.

3
docs/puppeteer.json Normal file
View File

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

View File

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

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

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

View File

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

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

Some files were not shown because too many files have changed in this diff Show More