Files
agents-marketplace/buyer/src/cli/accept.ts
jelisaveta-talbot cbab81efbd 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)
2026-05-06 13:27:48 +02:00

324 lines
10 KiB
TypeScript

/**
* 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);
});
}