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:
66
.dockerignore
Normal file
66
.dockerignore
Normal 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
35
.github/workflows/ci.yml
vendored
Normal 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
28
.gitignore
vendored
Normal 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
|
||||||
3
README.md
Normal file
3
README.md
Normal 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
15
buyer/.dockerignore
Normal 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
32
buyer/.env.example
Normal 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
88
buyer/Dockerfile
Normal 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
37
buyer/package.json
Normal 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
8
buyer/postcss.config.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* buyer/postcss.config.js — Tailwind / Autoprefixer pipeline.
|
||||||
|
*/
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
30
buyer/src/chain.ts
Normal file
30
buyer/src/chain.ts
Normal 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
323
buyer/src/cli/accept.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
95
buyer/src/cli/acceptFlow.ts
Normal file
95
buyer/src/cli/acceptFlow.ts
Normal 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
74
buyer/src/config.ts
Normal 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
111
buyer/src/index.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
586
buyer/src/sdk/Marketplace.ts
Normal file
586
buyer/src/sdk/Marketplace.ts
Normal 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
106
buyer/src/sdk/history.ts
Normal 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
172
buyer/src/sdk/httpClient.ts
Normal 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
22
buyer/src/sdk/index.ts
Normal 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
177
buyer/src/sdk/types.ts
Normal 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
495
buyer/src/server.ts
Normal 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
36
buyer/src/ui/App.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
buyer/src/ui/components/PromptForm.tsx
Normal file
128
buyer/src/ui/components/PromptForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
buyer/src/ui/components/SupplierCard.tsx
Normal file
65
buyer/src/ui/components/SupplierCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
buyer/src/ui/components/TaskRow.tsx
Normal file
46
buyer/src/ui/components/TaskRow.tsx
Normal 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
3
buyer/src/ui/index.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
13
buyer/src/ui/index.html
Normal file
13
buyer/src/ui/index.html
Normal 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
81
buyer/src/ui/main.tsx
Normal 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>,
|
||||||
|
);
|
||||||
77
buyer/src/ui/pages/Dashboard.tsx
Normal file
77
buyer/src/ui/pages/Dashboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
269
buyer/src/ui/pages/PendingReceipts.tsx
Normal file
269
buyer/src/ui/pages/PendingReceipts.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
buyer/src/ui/pages/TaskHistory.tsx
Normal file
43
buyer/src/ui/pages/TaskHistory.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
buyer/src/ui/pages/Wallet.tsx
Normal file
25
buyer/src/ui/pages/Wallet.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
buyer/src/ui/state/MarketplaceContext.tsx
Normal file
37
buyer/src/ui/state/MarketplaceContext.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
buyer/src/ui/stubs/blueprintStub.ts
Normal file
28
buyer/src/ui/stubs/blueprintStub.ts
Normal 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
14
buyer/tailwind.config.js
Normal 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
10
buyer/tsconfig.json
Normal 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
49
buyer/vite.config.ts
Normal 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") },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
4
contracts/advert/README.md
Normal file
4
contracts/advert/README.md
Normal 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).
|
||||||
4
contracts/escrow/README.md
Normal file
4
contracts/escrow/README.md
Normal 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.
|
||||||
18
contracts/marketplace/.github/workflows/continuous-integration.yml
vendored
Normal file
18
contracts/marketplace/.github/workflows/continuous-integration.yml
vendored
Normal 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
6
contracts/marketplace/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# Aiken compilation artifacts
|
||||||
|
artifacts/
|
||||||
|
# Aiken's project working directory
|
||||||
|
build/
|
||||||
|
# Aiken's default documentation export
|
||||||
|
docs/
|
||||||
8
contracts/marketplace/README.md
Normal file
8
contracts/marketplace/README.md
Normal 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.
|
||||||
15
contracts/marketplace/aiken.lock
Normal file
15
contracts/marketplace/aiken.lock
Normal 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]
|
||||||
18
contracts/marketplace/aiken.toml
Normal file
18
contracts/marketplace/aiken.toml
Normal 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]
|
||||||
202
contracts/marketplace/lib/marketplace/common.ak
Normal file
202
contracts/marketplace/lib/marketplace/common.ak
Normal 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
|
||||||
|
}
|
||||||
127
contracts/marketplace/lib/marketplace/types.ak
Normal file
127
contracts/marketplace/lib/marketplace/types.ak
Normal 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
|
||||||
|
}
|
||||||
404
contracts/marketplace/plutus.json
Normal file
404
contracts/marketplace/plutus.json
Normal file
File diff suppressed because one or more lines are too long
336
contracts/marketplace/validators/advert.ak
Normal file
336
contracts/marketplace/validators/advert.ak
Normal 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
|
||||||
|
}
|
||||||
735
contracts/marketplace/validators/escrow.ak
Normal file
735
contracts/marketplace/validators/escrow.ak
Normal 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
|
||||||
|
}
|
||||||
90
deploy/CLOUDFLARE_WAF_BYPASS.md
Normal file
90
deploy/CLOUDFLARE_WAF_BYPASS.md
Normal 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
291
deploy/README.md
Normal 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.
|
||||||
53
deploy/mainnet/docker-compose.buyer.yml
Normal file
53
deploy/mainnet/docker-compose.buyer.yml
Normal 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
|
||||||
63
deploy/mainnet/docker-compose.indexer.yml
Normal file
63
deploy/mainnet/docker-compose.indexer.yml
Normal 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:
|
||||||
48
deploy/mainnet/docker-compose.ollama.yml
Normal file
48
deploy/mainnet/docker-compose.ollama.yml
Normal 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:
|
||||||
72
deploy/mainnet/docker-compose.supplier.yml
Normal file
72
deploy/mainnet/docker-compose.supplier.yml
Normal 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
|
||||||
69
deploy/testnet/docker-compose.buyer.yml
Normal file
69
deploy/testnet/docker-compose.buyer.yml
Normal 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
|
||||||
71
deploy/testnet/docker-compose.indexer.yml
Normal file
71
deploy/testnet/docker-compose.indexer.yml
Normal 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:
|
||||||
52
deploy/testnet/docker-compose.ollama.yml
Normal file
52
deploy/testnet/docker-compose.ollama.yml
Normal 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:
|
||||||
79
deploy/testnet/docker-compose.supplier.yml
Normal file
79
deploy/testnet/docker-compose.supplier.yml
Normal 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
50
deploy/traefik.yml
Normal 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
314
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
# Local Agents Marketplace — Architecture (v1)
|
||||||
|
|
||||||
|
> Status: accepted for M0–M1 implementation
|
||||||
|
> Dated: 2026-04-24
|
||||||
|
> Scope: MVP validating "can agents drive prompts through a buyer → on-chain escrow → supplier inference path on Vector L2 / Cardano" — starting with a CPU-bound supplier on Hetzner.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Goal and scope
|
||||||
|
|
||||||
|
**What this is.** A two-sided marketplace where buyers (agents or humans) submit prompts to suppliers running inference on their own hardware, pay with AP3X via on-chain bonded escrow on Vector L2, and receive signed-receipt responses. The primary validation goal is **technical feasibility + lifecycle** ("can it be done end-to-end"), not demand modelling or economic stress testing.
|
||||||
|
|
||||||
|
**What v1 is NOT.**
|
||||||
|
- Not agentic on the supplier side. Suppliers answer single prompts. Buyer agents drive multiple prompts.
|
||||||
|
- Not LLM-only in design. Capability model is open from day 1 (whisper / kokoro / image generation are planned siblings), but the first supplier is an LLM.
|
||||||
|
- Not multi-slot. Single-slot per supplier process. Multi-capability operators run multiple processes.
|
||||||
|
- Not multi-chain. Vector testnet first, mainnet later. Masumi / Sokosumi / USDM is target #2 (adapter, not fork).
|
||||||
|
- Not disputed. M1 ships happy-path escrow only. Module-1 disputes wire in M2.
|
||||||
|
- Not streaming. `stream: true` is rejected by suppliers.
|
||||||
|
- Not tool-calling. `tools` / `tool_choice` / `functions` are rejected by suppliers.
|
||||||
|
- Not privacy-preserving end-to-end. TLS to supplier only, SaaS-parity model, documented in supplier ToS.
|
||||||
|
|
||||||
|
## 2. Decisions resolved (Q&A → architecture inputs)
|
||||||
|
|
||||||
|
| # | Decision |
|
||||||
|
|---|---|
|
||||||
|
| 1 | Payment token: **AP3X** (Vector testnet, later mainnet). Masumi = USDM, target #2. |
|
||||||
|
| 2 | v1 scope: **Option C** — happy-path escrow only, no disputes. Module-1 wiring is milestone 2. |
|
||||||
|
| 3 | Module-1 contract source: TBD — to be located or restored before M2. Not blocking M0–M1. |
|
||||||
|
| 4 | Evidence: **signed receipt** `{prompt_hash, response_hash, model, tokens, wallclock}` + Ed25519 supplier signature. Hash on-chain, plaintext off-chain. |
|
||||||
|
| 5 | Capability model: **one capability per supplier process**, flat-per-prompt pricing. Multi-capability operators run multiple processes. |
|
||||||
|
| 6 | Supplier status: **polled** by indexer every ~20 s, staleness visible to buyers. |
|
||||||
|
| 7 | Supplier registry: **separate script hash** from Module-1, independently indexed. |
|
||||||
|
| 8 | First capability: `llm.text.generate.v1` — single-shot, no tools, no stream, flat-per-prompt, tiny Ollama (`qwen2.5:0.5b`) on CPU. |
|
||||||
|
| 9 | Tests: three-tier (mock / Ogmios read-only / real chain) **with an explicit `ChainProvider` interface** from day 1. Real CBOR fixtures. Independent buyer/supplier fixture construction. |
|
||||||
|
| 10 | Chain follower: extend the apex-dashboard pattern (Node.js + Ogmios + SQLite + REST/SSE). |
|
||||||
|
| 11 | Supplier process: **single-slot**. |
|
||||||
|
| 12 | Pricing race: buyer's escrow datum **references the ad UTxO** (spec-lock). Supplier honors the price in the referenced ad or lets the escrow time out. |
|
||||||
|
| 13 | SLA: supplier publishes `max_processing_ms` in the advertisement. Buyer sets `deliver_by = posted_at + max_processing_ms + network_buffer`. |
|
||||||
|
| 14 | Bonds: **symmetric 1 AP3X** on both sides. Buyer miss → forfeits bond. Supplier miss → forfeits bond. |
|
||||||
|
| 15 | Endpoint auth: `X-Escrow-Ref` header. Supplier verifies escrow on-chain before computing. |
|
||||||
|
| 16 | Privacy: SaaS-parity. TLS to supplier. Documented in ToS. |
|
||||||
|
|
||||||
|
## 3. Component diagram
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────┐ ┌────────────────────────┐
|
||||||
|
│ Buyer app │ HTTP + X-Escrow-Ref │ Supplier node │
|
||||||
|
│ (web UI + lib │────────────────────────▶│ FastAPI / Node │
|
||||||
|
│ for agents) │ │ /capability │
|
||||||
|
│ │ │ /status │
|
||||||
|
│ optional: │ │ /v1/chat/completions │
|
||||||
|
│ OpenRouter │ │ │ │
|
||||||
|
│ client-side │ │ ▼ │
|
||||||
|
│ fallback │ │ Ollama (local LLM) │
|
||||||
|
└────────┬─────────┘ └──────────┬─────────────┘
|
||||||
|
│ │
|
||||||
|
│ REST / SSE │
|
||||||
|
▼ │
|
||||||
|
┌──────────────────────────────┐ │
|
||||||
|
│ Indexer │◀──── poll /status ────┘
|
||||||
|
│ (chain follower + status │
|
||||||
|
│ poller, SQLite + REST/SSE) │
|
||||||
|
└──────────────┬───────────────┘
|
||||||
|
│
|
||||||
|
│ Ogmios (chain-sync + queryLedgerState)
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ Vector L2 (Cardano) │
|
||||||
|
│ AdvertScript EscrowScript (Module-1, M2+) │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Three on-chain scripts: **AdvertScript** (registry), **EscrowScript** (happy-path escrow), **Module-1** (disputes, deferred). Indexer and Ogmios are shared infra. OpenRouter is a buyer-side client escape hatch — outside the protocol.
|
||||||
|
|
||||||
|
## 4. On-chain datum schemas
|
||||||
|
|
||||||
|
### 4.1 AdvertDatum
|
||||||
|
|
||||||
|
```
|
||||||
|
AdvertDatum {
|
||||||
|
supplier_pkh: VerificationKeyHash,
|
||||||
|
capability_id: ByteArray, // "llm.text.generate.v1"
|
||||||
|
model: ByteArray, // "qwen2.5:0.5b"
|
||||||
|
max_output_tokens: Int,
|
||||||
|
max_processing_ms: Int, // SLA for deliver_by math
|
||||||
|
price_lovelace: Int, // AP3X lovelace, flat per prompt
|
||||||
|
supplier_bond_lovelace: Int,
|
||||||
|
buyer_bond_lovelace: Int,
|
||||||
|
endpoint_url: ByteArray, // https://...
|
||||||
|
detail_uri: ByteArray, // off-chain JSON pointer
|
||||||
|
detail_hash: ByteArray(32), // sha256 of detail JSON
|
||||||
|
advertised_at: POSIXTime,
|
||||||
|
status: AdvertStatus // Active | Retired
|
||||||
|
}
|
||||||
|
|
||||||
|
Redeemers: PostAdvert | UpdateAdvert | RetireAdvert
|
||||||
|
```
|
||||||
|
|
||||||
|
One supplier process = one `AdvertDatum` UTxO. Update = spend old + post new (spec-lock race resolved by referencing the specific UTxO in escrow).
|
||||||
|
|
||||||
|
### 4.2 EscrowDatum (happy path, v1)
|
||||||
|
|
||||||
|
```
|
||||||
|
EscrowDatum {
|
||||||
|
buyer_pkh: VerificationKeyHash,
|
||||||
|
supplier_pkh: VerificationKeyHash,
|
||||||
|
advert_ref: OutputReference, // SPEC-LOCK to ad UTxO
|
||||||
|
capability_id: ByteArray, // duplicated for indexer filter
|
||||||
|
request_spec_hash: ByteArray(32), // sha256 canonical JSON of request envelope
|
||||||
|
prompt_hash: ByteArray(32), // sha256 of messages
|
||||||
|
payment_lovelace: Int,
|
||||||
|
buyer_bond_lovelace: Int,
|
||||||
|
supplier_bond_lovelace: Int,
|
||||||
|
deliver_by: POSIXTime,
|
||||||
|
posted_at: POSIXTime,
|
||||||
|
submitted_at: Option<POSIXTime>, // set on Submit
|
||||||
|
result_receipt_hash: Option<ByteArray>, // set on Submit
|
||||||
|
state: EscrowState
|
||||||
|
}
|
||||||
|
|
||||||
|
EscrowState = Open | Claimed | Submitted | Accepted | Reclaimed | Released
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Redeemers
|
||||||
|
|
||||||
|
| Redeemer | Signer | Precondition | Effect |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `Claim` | supplier | state=Open | Open → Claimed |
|
||||||
|
| `Submit` | supplier | state=Claimed | Claimed → Submitted, writes `submitted_at` + `result_receipt_hash` |
|
||||||
|
| `Accept` | buyer | state=Submitted | Submitted → Accepted; supplier gets payment + own bond; buyer gets own bond |
|
||||||
|
| `Reclaim` | buyer | state∈{Open,Claimed} AND now ≥ `deliver_by` | → Reclaimed; buyer takes payment + both bonds |
|
||||||
|
| `Release` | supplier | state=Submitted AND now ≥ `submitted_at + ACCEPT_WINDOW` | → Released; supplier gets payment + own bond + buyer bond |
|
||||||
|
|
||||||
|
**Constants** (proposed, v1):
|
||||||
|
- `ACCEPT_WINDOW = 10 min`
|
||||||
|
- `network_buffer = 30 s` (buyer-side convention, not enforced on-chain)
|
||||||
|
|
||||||
|
### 4.4 State diagram
|
||||||
|
|
||||||
|
```
|
||||||
|
Reclaim (after deliver_by)
|
||||||
|
┌──────────────────────┐
|
||||||
|
▼ │
|
||||||
|
┌─────────┐ │
|
||||||
|
PostEscrow ─▶ │ Open │ ─ Claim ─┐ │
|
||||||
|
└─────────┘ ▼ │
|
||||||
|
┌─────────┐│
|
||||||
|
┌────────── │ Claimed │┘
|
||||||
|
│ └────┬────┘
|
||||||
|
Reclaim │ Submit
|
||||||
|
(after deliver_by) ▼
|
||||||
|
│ ┌───────────┐
|
||||||
|
▼ │ Submitted │
|
||||||
|
┌──────────┐ └─────┬─────┘
|
||||||
|
│Reclaimed │ ┌────────┴────────┐
|
||||||
|
└──────────┘ │ Accept │ Release
|
||||||
|
│ (buyer) │ (after ACCEPT_WINDOW)
|
||||||
|
▼ ▼
|
||||||
|
┌──────────┐ ┌──────────┐
|
||||||
|
│ Accepted │ │ Released │
|
||||||
|
└──────────┘ └──────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Terminal states: `Accepted`, `Reclaimed`, `Released`.
|
||||||
|
|
||||||
|
## 5. HTTP contracts
|
||||||
|
|
||||||
|
### 5.1 Supplier endpoints
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /capability
|
||||||
|
→ { capability_id, model, max_output_tokens, max_processing_ms,
|
||||||
|
price_lovelace, advert_ref, supplier_pkh }
|
||||||
|
Must match on-chain advertisement. Buyer SHOULD verify.
|
||||||
|
|
||||||
|
GET /status
|
||||||
|
→ { status: "free"|"working"|"offline",
|
||||||
|
current_escrow_ref?: "txHash#ix",
|
||||||
|
last_seen: iso8601 }
|
||||||
|
|
||||||
|
POST /v1/chat/completions
|
||||||
|
Headers: X-Escrow-Ref: <txHash>#<ix>
|
||||||
|
Body: OpenAI-compatible ChatCompletionRequest
|
||||||
|
Rejects: stream=true, tools[], tool_choice, functions
|
||||||
|
Rejects: capability mismatch, max_output_tokens > advertised
|
||||||
|
Flow:
|
||||||
|
1. Pull escrow UTxO by ref (via Ogmios query)
|
||||||
|
2. Verify: state=Open, supplier_pkh=me, capability_id matches,
|
||||||
|
request_spec_hash = sha256(canonical(envelope)),
|
||||||
|
prompt_hash = sha256(messages)
|
||||||
|
3. Submit Claim tx (Open → Claimed)
|
||||||
|
4. Call local LLM
|
||||||
|
5. Build receipt: { prompt_hash, response_hash, model,
|
||||||
|
prompt_tokens, completion_tokens, wallclock_ms,
|
||||||
|
supplier_pkh, escrow_ref }
|
||||||
|
6. Sign receipt (Ed25519 with supplier key)
|
||||||
|
7. Submit Submit tx with result_receipt_hash = sha256(canonical(receipt))
|
||||||
|
8. Return { choices, usage, receipt, receipt_signature }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Indexer endpoints
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /suppliers — all Active adverts + cached status
|
||||||
|
GET /suppliers/{pkh} — single supplier detail + recent jobs
|
||||||
|
GET /capabilities — distinct capability_id list with counts
|
||||||
|
GET /capabilities/{id}/suppliers — filtered list, sortable by price/latency
|
||||||
|
GET /escrows/{ref} — state lookup by OutputReference
|
||||||
|
GET /escrows?buyer={pkh}|supplier={pkh}
|
||||||
|
GET /events?stream=1 — SSE: new ads, escrow transitions
|
||||||
|
GET /health — sync cursor, Ogmios status
|
||||||
|
```
|
||||||
|
|
||||||
|
Every field traces back to chain via `advert_ref` / `escrow_ref`. Indexer serves cached data for speed.
|
||||||
|
|
||||||
|
## 6. Chain-follower extension (from apex-dashboard)
|
||||||
|
|
||||||
|
**Reused as-is**: `WsTransport`, `SqliteCache` core, `ChainSyncWorker` cursor/rollback logic, HTTP+SSE scaffold.
|
||||||
|
|
||||||
|
**Added for marketplace**:
|
||||||
|
- Config entry `config/marketplace-deployments.json` with `advert_script_hash` + `escrow_script_hash`.
|
||||||
|
- Two new address roles: `advert`, `escrow`.
|
||||||
|
- Two new decoders: `decodeAdvertDatum`, `decodeEscrowDatum`.
|
||||||
|
- Two new tables: `advertisements`, `escrows` (keyed by OutputReference).
|
||||||
|
- New event types: `PostAdvert`, `UpdateAdvert`, `RetireAdvert`, `PostEscrow`, `ClaimEscrow`, `SubmitEscrow`, `AcceptEscrow`, `ReclaimEscrow`, `ReleaseEscrow`.
|
||||||
|
- Status poller: background loop polling each Active supplier's `/status` every 20 s; writes to `supplier_status` table (off-chain).
|
||||||
|
- Aggregator: per-capability active-supplier counts, p95 wallclock from receipts.
|
||||||
|
|
||||||
|
Rollback semantics inherited: soft-delete + UTxO resurrection, per apex-dashboard `sqlite-cache.ts:328-360`.
|
||||||
|
|
||||||
|
## 7. Test architecture
|
||||||
|
|
||||||
|
### 7.1 Repository layout (target)
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/
|
||||||
|
shared/
|
||||||
|
chain/
|
||||||
|
ChainProvider.ts # interface: queryUtxo, submitTx, evaluateTx, tip
|
||||||
|
MockChainProvider.ts # Tier 1 — in-memory, synthetic slots
|
||||||
|
ReadOnlyOgmiosProvider.ts # Tier 2 — eval+query only, no submit
|
||||||
|
LiveOgmiosProvider.ts # Tier 3 — full submit
|
||||||
|
cbor/
|
||||||
|
AdvertDatum.ts
|
||||||
|
EscrowDatum.ts
|
||||||
|
canonical.ts # deterministic JSON (RFC-8785 subset)
|
||||||
|
receipt/
|
||||||
|
build.ts sign.ts verify.ts
|
||||||
|
buyer/
|
||||||
|
supplier/
|
||||||
|
indexer/
|
||||||
|
contracts/
|
||||||
|
escrow/ advert/
|
||||||
|
tests/
|
||||||
|
unit/ # Tier 1, vitest
|
||||||
|
ogmios/ # Tier 2, read-only
|
||||||
|
lifecycle/ # Tier 3, docker-composed
|
||||||
|
fixtures/
|
||||||
|
buyer-side/ # independent CBOR builders (no shared helper)
|
||||||
|
supplier-side/ # independent CBOR builders
|
||||||
|
golden/ # cross-validation CBOR blobs
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Discipline (from apex-dashboard + refinements)
|
||||||
|
|
||||||
|
- Real CBOR in fixtures (cbor-x), not JSON stubs.
|
||||||
|
- Buyer and supplier tests each build datums **independently from the spec**. No shared helper. Golden files cross-validate.
|
||||||
|
- `ChainProvider` is DI, not convention — fixes the missing seam in apex-dashboard.
|
||||||
|
- Property/fuzz tests on the escrow state machine (not present in apex-dashboard).
|
||||||
|
- Tier 3 runs under `SKIP_BEFORE_SLOT` from day 1 to avoid the glacial-sync pit.
|
||||||
|
- Mainnet kill-switch: env-gate + explicit `MAINNET=1` flag, hardcoded wallet allowlist.
|
||||||
|
|
||||||
|
### 7.3 Must-have adversarial cases for v1
|
||||||
|
|
||||||
|
Malformed datum · escrow to wrong script · supplier claims escrow addressed to other supplier · `request_spec_hash` / `prompt_hash` mismatch · Submit after `deliver_by` · Accept after `ACCEPT_WINDOW` · Reclaim before `deliver_by` · double-claim · double-submit · replay of Accept/Release · two concurrent escrows to same single-slot supplier · supplier status lies (`free` while actually `working`) · advert updated mid-flight (spec-lock must hold).
|
||||||
|
|
||||||
|
## 8. Milestones
|
||||||
|
|
||||||
|
| M | Goal | Duration | Exit criteria |
|
||||||
|
|----|------|----------|---------------|
|
||||||
|
| **M0** | Skeleton + `ChainProvider` seam | ~1 wk | Monorepo + CI + Tier 1 mock escrow lifecycle green |
|
||||||
|
| **M1** | Happy-path end-to-end, Hetzner CPU supplier | 2–3 wks | `qwen2.5:0.5b` on Hetzner, buyer posts → supplier claims → submits → buyer accepts; full Vector testnet lifecycle; indexer serves discovery |
|
||||||
|
| **M2** | Module-1 dispute wiring | 2–3 wks post-M1 | Contract source located; supplier=claimer / buyer=auditor mapping; Dispute state; evidence URI; mainnet-dry-run lifecycle |
|
||||||
|
| **M3** | Capability expansion + local lab | 2–4 wks (may overlap M2) | `speech.transcribe.v1` via Whisper live; local-lab GPU supplier with larger model; heterogeneous discovery validated |
|
||||||
|
| **M4** | Masumi / Sokosumi adapter | later | MIP-003 HTTP shim; MIP-002 NFT registration; USDM payment path; Sokosumi listing |
|
||||||
|
|
||||||
|
## 9. Open follow-ups (not blocking M0 / M1)
|
||||||
|
|
||||||
|
1. **Locate Module-1 contract source** before M2. Initial grep across local checkouts failed — possibly on another machine or in a private repo.
|
||||||
|
2. **Canonical JSON** for `request_spec_hash`, `prompt_hash`, receipt. Proposal: RFC-8785 JCS subset (sorted keys, UTF-8 NFC, no whitespace).
|
||||||
|
3. **`ACCEPT_WINDOW` and `network_buffer` constants**. Proposed 10 min / 30 s.
|
||||||
|
4. **Receipt signing key**: same wallet key as `supplier_pkh`, or derived Ed25519 sub-key? Same-key is simpler; sub-key isolates risk.
|
||||||
|
5. **Mainnet safety**: env-gate + explicit flag + wallet allowlist before any mainnet Tier-3 run.
|
||||||
|
6. **Ollama-failure-leaves-Claimed (v1 hazard)**: if the supplier fails between Claim and Submit, the escrow stays in `Claimed` state on-chain. v1 has no recovery path — buyer must wait for `deliver_by` and `Reclaim`. M2+ should add either a supplier-side "abandonment" redeemer or a "failure receipt" path that releases funds without going through the dispute module.
|
||||||
|
7. **Buyer-side wallet fixture has invalid Ed25519 priv** (62 hex chars / 31 bytes; `tests/fixtures/buyer-side/wallet-keys.ts:32`). Currently harmless because no test signs with the buyer key, but M1-E will need this fixed via the same `priv → pub → pkh → bech32` derivation Catherine used for the supplier in M1-C.
|
||||||
|
8. **Supplier `index.ts` boots with `ReadOnlyOgmiosProvider`** which throws on `submitTx`. Real-chain Claim/Submit is therefore broken until M1-F adds a `LiveOgmiosProvider`. Boot script must either swap providers or branch on env.
|
||||||
|
9. **Buyer SDK uses structural receipt-signature check, not real Ed25519** in Tier-1 unit tests. The supplier-side wallet fixture's `SUPPLIER_PKH` is currently a hand-typed placeholder that does NOT equal `blake2b224(SUPPLIER_PUB_KEY_HEX)`, so the SDK can't perform cryptographic `verifyReceipt(...)` end-to-end against fixtures. M1-F must (a) re-derive `SUPPLIER_PKH` from the real pubkey and update any hardcoded references, (b) ensure mocks return a real `pub_key_hex` from `/capability` matching that derivation, (c) replace the structural check (`receipt_signature` is 128-char hex and not all zeros) with `verifyReceipt({receipt, signature}, pub_key_hex)` from `@marketplace/shared/receipt`. Real-chain Tier-3 lifecycle in M1-F naturally exercises the cryptographic path.
|
||||||
|
10. **Buyer SDK `httpClient.ts` carries an `HttpError.isSyncThrow` flag** and a "record-failure-and-return-sentinel" branch in `submitPrompt`. This was Catherine's defensive workaround for a Caroline test fixture that produced invalid hex synchronously (now fixed). Production fetch never throws synchronously; the branch is dead code. M1-F should remove the flag and the sentinel branch.
|
||||||
|
11. **`supplier/src/index.ts` reads `SUPPLIER_PUB_KEY_HEX`/`SUPPLIER_PKH`/`SUPPLIER_ADDRESS` from env** with `?? ""` fallbacks rather than deriving them from the priv. Inline note in code: "M1-D will plug in the proper Ed25519 + blake2b-224 pipeline." For testnet rollout 2026-04-27 we pass these explicitly via supplier/.env (computed from cardano-cli `address key-hash` + the .vkey cbor). Proper fix: derive in `main()` using `@noble/ed25519` + `blake2b224` + `bech32` (the same pipeline the buyer fixture wallet uses) and stop reading the env vars.
|
||||||
|
12. **Marketplace tx-builders produce synthetic JSON-in-hex CBOR**, not real Cardano tx CBOR. `LiveOgmiosProvider.submitTx` will get HTTP 400 from Ogmios on any real tx. Workaround used 2026-04-27: a one-shot `pycardano` script (`/tmp/post-advert.py`, modelled on `~/.openclaw/workspace-apex/testnet/deploy_agent_registry.py`) builds a real Cardano CBOR PostAdvert tx and submits via `https://submit.vector.testnet.apexfusion.org/api/submit/tx`. Proper fix (M1-F-4): rewrite all tx builders in `packages/shared/src/tx/**` using `@lucid-evolution/lucid` (already installed) so `pnpm tx:post-advert` and the supplier's Claim/Submit paths work without pycardano. Without this, the supplier `/v1/chat/completions` path will fail at the Claim stage (same 400 from Ogmios) — `/healthz` and `/capability` work today because they're read-only.
|
||||||
|
13. **Min-UTxO at advert script address requires ≥ ~1.69 AP3X** (datum size dependent). The on-chain advert posted 2026-04-27 locks 2 AP3X (rounded up). The Aiken validator does not constrain the locked value, so the operator-side tx builder must pick `max(supplier_bond_lovelace, min_utxo_for_datum)` — currently hardcoded as `max(SBOND, 2_000_000)` in `/tmp/post-advert.py`; M1-F-4's lucid-evolution rewrite should compute it dynamically from protocol params.
|
||||||
|
14. **`tests/fixtures/supplier-side/wallet-keys.ts` `SUPPLIER_PKH` is a placeholder `"abcdef0123…01"`**, NOT `blake2b224(SUPPLIER_PUB_KEY_HEX)`. The correct value is `9f356dd4cb466bbdddbfcfa3f4f61aa2a264cdb48707e60d1c829263` and address `addr1vx0n2mw5edrxh0wahl868a8kr232yexdkjrs0esdrjpfyccgazpvh`. This was tolerable until M1-F-4 because tests didn't run real lucid UPLC; with M1-F-4 live-CBOR tests, the local validator rejects on `signed_by` because the wallet's derived signing key produces a vkey witness that doesn't match the placeholder. **Production is unaffected** — the supplier's `/capability` returns its real priv-derived pkh, buyer constructs EscrowDatum with that pkh, validator checks pass. Cleanup queued as `M1-F-4-fix-fixtures`. Blast radius: 17+ files (both goldens regenerate). After cleanup, the test-only `presetWalletInputs ≥5 ADA padding` workaround in `packages/shared/src/tx/internal/liveCbor.ts` should also be removed.
|
||||||
|
15. **`tests/fixtures/...` invalid-checksum `addr_test1wrqq9…` script address** decode-fails in CML; Catherine workaround: override input UTxO address to blueprint-derived form before `collectFrom`. Same `M1-F-4-fix-fixtures` round.
|
||||||
|
16. **lucid-evolution / pnpm-store interaction**: `libsodium-wrappers-sumo`'s wrapper does `import "./libsodium-sumo.mjs"` but pnpm hoists the `.mjs` to a sibling pkg. Today's runtime workaround: copy the file into the wrapper's dir post-install. Clean fix: `pnpm patch libsodium-wrappers-sumo` with the import-path change, commit the patch.
|
||||||
|
|
||||||
|
## 10. Reference pointers (external)
|
||||||
|
|
||||||
|
- Apex-dashboard chain-follower pattern: `apex-dashboard/server/chain-sync-worker.ts` (private project; clone alongside this repo for cross-reference)
|
||||||
|
- Apex-dashboard CBOR decoders: `apex-dashboard/server/cbor-decoder.ts`
|
||||||
|
- Apex-dashboard test discipline: `apex-dashboard/tests/`
|
||||||
|
- Prior Python reference (AP3X buyer/supplier): `./docs/buyer/`, `./docs/supplier/`
|
||||||
|
- Masumi docs: https://docs.masumi.network/
|
||||||
|
- MIP-003 spec: https://docs.masumi.network/mips/_mip-003
|
||||||
412
docs/EXECUTIVE_SUMMARY.md
Normal file
412
docs/EXECUTIVE_SUMMARY.md
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
---
|
||||||
|
marp: true
|
||||||
|
paginate: true
|
||||||
|
size: 16:9
|
||||||
|
---
|
||||||
|
|
||||||
|
<!--
|
||||||
|
EXECUTIVE_SUMMARY.md — slide-style overview of the Local Agents Marketplace.
|
||||||
|
Build PDF + PPTX with: bash docs/build-deck.sh
|
||||||
|
Edit this file; mermaid blocks are pre-rendered to docs/img/diagram-*.png.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* ── Local Agents Marketplace — unified dark theme ────────────────────
|
||||||
|
* Every slide uses the same navy→indigo gradient as the title slide so
|
||||||
|
* the deck reads as one continuous document. Title and closing slides
|
||||||
|
* keep the centered hero treatment; content slides stay top-aligned but
|
||||||
|
* share the same palette and accent rules. */
|
||||||
|
:root {
|
||||||
|
--ink: #f1f5f9; /* primary text on dark gradient */
|
||||||
|
--muted: #94a3b8;
|
||||||
|
--accent: #818cf8; /* indigo-400, readable on the dark gradient */
|
||||||
|
--accent-deep: #4f46e5; /* used for solid accents (h2 bar, table head) */
|
||||||
|
--accent-soft: rgba(129,140,248,0.12);
|
||||||
|
--gradient: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #312e81 100%);
|
||||||
|
--row-even: rgba(255,255,255,0.04);
|
||||||
|
--row-odd: rgba(255,255,255,0.02);
|
||||||
|
--border: rgba(255,255,255,0.10);
|
||||||
|
}
|
||||||
|
section {
|
||||||
|
background: var(--gradient);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||||
|
font-size: 22px;
|
||||||
|
padding: 40px 60px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
section::after {
|
||||||
|
content: attr(data-marpit-pagination) ' / ' attr(data-marpit-pagination-total);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 48px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
border-bottom: 4px solid var(--accent);
|
||||||
|
padding-bottom: 10px;
|
||||||
|
margin: 0 0 18px 0;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 34px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
margin: 0 0 14px 0;
|
||||||
|
}
|
||||||
|
h2::before {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 6px;
|
||||||
|
height: 28px;
|
||||||
|
background: var(--accent);
|
||||||
|
margin-right: 14px;
|
||||||
|
vertical-align: middle;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
h3 { color: #c7d2fe; font-weight: 600; font-size: 24px; margin: 8px 0; }
|
||||||
|
p, li { margin: 6px 0; line-height: 1.45; }
|
||||||
|
ul, ol { margin: 8px 0 12px 0; padding-left: 28px; }
|
||||||
|
strong { color: #ffffff; }
|
||||||
|
a { color: #a5b4fc; }
|
||||||
|
blockquote {
|
||||||
|
border-left: 4px solid var(--accent);
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
padding: 10px 18px;
|
||||||
|
color: #e0e7ff;
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
background: rgba(255,255,255,0.10);
|
||||||
|
color: #e0e7ff;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
/* Constrain mermaid images so they always fit on a 720px-tall slide
|
||||||
|
* (16:9 @ 1280×720 default). Soft white card behind the diagram so the
|
||||||
|
* mermaid SVG (which renders on a transparent canvas with dark ink)
|
||||||
|
* stays readable on the navy gradient. */
|
||||||
|
img {
|
||||||
|
display: block;
|
||||||
|
margin: 8px auto;
|
||||||
|
max-width: 92%;
|
||||||
|
max-height: 420px;
|
||||||
|
object-fit: contain;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
|
||||||
|
}
|
||||||
|
/* Tile grid for the local-agent versatility slide. */
|
||||||
|
.backends {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
margin: 14px 0 12px 0;
|
||||||
|
}
|
||||||
|
.tile {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border: 2px solid var(--accent);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.tile small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
.tile.dots {
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
border-style: dashed;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
.pipeline {
|
||||||
|
text-align: center;
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: #f8fafc;
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin: 6px auto 14px auto;
|
||||||
|
max-width: 760px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.pipeline span {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0 10px;
|
||||||
|
}
|
||||||
|
/* Tables sit on a solid white card so cell text reads cleanly against
|
||||||
|
* the dark gradient backdrop. Rounded outer corners + shadow match the
|
||||||
|
* mermaid card styling. `display:table` + `table-layout:fixed` are
|
||||||
|
* needed so the table actually consumes the full slide width — Marp's
|
||||||
|
* default theme leaves tables at content-natural width, which left
|
||||||
|
* blank gutters on either side. */
|
||||||
|
table {
|
||||||
|
display: table !important;
|
||||||
|
width: 100% !important;
|
||||||
|
table-layout: fixed;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 12px 0;
|
||||||
|
font-size: 0.82em;
|
||||||
|
color: #0f172a;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
|
||||||
|
}
|
||||||
|
td, th { word-wrap: break-word; overflow-wrap: anywhere; }
|
||||||
|
th {
|
||||||
|
background: var(--accent-deep);
|
||||||
|
color: #ffffff;
|
||||||
|
text-align: left;
|
||||||
|
padding: 9px 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
padding: 7px 12px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
tr:nth-child(even) td { background: #ffffff; }
|
||||||
|
tr:nth-child(odd) td { background: #f8fafc; }
|
||||||
|
/* code spans inside table cells need a light pill, not the dark one
|
||||||
|
* defined globally — otherwise they'd disappear into the white row. */
|
||||||
|
td code {
|
||||||
|
background: #eef2ff;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
/* Bold/strong inside cells must stay dark — the global rule paints
|
||||||
|
* <strong> white for the dark-gradient body text, which would render
|
||||||
|
* invisible against the white table card. */
|
||||||
|
td strong, th strong {
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
th strong {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
/* Title slide — centered hero treatment on the same gradient. */
|
||||||
|
section.title {
|
||||||
|
text-align: center;
|
||||||
|
padding: 100px 80px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
section.title h1 {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 64px;
|
||||||
|
margin: 0 auto 20px auto;
|
||||||
|
}
|
||||||
|
section.title blockquote {
|
||||||
|
margin: 28px auto;
|
||||||
|
max-width: 900px;
|
||||||
|
}
|
||||||
|
/* Closing slide — same centered treatment. */
|
||||||
|
section.closing {
|
||||||
|
text-align: center;
|
||||||
|
padding: 100px 80px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
section.closing h2::before { display: none; }
|
||||||
|
section.closing blockquote { font-size: 26px; max-width: 900px; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- _class: title -->
|
||||||
|
|
||||||
|
# Local Agents Marketplace
|
||||||
|
|
||||||
|
### A trust-minimised, on-chain marketplace for LLM inference
|
||||||
|
|
||||||
|
Buy and sell prompt → response with **bonded escrow** on **Vector** (UTxO).
|
||||||
|
No platform fee. No central rate-limiter. Verifiable receipts on chain.
|
||||||
|
|
||||||
|
> *Status: M1 lifecycle proven end-to-end on Vector testnet — 2026-05-05.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The problem
|
||||||
|
|
||||||
|
* AI inference is **centralised** — a handful of providers, opaque pricing, account-level rate limits.
|
||||||
|
* Local-AI hardware is **idle most of the time** — GPUs and CPUs sit unused between user prompts.
|
||||||
|
* Buyers and sellers can't transact directly without a **trusted intermediary** taking a cut.
|
||||||
|
* "Verifiable" claims about which model ran are usually nothing more than the provider's word.
|
||||||
|
|
||||||
|
We need a **two-sided marketplace** where:
|
||||||
|
1. Sellers monetise spare compute,
|
||||||
|
2. Buyers get a **cryptographic receipt** of what was actually computed,
|
||||||
|
3. **Neither side has to trust the other** — bonds + on-chain settlement enforce honesty.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How it works — three actors, one chain
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph CHAIN[Vector — UTxO]
|
||||||
|
ADV[Advert UTxO<br/>capability, price, bonds]
|
||||||
|
ESC[Escrow UTxO<br/>locked funds + state]
|
||||||
|
end
|
||||||
|
|
||||||
|
SUP[🧠 Supplier<br/>local LLM node]
|
||||||
|
BUY[💼 Buyer<br/>app or agent]
|
||||||
|
IDX[🔭 Indexer<br/>read-only bulletin board]
|
||||||
|
|
||||||
|
SUP -->|publish advert| ADV
|
||||||
|
BUY -->|discover| IDX
|
||||||
|
IDX -.->|index events| CHAIN
|
||||||
|
BUY -->|post escrow| ESC
|
||||||
|
BUY -->|prompt + escrow_ref| SUP
|
||||||
|
SUP -->|signed receipt| BUY
|
||||||
|
SUP -->|claim & submit| ESC
|
||||||
|
BUY -->|accept & pay| ESC
|
||||||
|
```
|
||||||
|
|
||||||
|
* **Supplier** runs the inference and publishes a price.
|
||||||
|
* **Buyer** posts the work as a bonded escrow, sends the prompt, accepts the receipt.
|
||||||
|
* **Indexer** is a public mirror of chain state — anyone can run one. No platform middleman.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The bonded-escrow lifecycle
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
autonumber
|
||||||
|
participant B as Buyer
|
||||||
|
participant Chain as Vector
|
||||||
|
participant S as Supplier
|
||||||
|
participant LLM as Local LLM<br/>(OpenAI-compatible)
|
||||||
|
|
||||||
|
S->>Chain: PostAdvert (locks supplier_bond)
|
||||||
|
B->>Chain: PostEscrow → Open<br/>(locks payment + bonds)
|
||||||
|
B->>S: POST /v1/chat/completions
|
||||||
|
S->>Chain: ClaimEscrow → Claimed
|
||||||
|
S->>LLM: run inference
|
||||||
|
LLM-->>S: assistant message
|
||||||
|
S->>Chain: SubmitEscrow → Submitted
|
||||||
|
S-->>B: 200 OK + signed receipt
|
||||||
|
B->>Chain: AcceptEscrow — supplier paid
|
||||||
|
```
|
||||||
|
|
||||||
|
Every transition is on-chain, validated by an Aiken Plutus V3 script. The receipt is signed by the supplier's advert key; prompt and response hashes are bound on chain.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What if someone misbehaves?
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
OPEN[Open]:::norm --> CLAIMED[Claimed]:::norm --> SUBMITTED[Submitted]:::norm --> ACCEPTED[Accepted ✓]:::good
|
||||||
|
OPEN -->|deliver_by passed| RECLAIM[Reclaimed<br/>buyer recovers funds]:::warn
|
||||||
|
CLAIMED -->|deliver_by passed| RECLAIM
|
||||||
|
SUBMITTED -->|accept_window passed| RELEASE[Released<br/>supplier collects]:::warn
|
||||||
|
|
||||||
|
classDef norm fill:#e0e7ff,stroke:#4f46e5
|
||||||
|
classDef good fill:#dcfce7,stroke:#16a34a
|
||||||
|
classDef warn fill:#fef3c7,stroke:#d97706
|
||||||
|
```
|
||||||
|
|
||||||
|
| Failure mode | Recovery | Loss bearer |
|
||||||
|
|---|---|---|
|
||||||
|
| Supplier never claims/submits | Buyer **Reclaim** after `deliver_by` | Supplier — forfeits supplier_bond |
|
||||||
|
| Buyer disappears after Submit | Supplier **Release** after `submitted_at + 10 min` | Buyer — forfeits buyer_bond |
|
||||||
|
|
||||||
|
Bonds make honesty cheaper than running away. Both sides have skin in the game; no third-party arbiter is needed for the M1 happy/timeout paths.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Local-agent versatility — any OpenAI-compatible engine
|
||||||
|
|
||||||
|
<div class="pipeline">
|
||||||
|
<span>💼 Buyer</span> →
|
||||||
|
<span>🧠 Supplier (OpenAI-compatible HTTP wrapper)</span> →
|
||||||
|
<span>backend ↓</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="backends">
|
||||||
|
<div class="tile">Ollama<br/><small>local model runner</small></div>
|
||||||
|
<div class="tile">vLLM<br/><small>high-throughput GPU</small></div>
|
||||||
|
<div class="tile">llama.cpp<br/><small>CPU · Metal · ROCm</small></div>
|
||||||
|
<div class="tile">OpenRouter<br/><small>cloud API mux</small></div>
|
||||||
|
<div class="tile">openclaw / custom<br/><small>arbitrary stack</small></div>
|
||||||
|
<div class="tile dots">…<br/><small>any OpenAI-shaped API</small></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
The supplier service is a thin HTTP wrapper — it forwards `/v1/chat/completions` to whatever backend is configured. Each supplier picks its own model, hardware, and engine, then advertises a `capability_id`. Buyers shop on capability + model + price.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Capability + model = task selection
|
||||||
|
|
||||||
|
| Capability ID | Example models | Use case |
|
||||||
|
|---|---|---|
|
||||||
|
| `llm.text.generate.v1` | Llama 3 · Qwen 2.5 · Mistral · GPT-4 | Q&A, drafts, summarisation |
|
||||||
|
| `code.completion.v1` | Codestral · DeepSeek-Coder · StarCoder | IDE tab-complete, refactor |
|
||||||
|
| `vision.describe.v1` | LLaVA · Qwen-VL · Pixtral | Image captioning, OCR |
|
||||||
|
| `embedding.text.v1` | nomic-embed · BGE · ada-002 | RAG indexing |
|
||||||
|
| `audio.transcribe.v1` | whisper-large-v3 · distil-whisper | Voice-to-text |
|
||||||
|
| `…` | … | … |
|
||||||
|
|
||||||
|
* New capabilities = **just publish a new advert**. No marketplace governance gate.
|
||||||
|
* Today's deploy uses Ollama + Qwen 2.5; switching to vLLM with Llama 3 70B is a config change, not a code change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OpenClaw — agentic teams on your existing OpenAI seat
|
||||||
|
|
||||||
|
<div class="pipeline">
|
||||||
|
<span>🧠 Supplier wrapper</span> →
|
||||||
|
<span>🦾 OpenClaw</span> →
|
||||||
|
<span>🪪 OpenAI ChatGPT seat (flat-rate)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
* **Cost-efficient by default.** OpenClaw exposes your existing **OpenAI ChatGPT subscription** as an OpenAI-compatible API. The marketplace supplier dispatches its inference against it, so an entire **agentic team** (coders, QA automation testers, release curators…) draws from the **flat-rate seat you already pay for** — no per-token metering, no surprise invoices.
|
||||||
|
* **Reliability layer (optional, marginal cost).** OpenClaw can fall back to the **Anthropic API** or **OpenRouter** model pool when the primary seat is rate-limited, the prompt requires a different model, or you want a **second-layer hardening** of responses (e.g. cross-provider consensus on critical outputs). You only pay metered tokens for the requests that actually fall through.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Proven on Vector testnet — 2026-05-05
|
||||||
|
|
||||||
|
| Step | Tx hash |
|
||||||
|
|---|---|
|
||||||
|
| Buyer posts escrow | `af762561…c2f7bc5` |
|
||||||
|
| Supplier claims | (lifecycle tx) |
|
||||||
|
| Supplier submits receipt | (lifecycle tx) |
|
||||||
|
| Buyer accepts | `125e3cfe…7dc100d` |
|
||||||
|
|
||||||
|
Funds flowed correctly: supplier received `payment + supplier_bond` (3 ADA); buyer got `buyer_bond` back.
|
||||||
|
|
||||||
|
* **Browser demo:** https://mp-buyers.vector.testnet.apexfusion.org
|
||||||
|
* **Live event stream:** https://mp-indexer.vector.testnet.apexfusion.org
|
||||||
|
* **Test suite:** 1098 unit tests passing, full lifecycle exercised in CI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why UTxO + Vector
|
||||||
|
|
||||||
|
* **Bonded escrow is native** — Plutus validators enforce the state machine without a custom token or oracle.
|
||||||
|
* **Vector settles in seconds** — the buyer→claim→submit→accept loop fits inside a single chat-completion's wall clock.
|
||||||
|
* **AP3X token** for fees and bonds — fungible, transparent, on-chain.
|
||||||
|
* **No custom rollup, no bridge** — runs on a production chain today.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- _class: closing -->
|
||||||
|
|
||||||
|
## The pitch in one sentence
|
||||||
|
|
||||||
|
> Local Agents Marketplace turns any OpenAI-compatible inference endpoint into a **trustlessly billable service** — buyer and supplier exchange prompt, response, and payment with on-chain proof that what was paid for is what was delivered.
|
||||||
|
|
||||||
|
### Try it
|
||||||
|
|
||||||
|
* **Indexer (live event stream):** https://mp-indexer.vector.testnet.apexfusion.org
|
||||||
|
* **Buyer:** https://mp-buyers.vector.testnet.apexfusion.org
|
||||||
|
* **Supplier:** https://mp-suppliers.vector.testnet.apexfusion.org/capability
|
||||||
BIN
docs/EXECUTIVE_SUMMARY.pdf
Normal file
BIN
docs/EXECUTIVE_SUMMARY.pdf
Normal file
Binary file not shown.
BIN
docs/EXECUTIVE_SUMMARY.pptx
Normal file
BIN
docs/EXECUTIVE_SUMMARY.pptx
Normal file
Binary file not shown.
78
docs/build-deck.sh
Executable file
78
docs/build-deck.sh
Executable file
@@ -0,0 +1,78 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# build-deck.sh — render docs/EXECUTIVE_SUMMARY.md to PDF + PPTX with
|
||||||
|
# inline mermaid diagrams rasterised to PNG (Marp's PDF/PPTX exporters
|
||||||
|
# don't render mermaid blocks themselves, so we pre-render them with
|
||||||
|
# mermaid-cli and substitute image references into a copy of the markdown).
|
||||||
|
#
|
||||||
|
# Outputs:
|
||||||
|
# docs/EXECUTIVE_SUMMARY.pdf
|
||||||
|
# docs/EXECUTIVE_SUMMARY.pptx
|
||||||
|
# docs/img/diagram-*.png (intermediate)
|
||||||
|
# docs/EXECUTIVE_SUMMARY.rendered.md (intermediate; mermaid → !(img))
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
SRC=docs/EXECUTIVE_SUMMARY.md
|
||||||
|
TMP=docs/EXECUTIVE_SUMMARY.rendered.md
|
||||||
|
IMGDIR=docs/img
|
||||||
|
PDF=docs/EXECUTIVE_SUMMARY.pdf
|
||||||
|
PPTX=docs/EXECUTIVE_SUMMARY.pptx
|
||||||
|
|
||||||
|
mkdir -p "$IMGDIR"
|
||||||
|
# Clear both .mmd and .png so removing a mermaid block from the source
|
||||||
|
# also removes its image artifacts (otherwise stale diagrams would linger
|
||||||
|
# in docs/img/ and might be picked up by an external viewer).
|
||||||
|
rm -f "$IMGDIR"/diagram-*.mmd "$IMGDIR"/diagram-*.png
|
||||||
|
|
||||||
|
# ── 1. Extract each ```mermaid``` block → diagram-NNN.mmd → diagram-NNN.png
|
||||||
|
node - <<'JS'
|
||||||
|
const fs = require('fs');
|
||||||
|
const md = fs.readFileSync('docs/EXECUTIVE_SUMMARY.md', 'utf8');
|
||||||
|
const re = /```mermaid\n([\s\S]*?)```/g;
|
||||||
|
let m, n = 0;
|
||||||
|
const blocks = [];
|
||||||
|
while ((m = re.exec(md)) !== null) {
|
||||||
|
n++;
|
||||||
|
const file = `docs/img/diagram-${String(n).padStart(3,'0')}.mmd`;
|
||||||
|
fs.writeFileSync(file, m[1]);
|
||||||
|
blocks.push({ file, png: file.replace(/\.mmd$/, '.png') });
|
||||||
|
}
|
||||||
|
fs.writeFileSync('docs/img/.blocks.json', JSON.stringify(blocks));
|
||||||
|
console.log(`extracted ${n} mermaid blocks`);
|
||||||
|
JS
|
||||||
|
|
||||||
|
# ── 2. Render each .mmd → .png with mermaid-cli
|
||||||
|
for f in "$IMGDIR"/diagram-*.mmd; do
|
||||||
|
png="${f%.mmd}.png"
|
||||||
|
echo "rendering $f → $png"
|
||||||
|
npx --yes @mermaid-js/mermaid-cli@latest -i "$f" -o "$png" \
|
||||||
|
-t default -b transparent --width 1600 --height 900 --scale 2 \
|
||||||
|
--puppeteerConfigFile docs/puppeteer.json
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── 3. Build the rendered markdown — mermaid blocks → image references
|
||||||
|
node - <<'JS'
|
||||||
|
const fs = require('fs');
|
||||||
|
const md = fs.readFileSync('docs/EXECUTIVE_SUMMARY.md', 'utf8');
|
||||||
|
let n = 0;
|
||||||
|
const out = md.replace(/```mermaid\n([\s\S]*?)```/g, () => {
|
||||||
|
n++;
|
||||||
|
const png = `img/diagram-${String(n).padStart(3,'0')}.png`;
|
||||||
|
return ``;
|
||||||
|
});
|
||||||
|
fs.writeFileSync('docs/EXECUTIVE_SUMMARY.rendered.md', out);
|
||||||
|
console.log(`substituted ${n} blocks`);
|
||||||
|
JS
|
||||||
|
|
||||||
|
# ── 4. Render PDF + PPTX with Marp CLI
|
||||||
|
echo "→ PDF"
|
||||||
|
npx --yes @marp-team/marp-cli@latest --pdf --allow-local-files \
|
||||||
|
-o "$PDF" "$TMP"
|
||||||
|
|
||||||
|
echo "→ PPTX"
|
||||||
|
npx --yes @marp-team/marp-cli@latest --pptx --allow-local-files \
|
||||||
|
-o "$PPTX" "$TMP"
|
||||||
|
|
||||||
|
echo "done. ls -la docs/EXECUTIVE_SUMMARY.*"
|
||||||
|
ls -la docs/EXECUTIVE_SUMMARY.*
|
||||||
13
docs/buyer/.env.example
Normal file
13
docs/buyer/.env.example
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Chain
|
||||||
|
VECTOR_OGMIOS_URL=https://ogmios.vector.testnet.apexfusion.org
|
||||||
|
VECTOR_SUBMIT_URL=https://submit.vector.apexfusion.org/api/submit/tx
|
||||||
|
# Wallet
|
||||||
|
BUYER_MNEMONIC=your fifteen word mnemonic here
|
||||||
|
# Contract
|
||||||
|
ESCROW_SCRIPT_HASH=74d7f72f7ef0e2e79290ccf8d2eeda672b43379f037def441709395d
|
||||||
|
# Resolver
|
||||||
|
RESOLVER_ADDRESS=addr1qxql380vv0up49vyw6ey8rsgut7saqval7fc40e9hvy9t60xw4lf7cz8rxkq9fmes4yf72tvjcjuh0tweghyrcpnl9vs6h5gjs
|
||||||
|
# Known suppliers (comma-separated URLs)
|
||||||
|
SUPPLIER_URLS=http://localhost:9000
|
||||||
|
# Server
|
||||||
|
BUYER_PORT=8080
|
||||||
220
docs/buyer/README.md
Normal file
220
docs/buyer/README.md
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
# Vector Buyer App
|
||||||
|
|
||||||
|
A self-contained app for purchasing AI compute on the Vector L2 testnet (Cardano). You submit prompts, pay with AP3X tokens via on-chain escrow, and receive LLM responses from supplier nodes.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. You type a **prompt** and set a **payment** amount in the web UI
|
||||||
|
2. The app posts an **escrow transaction** on-chain, locking your payment + bond
|
||||||
|
3. The app sends the task to a **supplier node** via HTTP
|
||||||
|
4. The supplier calls an LLM, **claims** the escrow, and **submits** the result hash on-chain
|
||||||
|
5. You see the response in the UI and can **accept** to release payment, or **dispute**
|
||||||
|
|
||||||
|
All on-chain settlement happens automatically. You just interact with the web UI.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Install Python
|
||||||
|
|
||||||
|
You need Python 3.9 or newer. Download from https://www.python.org/downloads/
|
||||||
|
|
||||||
|
### 2. Create a virtual environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd buyer
|
||||||
|
python -m venv .venv
|
||||||
|
|
||||||
|
# Windows:
|
||||||
|
.venv\Scripts\activate
|
||||||
|
|
||||||
|
# macOS/Linux:
|
||||||
|
source .venv/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Install dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Configure your environment
|
||||||
|
|
||||||
|
Copy the example file and fill in your values:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
copy .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `.env` with your settings:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Your wallet seed phrase (15 words)
|
||||||
|
BUYER_MNEMONIC=word1 word2 word3 ... word15
|
||||||
|
|
||||||
|
# Supplier URLs (comma-separated)
|
||||||
|
SUPPLIER_URLS=http://SUPPLIER_IP:9000
|
||||||
|
|
||||||
|
# Port to run on (default 8080)
|
||||||
|
BUYER_PORT=8080
|
||||||
|
```
|
||||||
|
|
||||||
|
The other values (VECTOR_OGMIOS_URL, VECTOR_SUBMIT_URL, ESCROW_SCRIPT_HASH, RESOLVER_ADDRESS) are pre-configured for the Vector testnet and should not need changing.
|
||||||
|
|
||||||
|
### 5. Fund your wallet
|
||||||
|
|
||||||
|
Your buyer wallet needs AP3X to pay for compute tasks and escrow bonds. You need at least **10 AP3X** to get started.
|
||||||
|
|
||||||
|
When you start the app, the UI header shows your wallet address and balance. Send AP3X to that address.
|
||||||
|
|
||||||
|
### 6. Start the app
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
You will see:
|
||||||
|
```
|
||||||
|
Starting Vector Buyer App on port 8080
|
||||||
|
UI: http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Open the web UI
|
||||||
|
|
||||||
|
Go to **http://localhost:8080** in your browser.
|
||||||
|
|
||||||
|
## Using the Web UI
|
||||||
|
|
||||||
|
### Submitting a Task
|
||||||
|
|
||||||
|
1. Type your prompt in the text area
|
||||||
|
2. Set the payment amount with the slider (in AP3X)
|
||||||
|
3. Select a supplier from the dropdown
|
||||||
|
4. Click **Submit Task**
|
||||||
|
5. Wait for the response (typically 15-40 seconds)
|
||||||
|
|
||||||
|
The app will:
|
||||||
|
- Post the escrow on-chain (locks payment + 1 AP3X bond)
|
||||||
|
- Send the task to the supplier
|
||||||
|
- Display the response when ready
|
||||||
|
|
||||||
|
### Accepting a Result
|
||||||
|
|
||||||
|
After receiving a response, click **Accept & Pay** to release payment to the supplier. This completes the on-chain escrow lifecycle.
|
||||||
|
|
||||||
|
If you do not accept:
|
||||||
|
- Your payment stays locked in escrow
|
||||||
|
- After the dispute window (10 minutes), the supplier can collect payment automatically
|
||||||
|
- You lose your bond (1 AP3X) as a penalty for not responding
|
||||||
|
|
||||||
|
### Task History
|
||||||
|
|
||||||
|
Click **Task History** in the nav bar to see all past tasks. Click any task to expand it and see full details including TX hashes.
|
||||||
|
|
||||||
|
## Connecting to a Supplier
|
||||||
|
|
||||||
|
You need at least one supplier URL. The supplier operator will give you their URL.
|
||||||
|
|
||||||
|
**Same machine (testing):**
|
||||||
|
```
|
||||||
|
SUPPLIER_URLS=http://localhost:9000
|
||||||
|
```
|
||||||
|
|
||||||
|
**Local network:**
|
||||||
|
```
|
||||||
|
SUPPLIER_URLS=http://192.168.1.50:9000
|
||||||
|
```
|
||||||
|
|
||||||
|
**Remote / internet:**
|
||||||
|
```
|
||||||
|
SUPPLIER_URLS=http://supplier.example.com:9000
|
||||||
|
```
|
||||||
|
|
||||||
|
**Multiple suppliers:**
|
||||||
|
```
|
||||||
|
SUPPLIER_URLS=http://supplier1.com:9000,http://supplier2.com:9000
|
||||||
|
```
|
||||||
|
|
||||||
|
The app auto-discovers which suppliers are online and shows them in the dropdown.
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
buyer/
|
||||||
|
main.py Entry point - starts the web server
|
||||||
|
config.py Loads settings from .env
|
||||||
|
wallet.py Initializes blockchain wallet
|
||||||
|
api.py HTTP API endpoints
|
||||||
|
task_manager.py Orchestrates the full buy flow
|
||||||
|
escrow_ops.py On-chain escrow transactions
|
||||||
|
supplier_client.py HTTP client for talking to suppliers
|
||||||
|
discovery.py Auto-discovers online suppliers
|
||||||
|
storage.py Task history (JSON file)
|
||||||
|
.env Your configuration (secret - do not share)
|
||||||
|
.env.example Template for .env
|
||||||
|
requirements.txt Python dependencies
|
||||||
|
static/
|
||||||
|
app.html Web UI
|
||||||
|
lib/ Blockchain utilities
|
||||||
|
constants.py Protocol constants
|
||||||
|
chain.py Chain query helpers
|
||||||
|
datum.py CBOR datum builders
|
||||||
|
escrow_tx.py Transaction builder
|
||||||
|
blueprint.py Smart contract loader
|
||||||
|
protocol.py API data models
|
||||||
|
contracts/
|
||||||
|
escrow/
|
||||||
|
plutus.json Compiled smart contract
|
||||||
|
data/
|
||||||
|
tasks.json Task history (auto-created)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Reference
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| VECTOR_OGMIOS_URL | Blockchain node URL | Vector testnet |
|
||||||
|
| VECTOR_SUBMIT_URL | TX submission URL | Vector testnet |
|
||||||
|
| BUYER_MNEMONIC | Your 15-word wallet seed phrase | (required) |
|
||||||
|
| ESCROW_SCRIPT_HASH | Escrow contract hash | Pre-configured |
|
||||||
|
| RESOLVER_ADDRESS | Dispute resolver address | Pre-configured |
|
||||||
|
| SUPPLIER_URLS | Comma-separated supplier URLs | (required) |
|
||||||
|
| BUYER_PORT | HTTP port for the web UI | 8080 |
|
||||||
|
|
||||||
|
## Cost Breakdown
|
||||||
|
|
||||||
|
Each task costs:
|
||||||
|
- **Payment**: The amount you set (e.g. 5 AP3X) - goes to the supplier
|
||||||
|
- **Buyer bond**: 1 AP3X - returned to you when you accept
|
||||||
|
- **TX fees**: ~0.2-0.5 AP3X per transaction (escrow post + accept = 2 TXs)
|
||||||
|
|
||||||
|
If you accept the result, you get your 1 AP3X bond back. Total cost = payment + fees.
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- **Never share your .env file** - it contains your wallet seed phrase
|
||||||
|
- **Never share your data/ folder** - it contains task history
|
||||||
|
- The seed phrase controls your wallet funds. Back it up securely.
|
||||||
|
- Escrow protects you: the supplier cannot take your funds without delivering a result
|
||||||
|
- You have a 10-minute dispute window after receiving a result
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**"No module named 'vector_agent'"**
|
||||||
|
- Make sure you installed dependencies: `pip install -r requirements.txt`
|
||||||
|
|
||||||
|
**"ogmios_url required"**
|
||||||
|
- Make sure your .env file exists and is in the buyer/ folder
|
||||||
|
|
||||||
|
**"No supplier URL provided"**
|
||||||
|
- Add at least one supplier URL to SUPPLIER_URLS in your .env
|
||||||
|
|
||||||
|
**"0 suppliers available" in the UI**
|
||||||
|
- The supplier node may be offline. Check with the supplier operator.
|
||||||
|
- Verify the URL is correct and reachable from your machine.
|
||||||
|
|
||||||
|
**Balance shows 0 or very low**
|
||||||
|
- Fund your wallet by sending AP3X to the address shown in the UI header.
|
||||||
|
|
||||||
|
**Task fails with timeout**
|
||||||
|
- The supplier may be overloaded. Try again or use a different supplier.
|
||||||
|
- Check your internet connection to both the supplier and the Vector testnet.
|
||||||
0
docs/buyer/__init__.py
Normal file
0
docs/buyer/__init__.py
Normal file
88
docs/buyer/api.py
Normal file
88
docs/buyer/api.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
"""FastAPI router -- buyer API endpoints."""
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from config import get_config
|
||||||
|
from wallet import get_buyer_agent
|
||||||
|
from task_manager import submit_task, accept_task
|
||||||
|
from discovery import discover_suppliers
|
||||||
|
import storage
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class SubmitRequest(BaseModel):
|
||||||
|
prompt: str
|
||||||
|
payment_apex: float = 5.0
|
||||||
|
supplier_url: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/submit")
|
||||||
|
async def api_submit(req: SubmitRequest):
|
||||||
|
"""Submit a new task: post escrow, send to supplier, return result."""
|
||||||
|
config = get_config()
|
||||||
|
buyer_agent = get_buyer_agent()
|
||||||
|
|
||||||
|
supplier_url = req.supplier_url or (config.supplier_urls[0] if config.supplier_urls else "")
|
||||||
|
if not supplier_url:
|
||||||
|
raise HTTPException(status_code=400, detail="No supplier URL provided")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await submit_task(
|
||||||
|
prompt=req.prompt,
|
||||||
|
payment_apex=req.payment_apex,
|
||||||
|
supplier_url=supplier_url,
|
||||||
|
buyer_agent=buyer_agent,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
storage.save_task(result)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/accept/{task_id}")
|
||||||
|
async def api_accept(task_id: str):
|
||||||
|
"""Accept a completed task result and release payment."""
|
||||||
|
config = get_config()
|
||||||
|
buyer_agent = get_buyer_agent()
|
||||||
|
|
||||||
|
try:
|
||||||
|
accept_tx = await accept_task(task_id, buyer_agent, config)
|
||||||
|
storage.update_task(task_id, {"accept_tx": accept_tx, "status": "accepted"})
|
||||||
|
return {"accept_tx": accept_tx}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/tasks")
|
||||||
|
async def api_tasks():
|
||||||
|
"""Return task history from local storage."""
|
||||||
|
tasks = storage.load_tasks()
|
||||||
|
return {"tasks": tasks, "total": len(tasks)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/suppliers")
|
||||||
|
async def api_suppliers():
|
||||||
|
"""Discover reachable suppliers and return their info."""
|
||||||
|
config = get_config()
|
||||||
|
suppliers = await discover_suppliers(config)
|
||||||
|
return {
|
||||||
|
"suppliers": [s.model_dump() for s in suppliers],
|
||||||
|
"total": len(suppliers),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/balance")
|
||||||
|
async def api_balance():
|
||||||
|
"""Return the buyer wallet balance."""
|
||||||
|
try:
|
||||||
|
buyer_agent = get_buyer_agent()
|
||||||
|
balance = await buyer_agent.get_balance()
|
||||||
|
return {
|
||||||
|
"address": balance.address,
|
||||||
|
"ada": balance.ada,
|
||||||
|
"lovelace": balance.lovelace,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
35
docs/buyer/config.py
Normal file
35
docs/buyer/config.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"""Buyer configuration -- loads .env and exposes typed settings."""
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv(Path(__file__).parent / ".env")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BuyerConfig:
|
||||||
|
ogmios_url: str
|
||||||
|
submit_url: str
|
||||||
|
buyer_mnemonic: str
|
||||||
|
escrow_script_hash: str
|
||||||
|
resolver_address: str
|
||||||
|
supplier_urls: List[str] = field(default_factory=list)
|
||||||
|
port: int = 8080
|
||||||
|
|
||||||
|
|
||||||
|
def get_config() -> BuyerConfig:
|
||||||
|
raw_urls = os.getenv("SUPPLIER_URLS", "http://localhost:9000")
|
||||||
|
supplier_urls = [u.strip() for u in raw_urls.split(",") if u.strip()]
|
||||||
|
|
||||||
|
return BuyerConfig(
|
||||||
|
ogmios_url=os.getenv("VECTOR_OGMIOS_URL", "https://ogmios.vector.testnet.apexfusion.org"),
|
||||||
|
submit_url=os.getenv("VECTOR_SUBMIT_URL", "https://submit.vector.apexfusion.org/api/submit/tx"),
|
||||||
|
buyer_mnemonic=os.getenv("BUYER_MNEMONIC", ""),
|
||||||
|
escrow_script_hash=os.getenv("ESCROW_SCRIPT_HASH", ""),
|
||||||
|
resolver_address=os.getenv("RESOLVER_ADDRESS", ""),
|
||||||
|
supplier_urls=supplier_urls,
|
||||||
|
port=int(os.getenv("BUYER_PORT", "8080")),
|
||||||
|
)
|
||||||
257
docs/buyer/contracts/escrow/plutus.json
Normal file
257
docs/buyer/contracts/escrow/plutus.json
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
{
|
||||||
|
"preamble": {
|
||||||
|
"title": "vector/escrow",
|
||||||
|
"description": "Vector AI Compute Network — Optimistic Escrow Contract",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"plutusVersion": "v3",
|
||||||
|
"compiler": {
|
||||||
|
"name": "Aiken",
|
||||||
|
"version": "v1.1.21+unknown"
|
||||||
|
},
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
|
"validators": [
|
||||||
|
{
|
||||||
|
"title": "escrow.escrow.spend",
|
||||||
|
"datum": {
|
||||||
|
"title": "datum",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/escrow~1EscrowDatum"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"redeemer": {
|
||||||
|
"title": "action",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/escrow~1EscrowAction"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compiledCode": "59090801010029800aba2aba1aba0aab9faab9eaab9dab9a488888896600264653001300800198041804800cdc3a400530080024888966002600460106ea800e3300130093754007370e90004dc3a4009370e90034c020dd50022444453001301100598089809002c88c966002600e0031323259800980b80140122c80a0dd7180a80098089baa0038acc004c02800626464b300130170028024590141bae30150013011375400716403c8078c03cdd5001244464b300130080058992cc004c05c006266006602c00200516405060246ea80362b3001300b005899192cc004c06000a0071640546eb8c058004c048dd5006c566002600e00b15980098091baa00d800c590134566002600c00b15980098091baa00d800c59013456600266e1d20080058992cc004c05c006266006602c00200516405060246ea80362b30013370e9005002c56600260246ea803600316404d15980099b87480300162b30013012375401b0018b20268b202040408081010202040408080566002600e60206ea800a330013014301137540052301530163016301630163016301630160019180a980b180b180b180b180b180b180b180b000c8c054c058c058c058c05800660206ea80324602a602c0032301530163016001911919800800801912cc0040062980103d87a80008992cc004c010006266e952000330180014bd7044cc00c00cc068009014180c000a02c9180a980b180b180b180b180b180b000a4444444446530012232598009809980e9baa0018992cc004c054c078dd5000c4c8c8c8c8c8c8c8c8c8ca60026058003375c6058015375a6058011375a605800f302c0069bad302c0059bad302c0049bad302c00398160012444444444b3001303600a899811181a809099912cc004c0a8006264b30013039001899812981c000801c59036181a1baa0078acc004c0b40062b30013034375400f0028b206a8b206440c826644b3001302a0018acc004c0d0dd5001c00a2c81aa2b3001302d0018acc004c0d0dd5001c00a2c81aa2b300130290018991919912cc004c0f000e00d1640e46eb4c0e4004dd7181c801181c800981a1baa0038acc004c0a00062b3001303437540070028b206a8b206440c88191032099811004880a18189baa0013031375400b1640cc302c001302b001302a001302900130280013027001302600130250013024001301f37540031640746042603c6ea80062c80e0c018c074dd519801801000c8966002602660386ea800626464660020026eb0c030c07cdd5002112cc00400629422b30013371e6eb8c08c00400e2946266004004604800280f10211bae3020301d375400314a080da64b30013015301b37540031375a603e60386ea8006290002034301e301b3754603c60366ea8c024c06cdd5009a444b300130140098992cc004cdd79806180f9baa00e4c0103d87980008acc004cdc40011bad3006301f375401d1325980099baf300d3020375400298103d87a80008acc004cdd7980618101baa0013374a90001981100125eb822b30013371e6eb8c08cc080dd50009bae30233020375401f15980099baf300a30203754002601460406ea803e2b30013370e6eb4c024c080dd50009bad30093020375401f15980099b893370066e00dd6980498101baa00f375a604660486048604860406ea803cdd698119812181218121812181218101baa00f323322330020020012259800800c00e2646644b3001337229101000028acc004cdc7a441000028800c01902444cc014014c0a80110241bae3023001375a6048002604c0028120c8c8cc004004dd5980698119baa3300901b0152259800800c00e2646644b3001337229101000028acc004cdc7a441000028800c01902544cc014014c0ac0110251bae30240013756604a002604e002812852f5bded8c02900044cc01006000a2c80f22c80f22c80f22c80f22c80f22c80f0cc01005c0462c80ea2c80e8c084c078dd500cc566002602e01315980099baf300b301e375401a98103d87a80008992cc004c054c078dd5000c56600266e20008dd69803180f9baa00e8992cc004cdd7980698101baa0013374a900219811181198101baa01b33022375000697ae08acc004cdc79bae3023302037540026eb8c08cc080dd5007c56600266ebcc028c080dd5000980518101baa00f8acc004cdd7980618101baa001300c3020375401f1330040183023302037540051640791640791640791640786600802e0231640751640746014603c6ea80362c80e22b300130130098acc004c04cc074dd51805980f1baa00d89980100b1804180f1baa00d8b20388acc004c048026264b30013014301e37540031598009980180b9804980f9baa00e8acc004cdc480119b80375a60446046603e6ea8004dd69806980f9baa00e8992cc004cdd7980698101baa0014c0103d87c80008acc004cdc79bae3023302037540026eb8c08cc080dd5007c56600266ebcc028c080dd5000980518101baa00f8acc004cdd7980618101baa001300c3020375401f14a31640791640791640791640786600802e0231640751640751640746016603c6ea80362b30013370e9004004c4c96600266ebcc030c07cdd5007260103d87c80008acc004cc00c05cc088c08cc08cc08cc08cc08cc08cc08cc08cc08cc07cdd500744c966002602c603e6ea80062b30015980099baf002300a3020375401f14a313375e004604660406ea800501e4528c5901e45901e1805980f9baa00e8b203a8b203a3021301e375403315980099b87480280262b3001330020163008301e375401b13259800980a800c4cdc41bad3006301f375401c0051598009809800c4cdc419b80375a600c603e6ea8038dd69806980f9baa00e0028a50407480e8c074dd51805980f1baa00d8b20388992cc004c050c078dd5000c4c966002602c603e6ea80062b30013300401830233020375400313371066e00dd69811981218101baa002375a601c60406ea803c00e2c80f22c80f0c02cc07cdd500745901d1805980f1baa00d407080e101c2038407080e0446464b30013014301d375400313021301e37540031640706600a6eb0c018c074dd5001919baf3021301e375400200464b30013013301c375400313020301d3754600e603a6ea8c080c074dd5000c5901b198021bac301f301c3754004466ebcc080c074dd500080122c80788b200e180400098019baa0088a4d1365640041",
|
||||||
|
"hash": "74d7f72f7ef0e2e79290ccf8d2eeda672b43379f037def441709395d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "escrow.escrow.else",
|
||||||
|
"redeemer": {
|
||||||
|
"schema": {}
|
||||||
|
},
|
||||||
|
"compiledCode": "59090801010029800aba2aba1aba0aab9faab9eaab9dab9a488888896600264653001300800198041804800cdc3a400530080024888966002600460106ea800e3300130093754007370e90004dc3a4009370e90034c020dd50022444453001301100598089809002c88c966002600e0031323259800980b80140122c80a0dd7180a80098089baa0038acc004c02800626464b300130170028024590141bae30150013011375400716403c8078c03cdd5001244464b300130080058992cc004c05c006266006602c00200516405060246ea80362b3001300b005899192cc004c06000a0071640546eb8c058004c048dd5006c566002600e00b15980098091baa00d800c590134566002600c00b15980098091baa00d800c59013456600266e1d20080058992cc004c05c006266006602c00200516405060246ea80362b30013370e9005002c56600260246ea803600316404d15980099b87480300162b30013012375401b0018b20268b202040408081010202040408080566002600e60206ea800a330013014301137540052301530163016301630163016301630160019180a980b180b180b180b180b180b180b180b000c8c054c058c058c058c05800660206ea80324602a602c0032301530163016001911919800800801912cc0040062980103d87a80008992cc004c010006266e952000330180014bd7044cc00c00cc068009014180c000a02c9180a980b180b180b180b180b180b000a4444444446530012232598009809980e9baa0018992cc004c054c078dd5000c4c8c8c8c8c8c8c8c8c8ca60026058003375c6058015375a6058011375a605800f302c0069bad302c0059bad302c0049bad302c00398160012444444444b3001303600a899811181a809099912cc004c0a8006264b30013039001899812981c000801c59036181a1baa0078acc004c0b40062b30013034375400f0028b206a8b206440c826644b3001302a0018acc004c0d0dd5001c00a2c81aa2b3001302d0018acc004c0d0dd5001c00a2c81aa2b300130290018991919912cc004c0f000e00d1640e46eb4c0e4004dd7181c801181c800981a1baa0038acc004c0a00062b3001303437540070028b206a8b206440c88191032099811004880a18189baa0013031375400b1640cc302c001302b001302a001302900130280013027001302600130250013024001301f37540031640746042603c6ea80062c80e0c018c074dd519801801000c8966002602660386ea800626464660020026eb0c030c07cdd5002112cc00400629422b30013371e6eb8c08c00400e2946266004004604800280f10211bae3020301d375400314a080da64b30013015301b37540031375a603e60386ea8006290002034301e301b3754603c60366ea8c024c06cdd5009a444b300130140098992cc004cdd79806180f9baa00e4c0103d87980008acc004cdc40011bad3006301f375401d1325980099baf300d3020375400298103d87a80008acc004cdd7980618101baa0013374a90001981100125eb822b30013371e6eb8c08cc080dd50009bae30233020375401f15980099baf300a30203754002601460406ea803e2b30013370e6eb4c024c080dd50009bad30093020375401f15980099b893370066e00dd6980498101baa00f375a604660486048604860406ea803cdd698119812181218121812181218101baa00f323322330020020012259800800c00e2646644b3001337229101000028acc004cdc7a441000028800c01902444cc014014c0a80110241bae3023001375a6048002604c0028120c8c8cc004004dd5980698119baa3300901b0152259800800c00e2646644b3001337229101000028acc004cdc7a441000028800c01902544cc014014c0ac0110251bae30240013756604a002604e002812852f5bded8c02900044cc01006000a2c80f22c80f22c80f22c80f22c80f22c80f0cc01005c0462c80ea2c80e8c084c078dd500cc566002602e01315980099baf300b301e375401a98103d87a80008992cc004c054c078dd5000c56600266e20008dd69803180f9baa00e8992cc004cdd7980698101baa0013374a900219811181198101baa01b33022375000697ae08acc004cdc79bae3023302037540026eb8c08cc080dd5007c56600266ebcc028c080dd5000980518101baa00f8acc004cdd7980618101baa001300c3020375401f1330040183023302037540051640791640791640791640786600802e0231640751640746014603c6ea80362c80e22b300130130098acc004c04cc074dd51805980f1baa00d89980100b1804180f1baa00d8b20388acc004c048026264b30013014301e37540031598009980180b9804980f9baa00e8acc004cdc480119b80375a60446046603e6ea8004dd69806980f9baa00e8992cc004cdd7980698101baa0014c0103d87c80008acc004cdc79bae3023302037540026eb8c08cc080dd5007c56600266ebcc028c080dd5000980518101baa00f8acc004cdd7980618101baa001300c3020375401f14a31640791640791640791640786600802e0231640751640751640746016603c6ea80362b30013370e9004004c4c96600266ebcc030c07cdd5007260103d87c80008acc004cc00c05cc088c08cc08cc08cc08cc08cc08cc08cc08cc08cc07cdd500744c966002602c603e6ea80062b30015980099baf002300a3020375401f14a313375e004604660406ea800501e4528c5901e45901e1805980f9baa00e8b203a8b203a3021301e375403315980099b87480280262b3001330020163008301e375401b13259800980a800c4cdc41bad3006301f375401c0051598009809800c4cdc419b80375a600c603e6ea8038dd69806980f9baa00e0028a50407480e8c074dd51805980f1baa00d8b20388992cc004c050c078dd5000c4c966002602c603e6ea80062b30013300401830233020375400313371066e00dd69811981218101baa002375a601c60406ea803c00e2c80f22c80f0c02cc07cdd500745901d1805980f1baa00d407080e101c2038407080e0446464b30013014301d375400313021301e37540031640706600a6eb0c018c074dd5001919baf3021301e375400200464b30013013301c375400313020301d3754600e603a6ea8c080c074dd5000c5901b198021bac301f301c3754004466ebcc080c074dd500080122c80788b200e180400098019baa0088a4d1365640041",
|
||||||
|
"hash": "74d7f72f7ef0e2e79290ccf8d2eeda672b43379f037def441709395d"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"definitions": {
|
||||||
|
"ByteArray": {
|
||||||
|
"dataType": "bytes"
|
||||||
|
},
|
||||||
|
"Int": {
|
||||||
|
"dataType": "integer"
|
||||||
|
},
|
||||||
|
"Option<cardano/address/Credential>": {
|
||||||
|
"title": "Option",
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"title": "Some",
|
||||||
|
"description": "An optional value.",
|
||||||
|
"dataType": "constructor",
|
||||||
|
"index": 0,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/cardano~1address~1Credential"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "None",
|
||||||
|
"description": "Nothing.",
|
||||||
|
"dataType": "constructor",
|
||||||
|
"index": 1,
|
||||||
|
"fields": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"aiken/crypto/ScriptHash": {
|
||||||
|
"title": "ScriptHash",
|
||||||
|
"dataType": "bytes"
|
||||||
|
},
|
||||||
|
"aiken/crypto/VerificationKeyHash": {
|
||||||
|
"title": "VerificationKeyHash",
|
||||||
|
"dataType": "bytes"
|
||||||
|
},
|
||||||
|
"cardano/address/Credential": {
|
||||||
|
"title": "Credential",
|
||||||
|
"description": "A general structure for representing an on-chain `Credential`.\n\n Credentials are always one of two kinds: a direct public/private key\n pair, or a script (native or Plutus).",
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"title": "VerificationKey",
|
||||||
|
"dataType": "constructor",
|
||||||
|
"index": 0,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Script",
|
||||||
|
"dataType": "constructor",
|
||||||
|
"index": 1,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/aiken~1crypto~1ScriptHash"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"escrow/EscrowAction": {
|
||||||
|
"title": "EscrowAction",
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"title": "Claim",
|
||||||
|
"dataType": "constructor",
|
||||||
|
"index": 0,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"title": "supplier_cred",
|
||||||
|
"$ref": "#/definitions/cardano~1address~1Credential"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Submit",
|
||||||
|
"dataType": "constructor",
|
||||||
|
"index": 1,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"title": "result_hash",
|
||||||
|
"$ref": "#/definitions/ByteArray"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Accept",
|
||||||
|
"dataType": "constructor",
|
||||||
|
"index": 2,
|
||||||
|
"fields": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Dispute",
|
||||||
|
"dataType": "constructor",
|
||||||
|
"index": 3,
|
||||||
|
"fields": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Resolve",
|
||||||
|
"dataType": "constructor",
|
||||||
|
"index": 4,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"title": "winner",
|
||||||
|
"$ref": "#/definitions/cardano~1address~1Credential"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Reclaim",
|
||||||
|
"dataType": "constructor",
|
||||||
|
"index": 5,
|
||||||
|
"fields": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Collect",
|
||||||
|
"dataType": "constructor",
|
||||||
|
"index": 6,
|
||||||
|
"fields": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"escrow/EscrowDatum": {
|
||||||
|
"title": "EscrowDatum",
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"title": "EscrowDatum",
|
||||||
|
"dataType": "constructor",
|
||||||
|
"index": 0,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"title": "task_hash",
|
||||||
|
"$ref": "#/definitions/ByteArray"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "buyer",
|
||||||
|
"$ref": "#/definitions/cardano~1address~1Credential"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "payment_dfm",
|
||||||
|
"$ref": "#/definitions/Int"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "buyer_bond_dfm",
|
||||||
|
"$ref": "#/definitions/Int"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "supplier",
|
||||||
|
"$ref": "#/definitions/Option<cardano~1address~1Credential>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "supplier_bond_dfm",
|
||||||
|
"$ref": "#/definitions/Int"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "deadline_slot",
|
||||||
|
"$ref": "#/definitions/Int"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "dispute_window",
|
||||||
|
"$ref": "#/definitions/Int"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "state",
|
||||||
|
"$ref": "#/definitions/escrow~1EscrowState"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "resolver",
|
||||||
|
"$ref": "#/definitions/cardano~1address~1Credential"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"escrow/EscrowState": {
|
||||||
|
"title": "EscrowState",
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"title": "Open",
|
||||||
|
"dataType": "constructor",
|
||||||
|
"index": 0,
|
||||||
|
"fields": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Claimed",
|
||||||
|
"dataType": "constructor",
|
||||||
|
"index": 1,
|
||||||
|
"fields": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Submitted",
|
||||||
|
"dataType": "constructor",
|
||||||
|
"index": 2,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"title": "result_hash",
|
||||||
|
"$ref": "#/definitions/ByteArray"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "submitted_slot",
|
||||||
|
"$ref": "#/definitions/Int"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Disputed",
|
||||||
|
"dataType": "constructor",
|
||||||
|
"index": 3,
|
||||||
|
"fields": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
docs/buyer/discovery.py
Normal file
29
docs/buyer/discovery.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"""Simple supplier discovery -- checks health of known supplier URLs."""
|
||||||
|
import asyncio
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from lib.protocol import SupplierInfo
|
||||||
|
from supplier_client import check_supplier_health
|
||||||
|
|
||||||
|
|
||||||
|
async def discover_suppliers(config) -> List[SupplierInfo]:
|
||||||
|
"""Check health of each URL in config.supplier_urls and return reachable suppliers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: BuyerConfig with supplier_urls list.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of SupplierInfo for reachable suppliers.
|
||||||
|
"""
|
||||||
|
tasks = [check_supplier_health(url) for url in config.supplier_urls]
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
suppliers = []
|
||||||
|
for i, result in enumerate(results):
|
||||||
|
if isinstance(result, SupplierInfo):
|
||||||
|
# Ensure the endpoint field reflects the URL we queried
|
||||||
|
if not result.endpoint:
|
||||||
|
result.endpoint = config.supplier_urls[i]
|
||||||
|
suppliers.append(result)
|
||||||
|
|
||||||
|
return suppliers
|
||||||
140
docs/buyer/escrow_ops.py
Normal file
140
docs/buyer/escrow_ops.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
"""Buyer-side escrow operations -- post, accept, and reclaim."""
|
||||||
|
from cbor2 import CBORTag
|
||||||
|
|
||||||
|
from pycardano import (
|
||||||
|
Address,
|
||||||
|
RawPlutusData,
|
||||||
|
Transaction,
|
||||||
|
TransactionBuilder,
|
||||||
|
TransactionOutput,
|
||||||
|
VerificationKeyWitness,
|
||||||
|
)
|
||||||
|
from pycardano.hash import ScriptHash
|
||||||
|
from pycardano.transaction import TransactionWitnessSet
|
||||||
|
|
||||||
|
from lib.datum import build_open_datum
|
||||||
|
from lib.escrow_tx import build_and_submit_script_tx
|
||||||
|
from lib.constants import BUYER_BOND, SUPPLIER_BOND, DISPUTE_WINDOW_MS
|
||||||
|
|
||||||
|
|
||||||
|
async def post_escrow(
|
||||||
|
context,
|
||||||
|
signer,
|
||||||
|
script_hash,
|
||||||
|
task_hash,
|
||||||
|
payment_dfm,
|
||||||
|
deadline_posix_ms,
|
||||||
|
resolver_cred,
|
||||||
|
) -> str:
|
||||||
|
"""Post escrow on-chain in the Open state.
|
||||||
|
|
||||||
|
Locks payment + buyer_bond + 2M lovelace at the script address.
|
||||||
|
Uses manual signing (NOT build_and_sign) to avoid extended key issues.
|
||||||
|
|
||||||
|
Returns the transaction hash as a string.
|
||||||
|
"""
|
||||||
|
script_hash_obj = ScriptHash.from_primitive(script_hash)
|
||||||
|
script_address = Address(script_hash_obj, network=context.network)
|
||||||
|
|
||||||
|
buyer_vkh = bytes(signer._wallet.payment_verification_key.hash())
|
||||||
|
|
||||||
|
datum = build_open_datum(
|
||||||
|
task_hash_hex=task_hash,
|
||||||
|
buyer_vkh=buyer_vkh,
|
||||||
|
payment_dfm=payment_dfm,
|
||||||
|
buyer_bond=BUYER_BOND,
|
||||||
|
supplier_bond=SUPPLIER_BOND,
|
||||||
|
deadline_posix_ms=deadline_posix_ms,
|
||||||
|
dispute_window_ms=DISPUTE_WINDOW_MS,
|
||||||
|
resolver_cred=bytes.fromhex(resolver_cred),
|
||||||
|
)
|
||||||
|
|
||||||
|
total_lovelace = payment_dfm + BUYER_BOND + 2_000_000
|
||||||
|
datum_obj = RawPlutusData(datum)
|
||||||
|
|
||||||
|
await context.async_protocol_param()
|
||||||
|
|
||||||
|
builder = TransactionBuilder(context)
|
||||||
|
builder.add_input_address(signer._wallet.payment_address)
|
||||||
|
builder.add_output(
|
||||||
|
TransactionOutput(script_address, total_lovelace, datum=datum_obj)
|
||||||
|
)
|
||||||
|
|
||||||
|
tx_body = builder.build(change_address=signer._wallet.payment_address)
|
||||||
|
sig = signer._wallet.payment_signing_key.sign(tx_body.hash())
|
||||||
|
vk_witness = VerificationKeyWitness(signer._wallet.payment_verification_key, sig)
|
||||||
|
witness_set = TransactionWitnessSet(vkey_witnesses=[vk_witness])
|
||||||
|
tx = Transaction(tx_body, witness_set)
|
||||||
|
|
||||||
|
tx_cbor = tx.to_cbor()
|
||||||
|
if isinstance(tx_cbor, bytes):
|
||||||
|
tx_cbor = tx_cbor.hex()
|
||||||
|
await context.async_submit_tx_cbor(tx_cbor)
|
||||||
|
return str(tx.id)
|
||||||
|
|
||||||
|
|
||||||
|
async def accept_result(
|
||||||
|
context,
|
||||||
|
signer,
|
||||||
|
utxo,
|
||||||
|
script,
|
||||||
|
script_address,
|
||||||
|
supplier_address,
|
||||||
|
payment_dfm,
|
||||||
|
) -> str:
|
||||||
|
"""Accept a submitted result -- Accept redeemer (CBORTag 123, []).
|
||||||
|
|
||||||
|
Pays the supplier: payment + supplier_bond + buyer_bond.
|
||||||
|
The buyer gets back the remaining lovelace (min UTxO overhead) as change.
|
||||||
|
|
||||||
|
Returns the transaction hash as a string.
|
||||||
|
"""
|
||||||
|
from pycardano import Address as PycAddress
|
||||||
|
|
||||||
|
supplier_addr = PycAddress.from_primitive(supplier_address)
|
||||||
|
supplier_payout = payment_dfm + SUPPLIER_BOND + BUYER_BOND
|
||||||
|
|
||||||
|
redeemer_obj = CBORTag(123, [])
|
||||||
|
tx_hash = await build_and_submit_script_tx(
|
||||||
|
context=context,
|
||||||
|
signer=signer,
|
||||||
|
utxo=utxo,
|
||||||
|
script=script,
|
||||||
|
script_address=script_address,
|
||||||
|
redeemer_obj=redeemer_obj,
|
||||||
|
output_value=0,
|
||||||
|
output_datum_obj=None,
|
||||||
|
task_id="accept",
|
||||||
|
step_name="Accept",
|
||||||
|
extra_outputs=[
|
||||||
|
TransactionOutput(supplier_addr, supplier_payout),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return tx_hash
|
||||||
|
|
||||||
|
|
||||||
|
async def reclaim_escrow(
|
||||||
|
context,
|
||||||
|
signer,
|
||||||
|
utxo,
|
||||||
|
script,
|
||||||
|
script_address,
|
||||||
|
) -> str:
|
||||||
|
"""Reclaim escrow funds -- Reclaim redeemer (CBORTag 126, []).
|
||||||
|
|
||||||
|
Returns the transaction hash as a string.
|
||||||
|
"""
|
||||||
|
redeemer_obj = CBORTag(126, [])
|
||||||
|
tx_hash = await build_and_submit_script_tx(
|
||||||
|
context=context,
|
||||||
|
signer=signer,
|
||||||
|
utxo=utxo,
|
||||||
|
script=script,
|
||||||
|
script_address=script_address,
|
||||||
|
redeemer_obj=redeemer_obj,
|
||||||
|
output_value=0,
|
||||||
|
output_datum_obj=None,
|
||||||
|
task_id="reclaim",
|
||||||
|
step_name="Reclaim",
|
||||||
|
)
|
||||||
|
return tx_hash
|
||||||
1
docs/buyer/lib/__init__.py
Normal file
1
docs/buyer/lib/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
18
docs/buyer/lib/blueprint.py
Normal file
18
docs/buyer/lib/blueprint.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pycardano import PlutusV3Script, plutus_script_hash
|
||||||
|
|
||||||
|
|
||||||
|
def load_escrow_script():
|
||||||
|
"""Load the escrow PlutusV3 script from the bundled blueprint."""
|
||||||
|
# Look in supplier/contracts/escrow/plutus.json
|
||||||
|
blueprint_path = Path(__file__).resolve().parent.parent / "contracts" / "escrow" / "plutus.json"
|
||||||
|
with open(blueprint_path, "r") as f:
|
||||||
|
blueprint = json.load(f)
|
||||||
|
|
||||||
|
compiled_code = blueprint["validators"][0]["compiledCode"]
|
||||||
|
script = PlutusV3Script(bytes.fromhex(compiled_code))
|
||||||
|
script_hash = str(plutus_script_hash(script))
|
||||||
|
|
||||||
|
return script, script_hash
|
||||||
33
docs/buyer/lib/chain.py
Normal file
33
docs/buyer/lib/chain.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import asyncio
|
||||||
|
from .constants import MAX_UTXO_WAIT
|
||||||
|
|
||||||
|
|
||||||
|
def slot_to_posix_ms(context, slot):
|
||||||
|
gp = context.genesis_param
|
||||||
|
return (gp.system_start + slot * gp.slot_length) * 1000
|
||||||
|
|
||||||
|
|
||||||
|
async def wait_for_utxo(context, script_address, tx_hash, output_index=0):
|
||||||
|
elapsed = 0
|
||||||
|
while elapsed < MAX_UTXO_WAIT:
|
||||||
|
utxos = await context.async_utxos(str(script_address))
|
||||||
|
for utxo in utxos:
|
||||||
|
if (
|
||||||
|
str(utxo.input.transaction_id) == str(tx_hash)
|
||||||
|
and utxo.input.index == output_index
|
||||||
|
):
|
||||||
|
return utxo
|
||||||
|
await asyncio.sleep(4)
|
||||||
|
elapsed += 4
|
||||||
|
raise TimeoutError(
|
||||||
|
f"UTxO {tx_hash}#{output_index} not found at {script_address} "
|
||||||
|
f"after {MAX_UTXO_WAIT}s"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def find_script_utxo(context, script_address, tx_hash):
|
||||||
|
utxos = await context.async_utxos(str(script_address))
|
||||||
|
for utxo in utxos:
|
||||||
|
if str(utxo.input.transaction_id) == str(tx_hash):
|
||||||
|
return utxo
|
||||||
|
return None
|
||||||
6
docs/buyer/lib/constants.py
Normal file
6
docs/buyer/lib/constants.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
BUYER_BOND = 1_000_000
|
||||||
|
SUPPLIER_BOND = 1_000_000
|
||||||
|
DEFAULT_DEADLINE_OFFSET_MS = 1_800_000 # 30 minutes
|
||||||
|
DISPUTE_WINDOW_MS = 600_000 # 10 minutes
|
||||||
|
TX_PROPAGATION_DELAY = 12 # seconds
|
||||||
|
MAX_UTXO_WAIT = 60 # seconds
|
||||||
63
docs/buyer/lib/datum.py
Normal file
63
docs/buyer/lib/datum.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
from cbor2 import CBORTag
|
||||||
|
|
||||||
|
|
||||||
|
def _build_datum_fields(
|
||||||
|
task_hash_hex, buyer_vkh, supplier_vkh, payment_dfm,
|
||||||
|
buyer_bond, supplier_bond, deadline_posix_ms, dispute_window_ms,
|
||||||
|
state_field, resolver_cred,
|
||||||
|
):
|
||||||
|
if supplier_vkh is None:
|
||||||
|
supplier_field = CBORTag(122, [])
|
||||||
|
else:
|
||||||
|
supplier_field = CBORTag(121, [CBORTag(121, [supplier_vkh])])
|
||||||
|
|
||||||
|
return CBORTag(121, [
|
||||||
|
bytes.fromhex(task_hash_hex),
|
||||||
|
CBORTag(121, [buyer_vkh]),
|
||||||
|
payment_dfm,
|
||||||
|
buyer_bond,
|
||||||
|
supplier_field,
|
||||||
|
supplier_bond,
|
||||||
|
deadline_posix_ms,
|
||||||
|
dispute_window_ms,
|
||||||
|
state_field,
|
||||||
|
CBORTag(121, [resolver_cred]),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def build_open_datum(task_hash_hex, buyer_vkh, payment_dfm, buyer_bond,
|
||||||
|
supplier_bond, deadline_posix_ms, dispute_window_ms, resolver_cred):
|
||||||
|
state_field = CBORTag(121, [])
|
||||||
|
return _build_datum_fields(
|
||||||
|
task_hash_hex=task_hash_hex, buyer_vkh=buyer_vkh, supplier_vkh=None,
|
||||||
|
payment_dfm=payment_dfm, buyer_bond=buyer_bond, supplier_bond=supplier_bond,
|
||||||
|
deadline_posix_ms=deadline_posix_ms, dispute_window_ms=dispute_window_ms,
|
||||||
|
state_field=state_field, resolver_cred=resolver_cred,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_claimed_datum(task_hash_hex, buyer_vkh, supplier_vkh, payment_dfm,
|
||||||
|
buyer_bond, supplier_bond, deadline_posix_ms, dispute_window_ms,
|
||||||
|
resolver_cred):
|
||||||
|
state_field = CBORTag(122, [])
|
||||||
|
return _build_datum_fields(
|
||||||
|
task_hash_hex=task_hash_hex, buyer_vkh=buyer_vkh, supplier_vkh=supplier_vkh,
|
||||||
|
payment_dfm=payment_dfm, buyer_bond=buyer_bond, supplier_bond=supplier_bond,
|
||||||
|
deadline_posix_ms=deadline_posix_ms, dispute_window_ms=dispute_window_ms,
|
||||||
|
state_field=state_field, resolver_cred=resolver_cred,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_submitted_datum(task_hash_hex, buyer_vkh, supplier_vkh, payment_dfm,
|
||||||
|
buyer_bond, supplier_bond, deadline_posix_ms, dispute_window_ms,
|
||||||
|
result_hash_hex, submitted_posix_ms, resolver_cred):
|
||||||
|
state_field = CBORTag(123, [
|
||||||
|
bytes.fromhex(result_hash_hex),
|
||||||
|
submitted_posix_ms,
|
||||||
|
])
|
||||||
|
return _build_datum_fields(
|
||||||
|
task_hash_hex=task_hash_hex, buyer_vkh=buyer_vkh, supplier_vkh=supplier_vkh,
|
||||||
|
payment_dfm=payment_dfm, buyer_bond=buyer_bond, supplier_bond=supplier_bond,
|
||||||
|
deadline_posix_ms=deadline_posix_ms, dispute_window_ms=dispute_window_ms,
|
||||||
|
state_field=state_field, resolver_cred=resolver_cred,
|
||||||
|
)
|
||||||
197
docs/buyer/lib/escrow_tx.py
Normal file
197
docs/buyer/lib/escrow_tx.py
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
"""
|
||||||
|
Build, evaluate, and submit script transactions for escrow state transitions.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from pycardano import (
|
||||||
|
Redeemer,
|
||||||
|
Transaction,
|
||||||
|
TransactionBuilder,
|
||||||
|
TransactionOutput,
|
||||||
|
VerificationKeyWitness,
|
||||||
|
)
|
||||||
|
from pycardano.plutus import ExecutionUnits, RawPlutusData
|
||||||
|
|
||||||
|
|
||||||
|
async def build_and_submit_script_tx(
|
||||||
|
context, signer, utxo, script, script_address,
|
||||||
|
redeemer_obj, output_value, output_datum_obj,
|
||||||
|
task_id, step_name, validity_start_slot=None,
|
||||||
|
extra_outputs=None,
|
||||||
|
) -> str:
|
||||||
|
await context.async_protocol_param()
|
||||||
|
change_address = signer._wallet.payment_address
|
||||||
|
|
||||||
|
try:
|
||||||
|
tx_hash = await _build_direct(
|
||||||
|
context, signer, utxo, script, script_address,
|
||||||
|
redeemer_obj, output_value, output_datum_obj,
|
||||||
|
change_address, validity_start_slot, extra_outputs,
|
||||||
|
)
|
||||||
|
print(f"[{step_name}] TX submitted (direct): {tx_hash}")
|
||||||
|
return tx_hash
|
||||||
|
except ValueError as e:
|
||||||
|
if "collateral" not in str(e).lower():
|
||||||
|
raise
|
||||||
|
print(f"[{step_name}] Collateral check failed, falling back to Ogmios evaluate...")
|
||||||
|
|
||||||
|
tx_hash = await _build_with_ogmios_evaluate(
|
||||||
|
context, signer, utxo, script, script_address,
|
||||||
|
redeemer_obj, output_value, output_datum_obj,
|
||||||
|
change_address, validity_start_slot, step_name, extra_outputs,
|
||||||
|
)
|
||||||
|
print(f"[{step_name}] TX submitted (ogmios): {tx_hash}")
|
||||||
|
return tx_hash
|
||||||
|
|
||||||
|
|
||||||
|
def _make_builder(
|
||||||
|
context, signer, utxo, script, script_address,
|
||||||
|
redeemer_obj, output_value, output_datum_obj,
|
||||||
|
validity_start_slot, ex_units=None, skip_estimate=False,
|
||||||
|
extra_outputs=None,
|
||||||
|
):
|
||||||
|
builder = TransactionBuilder(context)
|
||||||
|
builder.add_input_address(signer._wallet.payment_address)
|
||||||
|
builder.fee_buffer = 10_000
|
||||||
|
|
||||||
|
if skip_estimate:
|
||||||
|
builder._should_estimate_execution_units = False
|
||||||
|
|
||||||
|
redeemer = Redeemer(RawPlutusData(redeemer_obj))
|
||||||
|
if ex_units:
|
||||||
|
redeemer.ex_units = ex_units
|
||||||
|
builder.add_script_input(utxo, script=script, datum=None, redeemer=redeemer)
|
||||||
|
|
||||||
|
if output_datum_obj is not None and output_value > 0:
|
||||||
|
builder.add_output(
|
||||||
|
TransactionOutput(script_address, output_value, datum=RawPlutusData(output_datum_obj))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add extra outputs (e.g. supplier payment in Accept TX)
|
||||||
|
if extra_outputs:
|
||||||
|
for out in extra_outputs:
|
||||||
|
builder.add_output(out)
|
||||||
|
|
||||||
|
builder.required_signers = [signer._wallet.payment_verification_key.hash()]
|
||||||
|
|
||||||
|
if validity_start_slot is not None:
|
||||||
|
builder.validity_start = validity_start_slot
|
||||||
|
|
||||||
|
return builder
|
||||||
|
|
||||||
|
|
||||||
|
def _sign_tx(signer, tx_body, witness_set):
|
||||||
|
sig = signer._wallet.payment_signing_key.sign(tx_body.hash())
|
||||||
|
vk_witness = VerificationKeyWitness(
|
||||||
|
signer._wallet.payment_verification_key, sig
|
||||||
|
)
|
||||||
|
witness_set.vkey_witnesses = [vk_witness]
|
||||||
|
return Transaction(tx_body, witness_set)
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_direct(
|
||||||
|
context, signer, utxo, script, script_address,
|
||||||
|
redeemer_obj, output_value, output_datum_obj,
|
||||||
|
change_address, validity_start_slot, extra_outputs=None,
|
||||||
|
):
|
||||||
|
builder = _make_builder(
|
||||||
|
context, signer, utxo, script, script_address,
|
||||||
|
redeemer_obj, output_value, output_datum_obj,
|
||||||
|
validity_start_slot, extra_outputs=extra_outputs,
|
||||||
|
)
|
||||||
|
tx_body = builder.build(change_address=change_address)
|
||||||
|
witness_set = builder.build_witness_set(True)
|
||||||
|
tx = _sign_tx(signer, tx_body, witness_set)
|
||||||
|
tx_cbor = tx.to_cbor()
|
||||||
|
if isinstance(tx_cbor, bytes):
|
||||||
|
tx_cbor = tx_cbor.hex()
|
||||||
|
await context.async_submit_tx_cbor(tx_cbor)
|
||||||
|
return str(tx.id)
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_with_ogmios_evaluate(
|
||||||
|
context, signer, utxo, script, script_address,
|
||||||
|
redeemer_obj, output_value, output_datum_obj,
|
||||||
|
change_address, validity_start_slot, step_name, extra_outputs=None,
|
||||||
|
):
|
||||||
|
ogmios_url = os.getenv("VECTOR_OGMIOS_URL")
|
||||||
|
if not ogmios_url:
|
||||||
|
raise RuntimeError("VECTOR_OGMIOS_URL not set")
|
||||||
|
|
||||||
|
dummy_ex_units = ExecutionUnits(500_000, 200_000_000)
|
||||||
|
builder = _make_builder(
|
||||||
|
context, signer, utxo, script, script_address,
|
||||||
|
redeemer_obj, output_value, output_datum_obj,
|
||||||
|
validity_start_slot, ex_units=dummy_ex_units, skip_estimate=True,
|
||||||
|
extra_outputs=extra_outputs,
|
||||||
|
)
|
||||||
|
|
||||||
|
orig_set = builder._set_collateral_return
|
||||||
|
def _patched(*a, **k):
|
||||||
|
try:
|
||||||
|
return orig_set(*a, **k)
|
||||||
|
except ValueError:
|
||||||
|
return
|
||||||
|
builder._set_collateral_return = _patched
|
||||||
|
|
||||||
|
tx_body = builder.build(change_address=change_address)
|
||||||
|
witness_set = builder.build_witness_set(True)
|
||||||
|
dummy_tx = _sign_tx(signer, tx_body, witness_set)
|
||||||
|
|
||||||
|
tx_cbor = dummy_tx.to_cbor()
|
||||||
|
if isinstance(tx_cbor, bytes):
|
||||||
|
tx_cbor = tx_cbor.hex()
|
||||||
|
|
||||||
|
evaluate_payload = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "evaluateTransaction",
|
||||||
|
"params": {"transaction": {"cbor": tx_cbor}},
|
||||||
|
"id": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(ogmios_url, json=evaluate_payload, timeout=30)
|
||||||
|
result = resp.json()
|
||||||
|
|
||||||
|
if "error" in result:
|
||||||
|
err_data = result["error"]
|
||||||
|
traces = ""
|
||||||
|
if isinstance(err_data.get("data"), list):
|
||||||
|
for item in err_data["data"]:
|
||||||
|
if "error" in item and "data" in item["error"]:
|
||||||
|
traces = str(item["error"]["data"].get("traces", ""))
|
||||||
|
raise RuntimeError(
|
||||||
|
f"[{step_name}] Validator failed: {err_data.get('message', '')} traces={traces}"
|
||||||
|
)
|
||||||
|
|
||||||
|
budget = result["result"][0]["budget"]
|
||||||
|
real_mem = int(budget["memory"] * 1.2)
|
||||||
|
real_cpu = int(budget["cpu"] * 1.2)
|
||||||
|
print(f"[{step_name}] Ogmios ex_units: mem={real_mem}, cpu={real_cpu}")
|
||||||
|
|
||||||
|
builder2 = _make_builder(
|
||||||
|
context, signer, utxo, script, script_address,
|
||||||
|
redeemer_obj, output_value, output_datum_obj,
|
||||||
|
validity_start_slot, ex_units=ExecutionUnits(real_mem, real_cpu),
|
||||||
|
skip_estimate=True, extra_outputs=extra_outputs,
|
||||||
|
)
|
||||||
|
|
||||||
|
orig_set2 = builder2._set_collateral_return
|
||||||
|
def _patched2(*a, **k):
|
||||||
|
try:
|
||||||
|
return orig_set2(*a, **k)
|
||||||
|
except ValueError:
|
||||||
|
return
|
||||||
|
builder2._set_collateral_return = _patched2
|
||||||
|
|
||||||
|
tx_body2 = builder2.build(change_address=change_address)
|
||||||
|
witness_set2 = builder2.build_witness_set(True)
|
||||||
|
real_tx = _sign_tx(signer, tx_body2, witness_set2)
|
||||||
|
|
||||||
|
real_cbor = real_tx.to_cbor()
|
||||||
|
if isinstance(real_cbor, bytes):
|
||||||
|
real_cbor = real_cbor.hex()
|
||||||
|
await context.async_submit_tx_cbor(real_cbor)
|
||||||
|
return str(real_tx.id)
|
||||||
38
docs/buyer/lib/protocol.py
Normal file
38
docs/buyer/lib/protocol.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class TaskRequest(BaseModel):
|
||||||
|
task_id: str
|
||||||
|
prompt: str
|
||||||
|
escrow_tx: str
|
||||||
|
escrow_script_hash: str
|
||||||
|
payment_dfm: int
|
||||||
|
task_hash: str
|
||||||
|
buyer_address: str
|
||||||
|
model_preference: str = "nemotron"
|
||||||
|
max_tokens: int = 2048
|
||||||
|
temperature: float = 0.7
|
||||||
|
deadline_posix_ms: int = 9_999_999_999_999
|
||||||
|
dispute_window_ms: int = 600_000
|
||||||
|
|
||||||
|
|
||||||
|
class TaskResponse(BaseModel):
|
||||||
|
task_id: str
|
||||||
|
status: str
|
||||||
|
response: str
|
||||||
|
result_hash: str
|
||||||
|
model_used: str
|
||||||
|
input_tokens: int
|
||||||
|
output_tokens: int
|
||||||
|
claim_tx: str
|
||||||
|
submit_tx: str
|
||||||
|
error: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class SupplierInfo(BaseModel):
|
||||||
|
name: str
|
||||||
|
address: str
|
||||||
|
endpoint: str
|
||||||
|
models: List[str]
|
||||||
|
min_payment_dfm: int = 1_000_000
|
||||||
37
docs/buyer/main.py
Normal file
37
docs/buyer/main.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""
|
||||||
|
Buyer app entry point.
|
||||||
|
Serves the API and the web UI.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Make the buyer folder the root for imports
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
load_dotenv(Path(__file__).parent / ".env")
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from api import router
|
||||||
|
from config import get_config
|
||||||
|
|
||||||
|
app = FastAPI(title="Vector Buyer App")
|
||||||
|
app.include_router(router)
|
||||||
|
app.mount("/static", StaticFiles(directory=str(Path(__file__).parent / "static")), name="static")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def index():
|
||||||
|
return FileResponse(str(Path(__file__).parent / "static" / "app.html"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
config = get_config()
|
||||||
|
print(f"Starting Vector Buyer App on port {config.port}")
|
||||||
|
print(f"UI: http://localhost:{config.port}")
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=config.port)
|
||||||
9
docs/buyer/requirements.txt
Normal file
9
docs/buyer/requirements.txt
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
fastapi>=0.104.0
|
||||||
|
uvicorn>=0.24.0
|
||||||
|
httpx>=0.27.0
|
||||||
|
python-dotenv>=1.0.0
|
||||||
|
pydantic>=2.0.0
|
||||||
|
aiofiles>=23.0.0
|
||||||
|
pycardano>=0.11.0
|
||||||
|
cbor2>=5.4.0
|
||||||
|
vector-agent>=0.1.0
|
||||||
502
docs/buyer/static/app.html
Normal file
502
docs/buyer/static/app.html
Normal file
@@ -0,0 +1,502 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Vector AI Compute Network -- Buyer</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
background: linear-gradient(135deg, #0a0b0f 0%, #12141c 100%);
|
||||||
|
color: #e0e0e0;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 16px 32px; border-bottom: 1px solid #1e2030;
|
||||||
|
background: rgba(10,11,15,0.95); backdrop-filter: blur(12px);
|
||||||
|
position: sticky; top: 0; z-index: 100;
|
||||||
|
}
|
||||||
|
.brand { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.logo {
|
||||||
|
width: 40px; height: 40px; border-radius: 10px;
|
||||||
|
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-weight: 800; font-size: 20px; color: #fff; line-height: 40px; text-align: center;
|
||||||
|
}
|
||||||
|
.brand h1 { font-size: 18px; font-weight: 700; color: #f0f0f0; }
|
||||||
|
.brand p { font-size: 12px; color: #6b7280; }
|
||||||
|
.header-right { display: flex; align-items: center; gap: 16px; }
|
||||||
|
.balance-display {
|
||||||
|
padding: 6px 14px; border-radius: 8px; background: #1e2030;
|
||||||
|
font-size: 13px; color: #10b981; font-weight: 600; font-family: monospace;
|
||||||
|
}
|
||||||
|
nav { display: flex; gap: 4px; }
|
||||||
|
.nav-btn {
|
||||||
|
padding: 8px 16px; border-radius: 8px; border: none;
|
||||||
|
background: transparent; color: #9ca3af; cursor: pointer;
|
||||||
|
font-size: 14px; font-weight: 500;
|
||||||
|
}
|
||||||
|
.nav-btn.active { background: #1e2030; color: #e0e0e0; }
|
||||||
|
main { padding: 24px 32px; max-width: 1200px; margin: 0 auto; }
|
||||||
|
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||||
|
.section-title { font-size: 20px; font-weight: 700; color: #f0f0f0; }
|
||||||
|
.badge { padding: 4px 12px; border-radius: 12px; background: #1e2030; color: #9ca3af; font-size: 13px; }
|
||||||
|
|
||||||
|
/* Tab content */
|
||||||
|
.tab { display: none; }
|
||||||
|
.tab.active { display: block; }
|
||||||
|
|
||||||
|
/* Form */
|
||||||
|
.card { background: #14161e; border-radius: 12px; border: 1px solid #1e2030; padding: 24px; }
|
||||||
|
.field-label {
|
||||||
|
display: block; font-size: 12px; font-weight: 600; color: #9ca3af;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
textarea {
|
||||||
|
width: 100%; padding: 12px 14px; border-radius: 8px;
|
||||||
|
border: 1px solid #2a2d3e; background: #1a1c28; color: #e0e0e0;
|
||||||
|
font-size: 14px; line-height: 1.6; font-family: inherit;
|
||||||
|
resize: vertical; outline: none;
|
||||||
|
}
|
||||||
|
textarea:focus { border-color: #6366f1; }
|
||||||
|
.controls { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; margin: 16px 0 20px; }
|
||||||
|
.payment-val { color: #10b981; font-weight: 700; font-family: monospace; }
|
||||||
|
input[type="range"] { width: 100%; accent-color: #6366f1; cursor: pointer; }
|
||||||
|
select {
|
||||||
|
padding: 8px 10px; border-radius: 6px; border: 1px solid #2a2d3e;
|
||||||
|
background: #1a1c28; color: #d1d5db; font-size: 13px; cursor: pointer; outline: none; width: 100%;
|
||||||
|
}
|
||||||
|
.submit-btn {
|
||||||
|
width: 100%; padding: 14px; border-radius: 10px; border: none;
|
||||||
|
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||||
|
color: #fff; font-size: 15px; font-weight: 700; cursor: pointer;
|
||||||
|
}
|
||||||
|
.submit-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.accept-btn {
|
||||||
|
width: 100%; padding: 12px; border-radius: 10px; border: none;
|
||||||
|
background: linear-gradient(135deg, #10b981, #059669);
|
||||||
|
color: #fff; font-size: 14px; font-weight: 700; cursor: pointer;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.accept-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.loading-bar { margin-top: 12px; height: 3px; border-radius: 2px; background: #1e2030; overflow: hidden; }
|
||||||
|
.loading-fill {
|
||||||
|
width: 40%; height: 100%; border-radius: 2px;
|
||||||
|
background: linear-gradient(90deg, #6366f1, #8b5cf6);
|
||||||
|
animation: slide 1.5s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
@keyframes slide { from { margin-left: 0; } to { margin-left: 60%; } }
|
||||||
|
|
||||||
|
/* Result */
|
||||||
|
.result-card { background: #14161e; border-radius: 12px; border: 1px solid #10b98140; padding: 24px; }
|
||||||
|
.result-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||||
|
.success-badge {
|
||||||
|
padding: 4px 12px; border-radius: 6px; background: #10b98120;
|
||||||
|
color: #10b981; font-size: 13px; font-weight: 600; border: 1px solid #10b98140;
|
||||||
|
}
|
||||||
|
.accepted-badge {
|
||||||
|
padding: 4px 12px; border-radius: 6px; background: #6366f120;
|
||||||
|
color: #a5b4fc; font-size: 13px; font-weight: 600; border: 1px solid #6366f140;
|
||||||
|
}
|
||||||
|
.result-cost { font-size: 18px; font-weight: 700; color: #10b981; }
|
||||||
|
.role-label {
|
||||||
|
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
||||||
|
color: #6366f1; letter-spacing: 0.5px; margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.prompt-box { background: #1a1c28; border-radius: 8px; padding: 12px 14px; border-left: 3px solid #f59e0b; margin-bottom: 14px; }
|
||||||
|
.response-box { background: #1a1c28; border-radius: 8px; padding: 12px 14px; border-left: 3px solid #10b981; margin-bottom: 16px; }
|
||||||
|
.content-text { font-size: 14px; color: #d1d5db; line-height: 1.6; white-space: pre-wrap; }
|
||||||
|
.meta-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 10px; border-top: 1px solid #1e2030; padding-top: 14px; margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.meta-label { font-size: 11px; color: #6b7280; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.meta-value { font-size: 13px; color: #d1d5db; font-family: monospace; margin-top: 2px; }
|
||||||
|
.new-btn {
|
||||||
|
width: 100%; padding: 12px; border-radius: 10px; border: 1px solid #6366f1;
|
||||||
|
background: transparent; color: #a5b4fc; font-size: 14px; font-weight: 600; cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error */
|
||||||
|
.error-box {
|
||||||
|
background: #ef444420; border: 1px solid #ef4444; border-radius: 8px;
|
||||||
|
padding: 14px 18px; color: #fca5a5; font-size: 14px; margin-top: 16px;
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
}
|
||||||
|
.dismiss-btn {
|
||||||
|
background: transparent; border: 1px solid #ef4444; color: #ef4444;
|
||||||
|
border-radius: 6px; padding: 4px 12px; cursor: pointer; font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* History */
|
||||||
|
.history-list { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.history-card {
|
||||||
|
background: #14161e; border-radius: 12px; border: 1px solid #1e2030;
|
||||||
|
padding: 16px 20px; cursor: pointer; transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.history-card:hover { border-color: #6366f140; }
|
||||||
|
.history-card.expanded { border-color: #6366f1; background: #161825; }
|
||||||
|
.history-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||||
|
.history-left { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.history-right { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.dot { width: 8px; height: 8px; border-radius: 50%; background: #10b981; }
|
||||||
|
.dot.accepted { background: #6366f1; }
|
||||||
|
.model-badge {
|
||||||
|
padding: 2px 8px; border-radius: 4px; background: #8b5cf620;
|
||||||
|
color: #a78bfa; font-size: 11px; font-weight: 600; font-family: monospace;
|
||||||
|
}
|
||||||
|
.time-badge { font-size: 11px; color: #6b7280; }
|
||||||
|
.token-info { font-size: 12px; color: #6b7280; font-family: monospace; }
|
||||||
|
.payment { font-size: 14px; font-weight: 700; color: #10b981; }
|
||||||
|
.empty { text-align: center; padding: 80px 20px; background: #12141c; border-radius: 12px; border: 1px dashed #1e2030; }
|
||||||
|
.empty p:first-child { color: #9ca3af; font-size: 16px; margin-bottom: 8px; }
|
||||||
|
.empty p:last-child { color: #4b5563; font-size: 13px; }
|
||||||
|
.expand-hint { text-align: center; font-size: 11px; color: #4b5563; margin-top: 6px; }
|
||||||
|
|
||||||
|
/* Supplier list */
|
||||||
|
.supplier-item {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 10px 14px; background: #1a1c28; border-radius: 8px; margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.supplier-name { font-weight: 600; color: #e0e0e0; font-size: 14px; }
|
||||||
|
.supplier-models { font-size: 12px; color: #a78bfa; font-family: monospace; }
|
||||||
|
.supplier-url { font-size: 11px; color: #6b7280; font-family: monospace; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<div class="brand">
|
||||||
|
<div class="logo">V</div>
|
||||||
|
<div>
|
||||||
|
<h1>Vector AI Compute Network</h1>
|
||||||
|
<p>Buyer Interface (v2)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<div class="balance-display" id="wallet-balance">Balance: loading...</div>
|
||||||
|
<nav>
|
||||||
|
<button class="nav-btn active" onclick="switchTab('buy', this)">Buy Compute</button>
|
||||||
|
<button class="nav-btn" onclick="switchTab('history', this)">Task History</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<!-- Buy Compute Tab -->
|
||||||
|
<div id="tab-buy" class="tab active">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">Buy Compute</h2>
|
||||||
|
<span class="badge" id="supplier-count">Loading...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input Form -->
|
||||||
|
<div id="form-area" class="card">
|
||||||
|
<label class="field-label">Your Prompt</label>
|
||||||
|
<textarea id="prompt" rows="4" placeholder="Enter your prompt here... e.g. 'Explain the eUTXO model in simple terms'"></textarea>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<div>
|
||||||
|
<label class="field-label">Payment: <span class="payment-val" id="payment-display">5.0 AP3X</span></label>
|
||||||
|
<input type="range" id="payment" min="1" max="20" step="0.5" value="5" oninput="document.getElementById('payment-display').textContent = this.value + ' AP3X'">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="field-label">Supplier</label>
|
||||||
|
<select id="supplier"></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="submit-btn" id="submit-btn" onclick="submitTask()">Submit Task</button>
|
||||||
|
<div id="loading-bar" class="loading-bar" style="display:none"><div class="loading-fill"></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<div id="error-area" class="error-box" style="display:none">
|
||||||
|
<span id="error-msg"></span>
|
||||||
|
<button class="dismiss-btn" onclick="document.getElementById('error-area').style.display='none'">Dismiss</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Result -->
|
||||||
|
<div id="result-area" class="result-card" style="display:none">
|
||||||
|
<div class="result-header">
|
||||||
|
<span class="success-badge" id="r-status-badge">Completed</span>
|
||||||
|
<span class="result-cost" id="r-cost"></span>
|
||||||
|
</div>
|
||||||
|
<div class="role-label">YOUR PROMPT</div>
|
||||||
|
<div class="prompt-box"><p class="content-text" id="r-prompt"></p></div>
|
||||||
|
<div class="role-label" id="r-supplier-label">RESPONSE</div>
|
||||||
|
<div class="response-box"><p class="content-text" id="r-response"></p></div>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<div><div class="meta-label">Model</div><div class="meta-value" id="r-model"></div></div>
|
||||||
|
<div><div class="meta-label">Tokens</div><div class="meta-value" id="r-tokens"></div></div>
|
||||||
|
<div><div class="meta-label">Latency</div><div class="meta-value" id="r-latency"></div></div>
|
||||||
|
<div><div class="meta-label">Task ID</div><div class="meta-value" id="r-taskid"></div></div>
|
||||||
|
<div><div class="meta-label">Escrow TX</div><div class="meta-value" id="r-escrow"></div></div>
|
||||||
|
<div><div class="meta-label">Supplier</div><div class="meta-value" id="r-supplier"></div></div>
|
||||||
|
</div>
|
||||||
|
<button class="accept-btn" id="accept-btn" onclick="acceptTask()">Accept & Pay</button>
|
||||||
|
<div id="accept-status" style="margin-top: 8px; font-size: 13px; color: #6b7280;"></div>
|
||||||
|
<button class="new-btn" onclick="resetForm()" style="margin-top: 8px;">New Task</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- History Tab -->
|
||||||
|
<div id="tab-history" class="tab">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">Task History</h2>
|
||||||
|
<span class="badge" id="history-count">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<div id="history-list" class="history-list"></div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var API = '';
|
||||||
|
var suppliers = [];
|
||||||
|
var expandedId = null;
|
||||||
|
var currentResult = null;
|
||||||
|
|
||||||
|
// --- Tab switching ---
|
||||||
|
function switchTab(tab, btn) {
|
||||||
|
document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });
|
||||||
|
document.querySelectorAll('.nav-btn').forEach(function(b) { b.classList.remove('active'); });
|
||||||
|
document.getElementById('tab-' + tab).classList.add('active');
|
||||||
|
btn.classList.add('active');
|
||||||
|
if (tab === 'history') loadHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Load balance ---
|
||||||
|
async function loadBalance() {
|
||||||
|
try {
|
||||||
|
var resp = await fetch(API + '/api/balance');
|
||||||
|
var data = await resp.json();
|
||||||
|
document.getElementById('wallet-balance').textContent = 'Balance: ' + parseFloat(data.ada).toFixed(2) + ' AP3X';
|
||||||
|
} catch(e) {
|
||||||
|
document.getElementById('wallet-balance').textContent = 'Balance: unavailable';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Load suppliers ---
|
||||||
|
async function loadSuppliers() {
|
||||||
|
try {
|
||||||
|
var resp = await fetch(API + '/api/suppliers');
|
||||||
|
var data = await resp.json();
|
||||||
|
suppliers = data.suppliers || [];
|
||||||
|
document.getElementById('supplier-count').textContent = suppliers.length + ' supplier' + (suppliers.length !== 1 ? 's' : '') + ' available';
|
||||||
|
var sel = document.getElementById('supplier');
|
||||||
|
if (suppliers.length === 0) {
|
||||||
|
sel.innerHTML = '<option value="">No suppliers found</option>';
|
||||||
|
} else {
|
||||||
|
sel.innerHTML = suppliers.map(function(s) {
|
||||||
|
var label = s.name + ' [' + s.models.join(', ') + '] - ' + s.endpoint;
|
||||||
|
return '<option value="' + esc(s.endpoint) + '">' + esc(label) + '</option>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
document.getElementById('supplier-count').textContent = 'Could not reach API';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Submit task ---
|
||||||
|
async function submitTask() {
|
||||||
|
var prompt = document.getElementById('prompt').value.trim();
|
||||||
|
if (!prompt) return;
|
||||||
|
|
||||||
|
var supplierUrl = document.getElementById('supplier').value;
|
||||||
|
if (!supplierUrl) {
|
||||||
|
document.getElementById('error-msg').textContent = 'No supplier selected';
|
||||||
|
document.getElementById('error-area').style.display = 'flex';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var btn = document.getElementById('submit-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Posting escrow on-chain...';
|
||||||
|
document.getElementById('loading-bar').style.display = 'block';
|
||||||
|
document.getElementById('error-area').style.display = 'none';
|
||||||
|
document.getElementById('result-area').style.display = 'none';
|
||||||
|
|
||||||
|
setTimeout(function() { if (btn.disabled) btn.textContent = 'Waiting for LLM response...'; }, 4000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
var resp = await fetch(API + '/api/submit', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt: prompt,
|
||||||
|
payment_apex: parseFloat(document.getElementById('payment').value),
|
||||||
|
supplier_url: supplierUrl
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
var err = null;
|
||||||
|
try { err = await resp.json(); } catch(x) {}
|
||||||
|
throw new Error((err && err.detail) ? err.detail : 'HTTP ' + resp.status);
|
||||||
|
}
|
||||||
|
var r = await resp.json();
|
||||||
|
currentResult = r;
|
||||||
|
showResult(prompt, r);
|
||||||
|
loadBalance();
|
||||||
|
} catch(e) {
|
||||||
|
document.getElementById('error-msg').textContent = e.message;
|
||||||
|
document.getElementById('error-area').style.display = 'flex';
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Submit Task';
|
||||||
|
document.getElementById('loading-bar').style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showResult(prompt, r) {
|
||||||
|
document.getElementById('form-area').style.display = 'none';
|
||||||
|
document.getElementById('result-area').style.display = 'block';
|
||||||
|
document.getElementById('r-cost').textContent = r.payment_apex.toFixed(1) + ' AP3X';
|
||||||
|
document.getElementById('r-prompt').textContent = prompt;
|
||||||
|
document.getElementById('r-supplier-label').textContent = 'RESPONSE -- ' + (r.supplier_url || '');
|
||||||
|
document.getElementById('r-response').textContent = r.response;
|
||||||
|
document.getElementById('r-model').textContent = r.model_used || 'unknown';
|
||||||
|
document.getElementById('r-tokens').textContent = (r.input_tokens || 0) + ' in / ' + (r.output_tokens || 0) + ' out';
|
||||||
|
document.getElementById('r-latency').textContent = ((r.latency_ms || 0) / 1000).toFixed(1) + 's';
|
||||||
|
document.getElementById('r-taskid').textContent = r.task_id;
|
||||||
|
document.getElementById('r-escrow').textContent = r.escrow_tx ? r.escrow_tx.substring(0, 32) + '...' : '-';
|
||||||
|
document.getElementById('r-supplier').textContent = r.supplier_url || '-';
|
||||||
|
|
||||||
|
// Show or hide accept button
|
||||||
|
var acceptBtn = document.getElementById('accept-btn');
|
||||||
|
var acceptStatus = document.getElementById('accept-status');
|
||||||
|
if (r.status === 'accepted' || r.accept_tx) {
|
||||||
|
acceptBtn.style.display = 'none';
|
||||||
|
acceptStatus.textContent = 'Accepted (TX: ' + (r.accept_tx || '').substring(0, 32) + '...)';
|
||||||
|
acceptStatus.style.color = '#10b981';
|
||||||
|
document.getElementById('r-status-badge').textContent = 'Accepted';
|
||||||
|
document.getElementById('r-status-badge').className = 'accepted-badge';
|
||||||
|
} else {
|
||||||
|
acceptBtn.style.display = 'block';
|
||||||
|
acceptBtn.disabled = false;
|
||||||
|
acceptBtn.textContent = 'Accept & Pay';
|
||||||
|
acceptStatus.textContent = '';
|
||||||
|
document.getElementById('r-status-badge').textContent = 'Completed';
|
||||||
|
document.getElementById('r-status-badge').className = 'success-badge';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Accept task ---
|
||||||
|
async function acceptTask() {
|
||||||
|
if (!currentResult || !currentResult.task_id) return;
|
||||||
|
|
||||||
|
var btn = document.getElementById('accept-btn');
|
||||||
|
var statusEl = document.getElementById('accept-status');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Accepting on-chain...';
|
||||||
|
statusEl.textContent = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
var resp = await fetch(API + '/api/accept/' + currentResult.task_id, { method: 'POST' });
|
||||||
|
if (!resp.ok) {
|
||||||
|
var err = null;
|
||||||
|
try { err = await resp.json(); } catch(x) {}
|
||||||
|
throw new Error((err && err.detail) ? err.detail : 'HTTP ' + resp.status);
|
||||||
|
}
|
||||||
|
var data = await resp.json();
|
||||||
|
btn.style.display = 'none';
|
||||||
|
statusEl.textContent = 'Accepted! TX: ' + data.accept_tx.substring(0, 32) + '...';
|
||||||
|
statusEl.style.color = '#10b981';
|
||||||
|
currentResult.accept_tx = data.accept_tx;
|
||||||
|
currentResult.status = 'accepted';
|
||||||
|
document.getElementById('r-status-badge').textContent = 'Accepted';
|
||||||
|
document.getElementById('r-status-badge').className = 'accepted-badge';
|
||||||
|
loadBalance();
|
||||||
|
} catch(e) {
|
||||||
|
statusEl.textContent = 'Accept failed: ' + e.message;
|
||||||
|
statusEl.style.color = '#ef4444';
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Accept & Pay';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
document.getElementById('form-area').style.display = 'block';
|
||||||
|
document.getElementById('result-area').style.display = 'none';
|
||||||
|
document.getElementById('prompt').value = '';
|
||||||
|
currentResult = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- History ---
|
||||||
|
function timeAgo(ts) {
|
||||||
|
var diff = Date.now() / 1000 - ts;
|
||||||
|
if (diff < 60) return Math.floor(diff) + 's ago';
|
||||||
|
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||||||
|
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
|
||||||
|
return Math.floor(diff / 86400) + 'd ago';
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(s, len) { return s && s.length > len ? s.substring(0, len) + '...' : (s || '-'); }
|
||||||
|
|
||||||
|
async function loadHistory() {
|
||||||
|
try {
|
||||||
|
var resp = await fetch(API + '/api/tasks');
|
||||||
|
var data = await resp.json();
|
||||||
|
var tasks = data.tasks || [];
|
||||||
|
document.getElementById('history-count').textContent = data.total + ' tasks';
|
||||||
|
|
||||||
|
if (tasks.length === 0) {
|
||||||
|
document.getElementById('history-list').innerHTML = '<div class="empty"><p>No tasks yet</p><p>Tasks will appear here once you submit prompts</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show most recent first
|
||||||
|
tasks = tasks.slice().reverse();
|
||||||
|
|
||||||
|
document.getElementById('history-list').innerHTML = tasks.map(function(t) {
|
||||||
|
var isExp = expandedId === t.task_id;
|
||||||
|
var promptText = isExp ? t.prompt : (t.prompt || '').substring(0, 150) + ((t.prompt || '').length > 150 ? '...' : '');
|
||||||
|
var respText = isExp ? t.response : (t.response || '').substring(0, 200) + ((t.response || '').length > 200 ? '...' : '');
|
||||||
|
var isAccepted = t.status === 'accepted' || t.accept_tx;
|
||||||
|
var dotClass = isAccepted ? 'dot accepted' : 'dot';
|
||||||
|
|
||||||
|
var html = '<div class="history-card' + (isExp ? ' expanded' : '') + '" onclick="toggleHistory(\'' + t.task_id + '\')">'
|
||||||
|
+ '<div class="history-header"><div class="history-left">'
|
||||||
|
+ '<span class="' + dotClass + '"></span><span class="model-badge">' + esc(t.model_used || 'unknown') + '</span>'
|
||||||
|
+ '<span class="time-badge">' + (t.completed_at ? timeAgo(t.completed_at) : '-') + '</span></div>'
|
||||||
|
+ '<div class="history-right"><span class="token-info">' + ((t.input_tokens||0) + (t.output_tokens||0)) + ' tokens</span>'
|
||||||
|
+ '<span class="payment">' + ((t.payment_dfm||0) / 1000000).toFixed(1) + ' AP3X</span></div></div>'
|
||||||
|
+ '<div class="role-label">PROMPT</div>'
|
||||||
|
+ '<div class="prompt-box"><p class="content-text">' + esc(promptText) + '</p></div>'
|
||||||
|
+ '<div class="role-label">RESPONSE</div>'
|
||||||
|
+ '<div class="response-box"><p class="content-text">' + esc(respText) + '</p></div>';
|
||||||
|
|
||||||
|
if (isExp) {
|
||||||
|
html += '<div class="meta-grid">'
|
||||||
|
+ '<div><div class="meta-label">Task ID</div><div class="meta-value">' + esc(t.task_id || '-') + '</div></div>'
|
||||||
|
+ '<div><div class="meta-label">Task Hash</div><div class="meta-value">' + truncate(t.task_hash, 24) + '</div></div>'
|
||||||
|
+ '<div><div class="meta-label">Escrow TX</div><div class="meta-value">' + truncate(t.escrow_tx, 24) + '</div></div>'
|
||||||
|
+ '<div><div class="meta-label">Claim TX</div><div class="meta-value">' + truncate(t.claim_tx, 24) + '</div></div>'
|
||||||
|
+ '<div><div class="meta-label">Submit TX</div><div class="meta-value">' + truncate(t.submit_tx, 24) + '</div></div>'
|
||||||
|
+ '<div><div class="meta-label">Accept TX</div><div class="meta-value">' + truncate(t.accept_tx, 24) + '</div></div>'
|
||||||
|
+ '<div><div class="meta-label">Input Tokens</div><div class="meta-value">' + (t.input_tokens||0) + '</div></div>'
|
||||||
|
+ '<div><div class="meta-label">Output Tokens</div><div class="meta-value">' + (t.output_tokens||0) + '</div></div>'
|
||||||
|
+ '<div><div class="meta-label">Latency</div><div class="meta-value">' + (t.latency_ms||0) + 'ms</div></div>'
|
||||||
|
+ '<div><div class="meta-label">Supplier</div><div class="meta-value">' + esc(t.supplier_url || '-') + '</div></div>'
|
||||||
|
+ '<div><div class="meta-label">Status</div><div class="meta-value" style="color:' + (isAccepted ? '#6366f1' : '#10b981') + '">' + esc(t.status||'completed') + '</div></div>'
|
||||||
|
+ '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '<div class="expand-hint">' + (isExp ? 'Click to collapse' : 'Click to expand') + '</div></div>';
|
||||||
|
return html;
|
||||||
|
}).join('');
|
||||||
|
} catch(e) {
|
||||||
|
document.getElementById('history-list').innerHTML = '<div class="empty"><p>Could not load history</p><p>' + esc(e.message) + '</p></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleHistory(id) { expandedId = expandedId === id ? null : id; loadHistory(); }
|
||||||
|
function esc(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||||
|
|
||||||
|
// --- Init ---
|
||||||
|
loadBalance();
|
||||||
|
loadSuppliers();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
51
docs/buyer/storage.py
Normal file
51
docs/buyer/storage.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"""Simple JSON file storage for task history."""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
DATA_DIR = Path(__file__).parent / "data"
|
||||||
|
TASKS_FILE = DATA_DIR / "tasks.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_data_dir():
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
if not TASKS_FILE.exists():
|
||||||
|
TASKS_FILE.write_text("[]", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def save_task(task_data: dict):
|
||||||
|
"""Append a task record to tasks.json."""
|
||||||
|
_ensure_data_dir()
|
||||||
|
tasks = load_tasks()
|
||||||
|
tasks.append(task_data)
|
||||||
|
TASKS_FILE.write_text(json.dumps(tasks, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def load_tasks() -> List[dict]:
|
||||||
|
"""Read all tasks from tasks.json."""
|
||||||
|
_ensure_data_dir()
|
||||||
|
try:
|
||||||
|
raw = TASKS_FILE.read_text(encoding="utf-8")
|
||||||
|
return json.loads(raw)
|
||||||
|
except (json.JSONDecodeError, FileNotFoundError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def get_task(task_id: str) -> Optional[dict]:
|
||||||
|
"""Find a task by its task_id."""
|
||||||
|
tasks = load_tasks()
|
||||||
|
for t in tasks:
|
||||||
|
if t.get("task_id") == task_id:
|
||||||
|
return t
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def update_task(task_id: str, updates: dict):
|
||||||
|
"""Update fields of an existing task by task_id."""
|
||||||
|
_ensure_data_dir()
|
||||||
|
tasks = load_tasks()
|
||||||
|
for i, t in enumerate(tasks):
|
||||||
|
if t.get("task_id") == task_id:
|
||||||
|
tasks[i].update(updates)
|
||||||
|
break
|
||||||
|
TASKS_FILE.write_text(json.dumps(tasks, indent=2), encoding="utf-8")
|
||||||
44
docs/buyer/supplier_client.py
Normal file
44
docs/buyer/supplier_client.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""HTTP client for communicating with suppliers."""
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from lib.protocol import SupplierInfo, TaskRequest, TaskResponse
|
||||||
|
|
||||||
|
|
||||||
|
async def send_task(supplier_url: str, task_request: TaskRequest) -> TaskResponse:
|
||||||
|
"""POST a task to a supplier and return the parsed response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
supplier_url: Base URL of the supplier (e.g. http://localhost:9000).
|
||||||
|
task_request: The task request payload.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TaskResponse parsed from the supplier's JSON response.
|
||||||
|
"""
|
||||||
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{supplier_url.rstrip('/')}/task",
|
||||||
|
json=task_request.model_dump(),
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return TaskResponse(**resp.json())
|
||||||
|
|
||||||
|
|
||||||
|
async def check_supplier_health(supplier_url: str) -> Optional[SupplierInfo]:
|
||||||
|
"""GET supplier info endpoint and return parsed SupplierInfo, or None on failure.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
supplier_url: Base URL of the supplier.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SupplierInfo if reachable, None otherwise.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
resp = await client.get(f"{supplier_url.rstrip('/')}/info")
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
return SupplierInfo(**data)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
167
docs/buyer/task_manager.py
Normal file
167
docs/buyer/task_manager.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
"""Orchestrates the full buyer flow: escrow -> task -> accept."""
|
||||||
|
import hashlib
|
||||||
|
import time
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from pycardano import Address
|
||||||
|
from pycardano.hash import ScriptHash
|
||||||
|
|
||||||
|
from lib.blueprint import load_escrow_script
|
||||||
|
from lib.chain import find_script_utxo, wait_for_utxo
|
||||||
|
from lib.constants import DEFAULT_DEADLINE_OFFSET_MS, DISPUTE_WINDOW_MS
|
||||||
|
from lib.protocol import TaskRequest
|
||||||
|
|
||||||
|
from escrow_ops import post_escrow, accept_result
|
||||||
|
from supplier_client import send_task, check_supplier_health
|
||||||
|
from storage import get_task
|
||||||
|
|
||||||
|
|
||||||
|
async def submit_task(prompt, payment_apex, supplier_url, buyer_agent, config) -> dict:
|
||||||
|
"""Execute the full buyer flow:
|
||||||
|
|
||||||
|
1. Load script from blueprint
|
||||||
|
2. Compute task_hash = sha256(f"{task_id}:{prompt}")
|
||||||
|
3. Compute deadline_posix_ms = current_posix_ms + DEFAULT_DEADLINE_OFFSET_MS
|
||||||
|
4. Post escrow on-chain (Open state)
|
||||||
|
5. Send TaskRequest to supplier via HTTP
|
||||||
|
6. Return combined result dict
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt: The user's prompt text.
|
||||||
|
payment_apex: Payment amount in APEX (float).
|
||||||
|
supplier_url: URL of the supplier to use.
|
||||||
|
buyer_agent: VectorAgent instance for the buyer.
|
||||||
|
config: BuyerConfig instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with escrow_tx, response data, supplier info, etc.
|
||||||
|
"""
|
||||||
|
context = buyer_agent.context
|
||||||
|
task_id = f"task-{uuid4().hex[:8]}"
|
||||||
|
payment_dfm = int(payment_apex * 1_000_000)
|
||||||
|
|
||||||
|
# 1. Load script from blueprint
|
||||||
|
script, script_hash_str = load_escrow_script()
|
||||||
|
|
||||||
|
# 2. Compute task hash
|
||||||
|
task_hash = hashlib.sha256(f"{task_id}:{prompt}".encode()).hexdigest()
|
||||||
|
|
||||||
|
# 3. Compute deadline
|
||||||
|
current_posix_ms = int(time.time() * 1000)
|
||||||
|
deadline_posix_ms = current_posix_ms + DEFAULT_DEADLINE_OFFSET_MS
|
||||||
|
|
||||||
|
# 4. Extract resolver credential from resolver address
|
||||||
|
resolver_addr = Address.from_primitive(config.resolver_address)
|
||||||
|
resolver_cred = bytes(resolver_addr.payment_part).hex()
|
||||||
|
|
||||||
|
# 5. Post escrow on-chain
|
||||||
|
escrow_tx = await post_escrow(
|
||||||
|
context=context,
|
||||||
|
signer=buyer_agent,
|
||||||
|
script_hash=config.escrow_script_hash,
|
||||||
|
task_hash=task_hash,
|
||||||
|
payment_dfm=payment_dfm,
|
||||||
|
deadline_posix_ms=deadline_posix_ms,
|
||||||
|
resolver_cred=resolver_cred,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"[Buyer] Escrow posted: {escrow_tx}")
|
||||||
|
|
||||||
|
# 6. Send task to supplier
|
||||||
|
task_request = TaskRequest(
|
||||||
|
task_id=task_id,
|
||||||
|
prompt=prompt,
|
||||||
|
escrow_tx=escrow_tx,
|
||||||
|
escrow_script_hash=config.escrow_script_hash,
|
||||||
|
payment_dfm=payment_dfm,
|
||||||
|
task_hash=task_hash,
|
||||||
|
buyer_address=buyer_agent.address,
|
||||||
|
model_preference="nemotron",
|
||||||
|
max_tokens=2048,
|
||||||
|
temperature=0.7,
|
||||||
|
deadline_posix_ms=deadline_posix_ms,
|
||||||
|
dispute_window_ms=DISPUTE_WINDOW_MS,
|
||||||
|
)
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
response = await send_task(supplier_url, task_request)
|
||||||
|
latency_ms = int((time.time() - start_time) * 1000)
|
||||||
|
|
||||||
|
print(f"[Buyer] Supplier response: status={response.status}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"task_id": task_id,
|
||||||
|
"prompt": prompt,
|
||||||
|
"payment_apex": payment_apex,
|
||||||
|
"payment_dfm": payment_dfm,
|
||||||
|
"escrow_tx": escrow_tx,
|
||||||
|
"task_hash": task_hash,
|
||||||
|
"supplier_url": supplier_url,
|
||||||
|
"buyer_address": buyer_agent.address,
|
||||||
|
"deadline_posix_ms": deadline_posix_ms,
|
||||||
|
"status": response.status,
|
||||||
|
"response": response.response,
|
||||||
|
"result_hash": response.result_hash,
|
||||||
|
"model_used": response.model_used,
|
||||||
|
"input_tokens": response.input_tokens,
|
||||||
|
"output_tokens": response.output_tokens,
|
||||||
|
"claim_tx": response.claim_tx,
|
||||||
|
"submit_tx": response.submit_tx,
|
||||||
|
"latency_ms": latency_ms,
|
||||||
|
"completed_at": time.time(),
|
||||||
|
"error": response.error,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def accept_task(task_id, buyer_agent, config) -> str:
|
||||||
|
"""Accept a submitted task result by spending the escrow UTxO.
|
||||||
|
|
||||||
|
Loads the task from local storage, finds the Submit UTxO on-chain,
|
||||||
|
and calls accept_result.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_id: The task identifier.
|
||||||
|
buyer_agent: VectorAgent instance for the buyer.
|
||||||
|
config: BuyerConfig instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The accept transaction hash as a string.
|
||||||
|
"""
|
||||||
|
task_data = get_task(task_id)
|
||||||
|
if not task_data:
|
||||||
|
raise ValueError(f"Task {task_id} not found in storage")
|
||||||
|
|
||||||
|
submit_tx_hash = task_data.get("submit_tx")
|
||||||
|
if not submit_tx_hash:
|
||||||
|
raise ValueError(f"Task {task_id} has no submit_tx recorded")
|
||||||
|
|
||||||
|
context = buyer_agent.context
|
||||||
|
script, script_hash_str = load_escrow_script()
|
||||||
|
|
||||||
|
script_hash_obj = ScriptHash.from_primitive(config.escrow_script_hash)
|
||||||
|
script_address = Address(script_hash_obj, network=context.network)
|
||||||
|
|
||||||
|
# Find the UTxO at the script address
|
||||||
|
utxo = await find_script_utxo(context, str(script_address), submit_tx_hash)
|
||||||
|
if not utxo:
|
||||||
|
# Try waiting for it
|
||||||
|
utxo = await wait_for_utxo(context, str(script_address), submit_tx_hash)
|
||||||
|
|
||||||
|
# Get the supplier's wallet address
|
||||||
|
supplier_url = task_data.get("supplier_url", "")
|
||||||
|
supplier_info = await check_supplier_health(supplier_url) if supplier_url else None
|
||||||
|
if not supplier_info or not supplier_info.address:
|
||||||
|
raise ValueError(f"Cannot resolve supplier address from {supplier_url}")
|
||||||
|
|
||||||
|
accept_tx = await accept_result(
|
||||||
|
context=context,
|
||||||
|
signer=buyer_agent,
|
||||||
|
utxo=utxo,
|
||||||
|
script=script,
|
||||||
|
script_address=script_address,
|
||||||
|
supplier_address=supplier_info.address,
|
||||||
|
payment_dfm=task_data.get("payment_dfm", 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"[Buyer] Accepted task {task_id}: {accept_tx}")
|
||||||
|
return accept_tx
|
||||||
15
docs/buyer/wallet.py
Normal file
15
docs/buyer/wallet.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
"""Buyer wallet -- initializes VectorAgent from mnemonic."""
|
||||||
|
from vector_agent import VectorAgent
|
||||||
|
|
||||||
|
from config import get_config
|
||||||
|
|
||||||
|
|
||||||
|
def get_buyer_agent() -> VectorAgent:
|
||||||
|
"""Create and return a VectorAgent from the configured buyer mnemonic."""
|
||||||
|
config = get_config()
|
||||||
|
agent = VectorAgent(
|
||||||
|
ogmios_url=config.ogmios_url,
|
||||||
|
submit_url=config.submit_url,
|
||||||
|
mnemonic=config.buyer_mnemonic,
|
||||||
|
)
|
||||||
|
return agent
|
||||||
1
docs/img/.blocks.json
Normal file
1
docs/img/.blocks.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[{"file":"docs/img/diagram-001.mmd","png":"docs/img/diagram-001.png"},{"file":"docs/img/diagram-002.mmd","png":"docs/img/diagram-002.png"},{"file":"docs/img/diagram-003.mmd","png":"docs/img/diagram-003.png"}]
|
||||||
BIN
docs/img/diagram-001.png
Normal file
BIN
docs/img/diagram-001.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 140 KiB |
BIN
docs/img/diagram-002.png
Normal file
BIN
docs/img/diagram-002.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 138 KiB |
BIN
docs/img/diagram-003.png
Normal file
BIN
docs/img/diagram-003.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
412
docs/local-llm-marketplace.md
Normal file
412
docs/local-llm-marketplace.md
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
---
|
||||||
|
marp: true
|
||||||
|
paginate: true
|
||||||
|
size: 16:9
|
||||||
|
---
|
||||||
|
|
||||||
|
<!--
|
||||||
|
EXECUTIVE_SUMMARY.md — slide-style overview of the Local Agents Marketplace.
|
||||||
|
Build PDF + PPTX with: bash docs/build-deck.sh
|
||||||
|
Edit this file; mermaid blocks are pre-rendered to docs/img/diagram-*.png.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* ── Local Agents Marketplace — unified dark theme ────────────────────
|
||||||
|
* Every slide uses the same navy→indigo gradient as the title slide so
|
||||||
|
* the deck reads as one continuous document. Title and closing slides
|
||||||
|
* keep the centered hero treatment; content slides stay top-aligned but
|
||||||
|
* share the same palette and accent rules. */
|
||||||
|
:root {
|
||||||
|
--ink: #f1f5f9; /* primary text on dark gradient */
|
||||||
|
--muted: #94a3b8;
|
||||||
|
--accent: #818cf8; /* indigo-400, readable on the dark gradient */
|
||||||
|
--accent-deep: #4f46e5; /* used for solid accents (h2 bar, table head) */
|
||||||
|
--accent-soft: rgba(129,140,248,0.12);
|
||||||
|
--gradient: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #312e81 100%);
|
||||||
|
--row-even: rgba(255,255,255,0.04);
|
||||||
|
--row-odd: rgba(255,255,255,0.02);
|
||||||
|
--border: rgba(255,255,255,0.10);
|
||||||
|
}
|
||||||
|
section {
|
||||||
|
background: var(--gradient);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||||
|
font-size: 22px;
|
||||||
|
padding: 40px 60px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
section::after {
|
||||||
|
content: attr(data-marpit-pagination) ' / ' attr(data-marpit-pagination-total);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 48px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
border-bottom: 4px solid var(--accent);
|
||||||
|
padding-bottom: 10px;
|
||||||
|
margin: 0 0 18px 0;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 34px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
margin: 0 0 14px 0;
|
||||||
|
}
|
||||||
|
h2::before {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 6px;
|
||||||
|
height: 28px;
|
||||||
|
background: var(--accent);
|
||||||
|
margin-right: 14px;
|
||||||
|
vertical-align: middle;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
h3 { color: #c7d2fe; font-weight: 600; font-size: 24px; margin: 8px 0; }
|
||||||
|
p, li { margin: 6px 0; line-height: 1.45; }
|
||||||
|
ul, ol { margin: 8px 0 12px 0; padding-left: 28px; }
|
||||||
|
strong { color: #ffffff; }
|
||||||
|
a { color: #a5b4fc; }
|
||||||
|
blockquote {
|
||||||
|
border-left: 4px solid var(--accent);
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
padding: 10px 18px;
|
||||||
|
color: #e0e7ff;
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
background: rgba(255,255,255,0.10);
|
||||||
|
color: #e0e7ff;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
/* Constrain mermaid images so they always fit on a 720px-tall slide
|
||||||
|
* (16:9 @ 1280×720 default). Soft white card behind the diagram so the
|
||||||
|
* mermaid SVG (which renders on a transparent canvas with dark ink)
|
||||||
|
* stays readable on the navy gradient. */
|
||||||
|
img {
|
||||||
|
display: block;
|
||||||
|
margin: 8px auto;
|
||||||
|
max-width: 92%;
|
||||||
|
max-height: 420px;
|
||||||
|
object-fit: contain;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
|
||||||
|
}
|
||||||
|
/* Tile grid for the local-agent versatility slide. */
|
||||||
|
.backends {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
margin: 14px 0 12px 0;
|
||||||
|
}
|
||||||
|
.tile {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border: 2px solid var(--accent);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.tile small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
.tile.dots {
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
border-style: dashed;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
.pipeline {
|
||||||
|
text-align: center;
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: #f8fafc;
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin: 6px auto 14px auto;
|
||||||
|
max-width: 760px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.pipeline span {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0 10px;
|
||||||
|
}
|
||||||
|
/* Tables sit on a solid white card so cell text reads cleanly against
|
||||||
|
* the dark gradient backdrop. Rounded outer corners + shadow match the
|
||||||
|
* mermaid card styling. `display:table` + `table-layout:fixed` are
|
||||||
|
* needed so the table actually consumes the full slide width — Marp's
|
||||||
|
* default theme leaves tables at content-natural width, which left
|
||||||
|
* blank gutters on either side. */
|
||||||
|
table {
|
||||||
|
display: table !important;
|
||||||
|
width: 100% !important;
|
||||||
|
table-layout: fixed;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 12px 0;
|
||||||
|
font-size: 0.82em;
|
||||||
|
color: #0f172a;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
|
||||||
|
}
|
||||||
|
td, th { word-wrap: break-word; overflow-wrap: anywhere; }
|
||||||
|
th {
|
||||||
|
background: var(--accent-deep);
|
||||||
|
color: #ffffff;
|
||||||
|
text-align: left;
|
||||||
|
padding: 9px 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
padding: 7px 12px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
tr:nth-child(even) td { background: #ffffff; }
|
||||||
|
tr:nth-child(odd) td { background: #f8fafc; }
|
||||||
|
/* code spans inside table cells need a light pill, not the dark one
|
||||||
|
* defined globally — otherwise they'd disappear into the white row. */
|
||||||
|
td code {
|
||||||
|
background: #eef2ff;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
/* Bold/strong inside cells must stay dark — the global rule paints
|
||||||
|
* <strong> white for the dark-gradient body text, which would render
|
||||||
|
* invisible against the white table card. */
|
||||||
|
td strong, th strong {
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
th strong {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
/* Title slide — centered hero treatment on the same gradient. */
|
||||||
|
section.title {
|
||||||
|
text-align: center;
|
||||||
|
padding: 100px 80px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
section.title h1 {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 64px;
|
||||||
|
margin: 0 auto 20px auto;
|
||||||
|
}
|
||||||
|
section.title blockquote {
|
||||||
|
margin: 28px auto;
|
||||||
|
max-width: 900px;
|
||||||
|
}
|
||||||
|
/* Closing slide — same centered treatment. */
|
||||||
|
section.closing {
|
||||||
|
text-align: center;
|
||||||
|
padding: 100px 80px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
section.closing h2::before { display: none; }
|
||||||
|
section.closing blockquote { font-size: 26px; max-width: 900px; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- _class: title -->
|
||||||
|
|
||||||
|
# Local Agents Marketplace
|
||||||
|
|
||||||
|
### A trust-minimised, on-chain marketplace for LLM inference
|
||||||
|
|
||||||
|
Buy and sell prompt → response with **bonded escrow** on **Vector** (UTxO).
|
||||||
|
No platform fee. No central rate-limiter. Verifiable receipts on chain.
|
||||||
|
|
||||||
|
> *Status: M1 lifecycle proven end-to-end on Vector testnet — 2026-05-05.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The problem
|
||||||
|
|
||||||
|
* AI inference is **centralised** — a handful of providers, opaque pricing, account-level rate limits.
|
||||||
|
* Local-AI hardware is **idle most of the time** — GPUs and CPUs sit unused between user prompts.
|
||||||
|
* Buyers and sellers can't transact directly without a **trusted intermediary** taking a cut.
|
||||||
|
* "Verifiable" claims about which model ran are usually nothing more than the provider's word.
|
||||||
|
|
||||||
|
We need a **two-sided marketplace** where:
|
||||||
|
1. Sellers monetise spare compute,
|
||||||
|
2. Buyers get a **cryptographic receipt** of what was actually computed,
|
||||||
|
3. **Neither side has to trust the other** — bonds + on-chain settlement enforce honesty.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How it works — three actors, one chain
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph CHAIN[Vector — UTxO]
|
||||||
|
ADV[Advert UTxO<br/>capability, price, bonds]
|
||||||
|
ESC[Escrow UTxO<br/>locked funds + state]
|
||||||
|
end
|
||||||
|
|
||||||
|
SUP[🧠 Supplier<br/>local LLM node]
|
||||||
|
BUY[💼 Buyer<br/>app or agent]
|
||||||
|
IDX[🔭 Indexer<br/>read-only bulletin board]
|
||||||
|
|
||||||
|
SUP -->|publish advert| ADV
|
||||||
|
BUY -->|discover| IDX
|
||||||
|
IDX -.->|index events| CHAIN
|
||||||
|
BUY -->|post escrow| ESC
|
||||||
|
BUY -->|prompt + escrow_ref| SUP
|
||||||
|
SUP -->|signed receipt| BUY
|
||||||
|
SUP -->|claim & submit| ESC
|
||||||
|
BUY -->|accept & pay| ESC
|
||||||
|
```
|
||||||
|
|
||||||
|
* **Supplier** runs the inference and publishes a price.
|
||||||
|
* **Buyer** posts the work as a bonded escrow, sends the prompt, accepts the receipt.
|
||||||
|
* **Indexer** is a public mirror of chain state — anyone can run one. No platform middleman.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The bonded-escrow lifecycle
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
autonumber
|
||||||
|
participant B as Buyer
|
||||||
|
participant Chain as Vector
|
||||||
|
participant S as Supplier
|
||||||
|
participant LLM as Local LLM<br/>(OpenAI-compatible)
|
||||||
|
|
||||||
|
S->>Chain: PostAdvert (locks supplier_bond)
|
||||||
|
B->>Chain: PostEscrow → Open<br/>(locks payment + bonds)
|
||||||
|
B->>S: POST /v1/chat/completions
|
||||||
|
S->>Chain: ClaimEscrow → Claimed
|
||||||
|
S->>LLM: run inference
|
||||||
|
LLM-->>S: assistant message
|
||||||
|
S->>Chain: SubmitEscrow → Submitted
|
||||||
|
S-->>B: 200 OK + signed receipt
|
||||||
|
B->>Chain: AcceptEscrow — supplier paid
|
||||||
|
```
|
||||||
|
|
||||||
|
Every transition is on-chain, validated by an Aiken Plutus V3 script. The receipt is signed by the supplier's advert key; prompt and response hashes are bound on chain.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What if someone misbehaves?
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
OPEN[Open]:::norm --> CLAIMED[Claimed]:::norm --> SUBMITTED[Submitted]:::norm --> ACCEPTED[Accepted ✓]:::good
|
||||||
|
OPEN -->|deliver_by passed| RECLAIM[Reclaimed<br/>buyer recovers funds]:::warn
|
||||||
|
CLAIMED -->|deliver_by passed| RECLAIM
|
||||||
|
SUBMITTED -->|accept_window passed| RELEASE[Released<br/>supplier collects]:::warn
|
||||||
|
|
||||||
|
classDef norm fill:#e0e7ff,stroke:#4f46e5
|
||||||
|
classDef good fill:#dcfce7,stroke:#16a34a
|
||||||
|
classDef warn fill:#fef3c7,stroke:#d97706
|
||||||
|
```
|
||||||
|
|
||||||
|
| Failure mode | Recovery | Loss bearer |
|
||||||
|
|---|---|---|
|
||||||
|
| Supplier never claims/submits | Buyer **Reclaim** after `deliver_by` | Supplier — forfeits supplier_bond |
|
||||||
|
| Buyer disappears after Submit | Supplier **Release** after `submitted_at + 10 min` | Buyer — forfeits buyer_bond |
|
||||||
|
|
||||||
|
Bonds make honesty cheaper than running away. Both sides have skin in the game; no third-party arbiter is needed for the M1 happy/timeout paths.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Local-agent versatility — any OpenAI-compatible engine
|
||||||
|
|
||||||
|
<div class="pipeline">
|
||||||
|
<span>💼 Buyer</span> →
|
||||||
|
<span>🧠 Supplier (OpenAI-compatible HTTP wrapper)</span> →
|
||||||
|
<span>backend ↓</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="backends">
|
||||||
|
<div class="tile">Ollama<br/><small>local model runner</small></div>
|
||||||
|
<div class="tile">vLLM<br/><small>high-throughput GPU</small></div>
|
||||||
|
<div class="tile">llama.cpp<br/><small>CPU · Metal · ROCm</small></div>
|
||||||
|
<div class="tile">OpenRouter<br/><small>cloud API mux</small></div>
|
||||||
|
<div class="tile">openclaw / custom<br/><small>arbitrary stack</small></div>
|
||||||
|
<div class="tile dots">…<br/><small>any OpenAI-shaped API</small></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
The supplier service is a thin HTTP wrapper — it forwards `/v1/chat/completions` to whatever backend is configured. Each supplier picks its own model, hardware, and engine, then advertises a `capability_id`. Buyers shop on capability + model + price.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Capability + model = task selection
|
||||||
|
|
||||||
|
| Capability ID | Example models | Use case |
|
||||||
|
|---|---|---|
|
||||||
|
| `llm.text.generate.v1` | Llama 3 · Qwen 2.5 · Mistral · GPT-4 | Q&A, drafts, summarisation |
|
||||||
|
| `code.completion.v1` | Codestral · DeepSeek-Coder · StarCoder | IDE tab-complete, refactor |
|
||||||
|
| `vision.describe.v1` | LLaVA · Qwen-VL · Pixtral | Image captioning, OCR |
|
||||||
|
| `embedding.text.v1` | nomic-embed · BGE · ada-002 | RAG indexing |
|
||||||
|
| `audio.transcribe.v1` | whisper-large-v3 · distil-whisper | Voice-to-text |
|
||||||
|
| `…` | … | … |
|
||||||
|
|
||||||
|
* New capabilities = **just publish a new advert**. No marketplace governance gate.
|
||||||
|
* Today's deploy uses Ollama + Qwen 2.5; switching to vLLM with Llama 3 70B is a config change, not a code change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OpenClaw — agentic teams on your existing OpenAI seat
|
||||||
|
|
||||||
|
<div class="pipeline">
|
||||||
|
<span>🧠 Supplier wrapper</span> →
|
||||||
|
<span>🦾 OpenClaw</span> →
|
||||||
|
<span>🪪 OpenAI ChatGPT seat (flat-rate)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
* **Cost-efficient by default.** OpenClaw exposes your existing **OpenAI ChatGPT subscription** as an OpenAI-compatible API. The marketplace supplier dispatches its inference against it, so an entire **agentic team** (coders, QA automation testers, release curators…) draws from the **flat-rate seat you already pay for** — no per-token metering, no surprise invoices.
|
||||||
|
* **Reliability layer (optional, marginal cost).** OpenClaw can fall back to the **Anthropic API** or **OpenRouter** model pool when the primary seat is rate-limited, the prompt requires a different model, or you want a **second-layer hardening** of responses (e.g. cross-provider consensus on critical outputs). You only pay metered tokens for the requests that actually fall through.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Proven on Vector testnet — 2026-05-05
|
||||||
|
|
||||||
|
| Step | Tx hash |
|
||||||
|
|---|---|
|
||||||
|
| Buyer posts escrow | `af762561…c2f7bc5` |
|
||||||
|
| Supplier claims | (lifecycle tx) |
|
||||||
|
| Supplier submits receipt | (lifecycle tx) |
|
||||||
|
| Buyer accepts | `125e3cfe…7dc100d` |
|
||||||
|
|
||||||
|
Funds flowed correctly: supplier received `payment + supplier_bond` (3 ADA); buyer got `buyer_bond` back.
|
||||||
|
|
||||||
|
* **Browser demo:** https://mp-buyers.vector.testnet.apexfusion.org
|
||||||
|
* **Live event stream:** https://mp-indexer.vector.testnet.apexfusion.org
|
||||||
|
* **Test suite:** 1098 unit tests passing, full lifecycle exercised in CI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why UTxO + Vector
|
||||||
|
|
||||||
|
* **Bonded escrow is native** — Plutus validators enforce the state machine without a custom token or oracle.
|
||||||
|
* **Vector settles in seconds** — the buyer→claim→submit→accept loop fits inside a single chat-completion's wall clock.
|
||||||
|
* **AP3X token** for fees and bonds — fungible, transparent, on-chain.
|
||||||
|
* **No custom rollup, no bridge** — runs on a production chain today.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- _class: closing -->
|
||||||
|
|
||||||
|
## The pitch in one sentence
|
||||||
|
|
||||||
|
> Local Agents Marketplace turns any OpenAI-compatible inference endpoint into a **trustlessly billable service** — buyer and supplier exchange prompt, response, and payment with on-chain proof that what was paid for is what was delivered.
|
||||||
|
|
||||||
|
### Try it
|
||||||
|
|
||||||
|
* **Indexer (live event stream):** https://mp-indexer.vector.testnet.apexfusion.org
|
||||||
|
* **Buyer:** https://mp-buyers.vector.testnet.apexfusion.org
|
||||||
|
* **Supplier:** https://mp-suppliers.vector.testnet.apexfusion.org/capability
|
||||||
BIN
docs/local-llm-marketplace.pdf
Normal file
BIN
docs/local-llm-marketplace.pdf
Normal file
Binary file not shown.
BIN
docs/local-llm-marketplace.pptx
Normal file
BIN
docs/local-llm-marketplace.pptx
Normal file
Binary file not shown.
3
docs/puppeteer.json
Normal file
3
docs/puppeteer.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"args": ["--no-sandbox", "--disable-setuid-sandbox"]
|
||||||
|
}
|
||||||
14
docs/supplier/.env.example
Normal file
14
docs/supplier/.env.example
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Chain
|
||||||
|
VECTOR_OGMIOS_URL=https://ogmios.vector.testnet.apexfusion.org
|
||||||
|
VECTOR_SUBMIT_URL=https://submit.vector.apexfusion.org/api/submit/tx
|
||||||
|
# Wallet
|
||||||
|
SUPPLIER_MNEMONIC=your fifteen word mnemonic here
|
||||||
|
# Contract
|
||||||
|
ESCROW_SCRIPT_HASH=74d7f72f7ef0e2e79290ccf8d2eeda672b43379f037def441709395d
|
||||||
|
# LLM
|
||||||
|
OPENROUTER_API_KEY=sk-or-v1-...
|
||||||
|
# Resolver
|
||||||
|
RESOLVER_ADDRESS=addr1qxql380vv0up49vyw6ey8rsgut7saqval7fc40e9hvy9t60xw4lf7cz8rxkq9fmes4yf72tvjcjuh0tweghyrcpnl9vs6h5gjs
|
||||||
|
# Server
|
||||||
|
SUPPLIER_PORT=9000
|
||||||
|
SUPPLIER_NAME=default-supplier
|
||||||
185
docs/supplier/README.md
Normal file
185
docs/supplier/README.md
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
# Vector Supplier Node
|
||||||
|
|
||||||
|
A self-contained AI compute supplier for the Vector L2 testnet (Cardano). You receive task requests from buyers, fulfill them with an LLM, and settle payment on-chain via escrow smart contracts.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. A **buyer** posts an escrow transaction on-chain, locking their payment + bond
|
||||||
|
2. The buyer sends you an HTTP request with the task prompt and escrow TX hash
|
||||||
|
3. Your node **verifies** the escrow exists on-chain
|
||||||
|
4. Your node calls the **LLM** to generate a response
|
||||||
|
5. Your node **claims** the escrow on-chain (Open -> Claimed)
|
||||||
|
6. Your node **submits** the result hash on-chain (Claimed -> Submitted)
|
||||||
|
7. The buyer reviews the response and **accepts** (releasing payment to you)
|
||||||
|
|
||||||
|
All on-chain steps happen automatically. You just need to keep this node running.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Install Python
|
||||||
|
|
||||||
|
You need Python 3.9 or newer. Download from https://www.python.org/downloads/
|
||||||
|
|
||||||
|
### 2. Create a virtual environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd supplier
|
||||||
|
python -m venv .venv
|
||||||
|
|
||||||
|
# Windows:
|
||||||
|
.venv\Scripts\activate
|
||||||
|
|
||||||
|
# macOS/Linux:
|
||||||
|
source .venv/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Install dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Configure your environment
|
||||||
|
|
||||||
|
Copy the example file and fill in your values:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
copy .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `.env` with your settings:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Your wallet seed phrase (15 words)
|
||||||
|
SUPPLIER_MNEMONIC=word1 word2 word3 ... word15
|
||||||
|
|
||||||
|
# LLM API key from https://openrouter.ai
|
||||||
|
OPENROUTER_API_KEY=sk-or-v1-...
|
||||||
|
|
||||||
|
# Port to run on (default 9000)
|
||||||
|
SUPPLIER_PORT=9000
|
||||||
|
|
||||||
|
# Your node's display name
|
||||||
|
SUPPLIER_NAME=my-supplier
|
||||||
|
```
|
||||||
|
|
||||||
|
The other values (VECTOR_OGMIOS_URL, VECTOR_SUBMIT_URL, ESCROW_SCRIPT_HASH, RESOLVER_ADDRESS) are pre-configured for the Vector testnet and should not need changing.
|
||||||
|
|
||||||
|
### 5. Fund your wallet
|
||||||
|
|
||||||
|
Your supplier wallet needs ADA to pay transaction fees and post bonds. You need at least **5 ADA** to get started.
|
||||||
|
|
||||||
|
When you start the node, it will print your wallet address. Send ADA to that address.
|
||||||
|
|
||||||
|
### 6. Start the node
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
You will see:
|
||||||
|
```
|
||||||
|
Starting Vector Supplier Node 'my-supplier' on port 9000
|
||||||
|
Dashboard: http://localhost:9000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Open the dashboard
|
||||||
|
|
||||||
|
Go to **http://localhost:9000** in your browser. The dashboard shows:
|
||||||
|
|
||||||
|
- Your wallet balance
|
||||||
|
- Total earnings
|
||||||
|
- Number of tasks completed
|
||||||
|
- Task history with full details (prompts, responses, TX hashes)
|
||||||
|
|
||||||
|
## Giving Your URL to Buyers
|
||||||
|
|
||||||
|
Buyers need your URL to send you tasks. If you are running on:
|
||||||
|
|
||||||
|
- **Same machine**: `http://localhost:9000`
|
||||||
|
- **Local network**: `http://YOUR_IP:9000` (find your IP with `ipconfig` on Windows)
|
||||||
|
- **Public internet**: You need to either:
|
||||||
|
- Set up port forwarding on your router (forward port 9000)
|
||||||
|
- Use a service like ngrok: `ngrok http 9000`
|
||||||
|
- Deploy to a cloud server (AWS, DigitalOcean, etc.)
|
||||||
|
|
||||||
|
The buyer adds your URL to their `SUPPLIER_URLS` config and your node appears in their dashboard.
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
supplier/
|
||||||
|
main.py Entry point - starts the web server
|
||||||
|
config.py Loads settings from .env
|
||||||
|
wallet.py Initializes blockchain wallet
|
||||||
|
api.py HTTP API endpoints
|
||||||
|
task_runner.py Orchestrates the full task flow
|
||||||
|
escrow_ops.py On-chain escrow transactions
|
||||||
|
llm.py LLM API client (OpenRouter/Anthropic/OpenAI)
|
||||||
|
storage.py Task history (JSON file)
|
||||||
|
.env Your configuration (secret - do not share)
|
||||||
|
.env.example Template for .env
|
||||||
|
requirements.txt Python dependencies
|
||||||
|
static/
|
||||||
|
dashboard.html Web dashboard UI
|
||||||
|
lib/ Blockchain utilities
|
||||||
|
constants.py Protocol constants
|
||||||
|
chain.py Chain query helpers
|
||||||
|
datum.py CBOR datum builders
|
||||||
|
escrow_tx.py Transaction builder
|
||||||
|
blueprint.py Smart contract loader
|
||||||
|
protocol.py API data models
|
||||||
|
contracts/
|
||||||
|
escrow/
|
||||||
|
plutus.json Compiled smart contract
|
||||||
|
data/
|
||||||
|
tasks.json Task history (auto-created)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Reference
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| VECTOR_OGMIOS_URL | Blockchain node URL | Vector testnet |
|
||||||
|
| VECTOR_SUBMIT_URL | TX submission URL | Vector testnet |
|
||||||
|
| SUPPLIER_MNEMONIC | Your 15-word wallet seed phrase | (required) |
|
||||||
|
| ESCROW_SCRIPT_HASH | Escrow contract hash | Pre-configured |
|
||||||
|
| OPENROUTER_API_KEY | LLM API key from openrouter.ai | (required) |
|
||||||
|
| RESOLVER_ADDRESS | Dispute resolver address | Pre-configured |
|
||||||
|
| SUPPLIER_PORT | HTTP port | 9000 |
|
||||||
|
| SUPPLIER_NAME | Display name | default-supplier |
|
||||||
|
|
||||||
|
## Earnings
|
||||||
|
|
||||||
|
Payment is in **AP3X** (the Vector testnet token). 1 AP3X = 1,000,000 lovelace.
|
||||||
|
|
||||||
|
When a buyer accepts your response, the escrow releases:
|
||||||
|
- Your payment (set by the buyer, typically 3-10 AP3X per task)
|
||||||
|
- Your bond back (1 AP3X)
|
||||||
|
- The buyer's bond (1 AP3X bonus to you)
|
||||||
|
|
||||||
|
If the buyer does NOT accept within the dispute window (10 minutes), you can collect payment automatically.
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- **Never share your .env file** - it contains your wallet seed phrase
|
||||||
|
- **Never share your data/ folder** - it contains task history
|
||||||
|
- The seed phrase controls your wallet funds. Back it up securely.
|
||||||
|
- The node only responds to buyers who have already locked funds on-chain, so you cannot be scammed out of compute.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**"No module named 'vector_agent'"**
|
||||||
|
- Make sure you installed dependencies: `pip install -r requirements.txt`
|
||||||
|
|
||||||
|
**"ogmios_url required"**
|
||||||
|
- Make sure your .env file exists and is in the supplier/ folder
|
||||||
|
|
||||||
|
**"Collateral check failed"**
|
||||||
|
- Your wallet needs more ADA. Send at least 5 ADA to your supplier address.
|
||||||
|
|
||||||
|
**Dashboard shows "Balance: unavailable"**
|
||||||
|
- The Vector testnet node may be temporarily down. Wait and refresh.
|
||||||
|
|
||||||
|
**Task fails with "UTxO not found"**
|
||||||
|
- The buyer's escrow transaction hasn't propagated yet. The node retries automatically for up to 60 seconds.
|
||||||
0
docs/supplier/__init__.py
Normal file
0
docs/supplier/__init__.py
Normal file
186
docs/supplier/api.py
Normal file
186
docs/supplier/api.py
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
"""
|
||||||
|
FastAPI router -- HTTP endpoints for the supplier service.
|
||||||
|
Includes task processing, health checks, and dashboard API.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
|
from lib.protocol import TaskRequest, TaskResponse, SupplierInfo
|
||||||
|
|
||||||
|
from config import get_config
|
||||||
|
from wallet import get_supplier_agent
|
||||||
|
from llm import LLMProvider
|
||||||
|
from task_runner import run_task
|
||||||
|
import storage
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Lazy-initialized singletons
|
||||||
|
_agent = None
|
||||||
|
_llm_provider = None
|
||||||
|
_config = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_agent():
|
||||||
|
global _agent
|
||||||
|
if _agent is None:
|
||||||
|
_agent = get_supplier_agent()
|
||||||
|
return _agent
|
||||||
|
|
||||||
|
|
||||||
|
def _get_llm_provider():
|
||||||
|
global _llm_provider
|
||||||
|
if _llm_provider is None:
|
||||||
|
config = _get_config()
|
||||||
|
_llm_provider = LLMProvider(
|
||||||
|
openrouter_api_key=config.openrouter_api_key,
|
||||||
|
)
|
||||||
|
return _llm_provider
|
||||||
|
|
||||||
|
|
||||||
|
def _get_config():
|
||||||
|
global _config
|
||||||
|
if _config is None:
|
||||||
|
_config = get_config()
|
||||||
|
return _config
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/task", response_model=TaskResponse)
|
||||||
|
async def handle_task(task_request: TaskRequest):
|
||||||
|
"""Receive a task request from a buyer, fulfill it, and settle on-chain."""
|
||||||
|
config = _get_config()
|
||||||
|
agent = _get_agent()
|
||||||
|
llm = _get_llm_provider()
|
||||||
|
|
||||||
|
logger.info(f"Received task {task_request.task_id} -- escrow_tx={task_request.escrow_tx}")
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
response = await run_task(task_request, agent, llm, config)
|
||||||
|
latency_ms = int((time.time() - start_time) * 1000)
|
||||||
|
|
||||||
|
# Save to local storage regardless of success/failure
|
||||||
|
task_record = {
|
||||||
|
"task_id": task_request.task_id,
|
||||||
|
"prompt": task_request.prompt,
|
||||||
|
"buyer_address": task_request.buyer_address,
|
||||||
|
"payment_dfm": task_request.payment_dfm,
|
||||||
|
"escrow_tx": task_request.escrow_tx,
|
||||||
|
"task_hash": task_request.task_hash,
|
||||||
|
"model_preference": task_request.model_preference,
|
||||||
|
"status": response.status,
|
||||||
|
"response": response.response,
|
||||||
|
"result_hash": response.result_hash,
|
||||||
|
"model_used": response.model_used,
|
||||||
|
"input_tokens": response.input_tokens,
|
||||||
|
"output_tokens": response.output_tokens,
|
||||||
|
"claim_tx": response.claim_tx,
|
||||||
|
"submit_tx": response.submit_tx,
|
||||||
|
"error": response.error,
|
||||||
|
"latency_ms": latency_ms,
|
||||||
|
"completed_at": time.time(),
|
||||||
|
}
|
||||||
|
storage.save_task(task_record)
|
||||||
|
|
||||||
|
if response.status == "failed":
|
||||||
|
logger.error(f"Task {task_request.task_id} failed: {response.error}")
|
||||||
|
raise HTTPException(status_code=500, detail=response.error)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def health():
|
||||||
|
"""Health check endpoint."""
|
||||||
|
config = _get_config()
|
||||||
|
try:
|
||||||
|
agent = _get_agent()
|
||||||
|
address = str(agent.address)
|
||||||
|
except Exception:
|
||||||
|
address = "not initialized"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"name": config.name,
|
||||||
|
"address": address,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/info", response_model=SupplierInfo)
|
||||||
|
async def info():
|
||||||
|
"""Return supplier metadata."""
|
||||||
|
config = _get_config()
|
||||||
|
try:
|
||||||
|
agent = _get_agent()
|
||||||
|
address = str(agent.address)
|
||||||
|
except Exception:
|
||||||
|
address = "not initialized"
|
||||||
|
|
||||||
|
return SupplierInfo(
|
||||||
|
name=config.name,
|
||||||
|
address=address,
|
||||||
|
endpoint=f"http://localhost:{config.port}",
|
||||||
|
models=[
|
||||||
|
"nvidia/nemotron-3-super-120b-a12b",
|
||||||
|
"nvidia/nemotron-3-nano-30b-a3b",
|
||||||
|
"anthropic/claude-sonnet-4-6",
|
||||||
|
"anthropic/claude-haiku-4-5-20251001",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/dashboard")
|
||||||
|
async def dashboard():
|
||||||
|
"""Return dashboard data: wallet info, stats, and task history."""
|
||||||
|
config = _get_config()
|
||||||
|
|
||||||
|
# Wallet info
|
||||||
|
wallet_info = {"address": "not initialized", "ada": "0", "lovelace": 0}
|
||||||
|
try:
|
||||||
|
agent = _get_agent()
|
||||||
|
balance = await agent.get_balance()
|
||||||
|
wallet_info = {
|
||||||
|
"address": balance.address,
|
||||||
|
"ada": balance.ada,
|
||||||
|
"lovelace": balance.lovelace,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not fetch balance: {e}")
|
||||||
|
|
||||||
|
# Task history
|
||||||
|
tasks = storage.load_tasks()
|
||||||
|
|
||||||
|
# Compute stats
|
||||||
|
completed_tasks = [t for t in tasks if t.get("status") == "completed"]
|
||||||
|
total_earnings_dfm = sum(t.get("payment_dfm", 0) for t in completed_tasks)
|
||||||
|
tasks_completed = len(completed_tasks)
|
||||||
|
total_tokens = sum(
|
||||||
|
t.get("input_tokens", 0) + t.get("output_tokens", 0)
|
||||||
|
for t in completed_tasks
|
||||||
|
)
|
||||||
|
avg_latency_ms = 0
|
||||||
|
if completed_tasks:
|
||||||
|
latencies = [t.get("latency_ms", 0) for t in completed_tasks if t.get("latency_ms")]
|
||||||
|
avg_latency_ms = int(sum(latencies) / len(latencies)) if latencies else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"wallet": wallet_info,
|
||||||
|
"stats": {
|
||||||
|
"total_earnings_dfm": total_earnings_dfm,
|
||||||
|
"tasks_completed": tasks_completed,
|
||||||
|
"avg_latency_ms": avg_latency_ms,
|
||||||
|
"total_tokens": total_tokens,
|
||||||
|
},
|
||||||
|
"tasks": list(reversed(tasks)), # Most recent first
|
||||||
|
"name": config.name,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/tasks")
|
||||||
|
async def api_tasks():
|
||||||
|
"""Return task history."""
|
||||||
|
tasks = storage.load_tasks()
|
||||||
|
return {"tasks": list(reversed(tasks)), "total": len(tasks)}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user