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)
257 lines
10 KiB
TypeScript
257 lines
10 KiB
TypeScript
/**
|
|
* cli-post-advert-cli.test.ts — RED phase tests for M1-F-3
|
|
*
|
|
* Tests parseCliArgs(argv, env) — the pure argument-parsing function in
|
|
* supplier/src/cli/post-advert.ts. No subprocess spawning; no real Ogmios.
|
|
*
|
|
* All tests are expected to FAIL (red) until Catherine implements
|
|
* parseCliArgs in M1-F-3-green.
|
|
*
|
|
* Spec: supplier/src/cli/post-advert.ts + ARCHITECTURE.md §4.1.
|
|
*/
|
|
|
|
import { describe, it, expect } from "vitest";
|
|
import { parseCliArgs } from "../../supplier/src/cli/post-advert.js";
|
|
import {
|
|
buildCliEnv,
|
|
SHA256_EMPTY,
|
|
CLI_ADVERT_CAPABILITY_ID,
|
|
CLI_ADVERT_MODEL,
|
|
CLI_ADVERT_MAX_OUTPUT_TOKENS,
|
|
CLI_ADVERT_MAX_PROCESSING_MS,
|
|
CLI_ADVERT_PRICE_LOVELACE,
|
|
CLI_ADVERT_SUPPLIER_BOND_LOVELACE,
|
|
CLI_ADVERT_BUYER_BOND_LOVELACE,
|
|
CLI_ADVERT_ENDPOINT_URL,
|
|
CLI_ADVERT_DETAIL_URI,
|
|
CLI_ADVERT_DETAIL_HASH,
|
|
} from "../fixtures/supplier-side/sample-advert-datum.js";
|
|
import { SUPPLIER_PRIVATE_KEY_HEX } from "../fixtures/supplier-side/wallet-keys.js";
|
|
|
|
// ─── Happy path: full env, no flags ──────────────────────────────────────────
|
|
|
|
describe("parseCliArgs() — happy path: full env no flags", () => {
|
|
it("returns a valid CliConfig when all required env vars are populated", () => {
|
|
const cfg = parseCliArgs([], buildCliEnv());
|
|
expect(cfg.privKeyHex).toBe(SUPPLIER_PRIVATE_KEY_HEX);
|
|
expect(cfg.networkId).toBe(0);
|
|
expect(cfg.capabilityId).toBe(CLI_ADVERT_CAPABILITY_ID);
|
|
expect(cfg.model).toBe(CLI_ADVERT_MODEL);
|
|
expect(cfg.maxOutputTokens).toBe(CLI_ADVERT_MAX_OUTPUT_TOKENS);
|
|
expect(cfg.maxProcessingMs).toBe(CLI_ADVERT_MAX_PROCESSING_MS);
|
|
expect(cfg.priceLovelace).toBe(CLI_ADVERT_PRICE_LOVELACE);
|
|
expect(cfg.supplierBondLovelace).toBe(CLI_ADVERT_SUPPLIER_BOND_LOVELACE);
|
|
expect(cfg.buyerBondLovelace).toBe(CLI_ADVERT_BUYER_BOND_LOVELACE);
|
|
expect(cfg.endpointUrl).toBe(CLI_ADVERT_ENDPOINT_URL);
|
|
expect(cfg.detailUri).toBe(CLI_ADVERT_DETAIL_URI);
|
|
expect(cfg.detailHash).toBe(CLI_ADVERT_DETAIL_HASH);
|
|
expect(cfg.dryRun).toBe(false);
|
|
expect(cfg.useMock).toBe(false);
|
|
expect(cfg.awaitTimeoutMs).toBe(120_000);
|
|
});
|
|
|
|
it("ogmiosUrl comes from env OGMIOS_URL when set", () => {
|
|
const env = { ...buildCliEnv(), OGMIOS_URL: "https://custom.ogmios.example.com" };
|
|
const cfg = parseCliArgs([], env);
|
|
expect(cfg.ogmiosUrl).toBe("https://custom.ogmios.example.com");
|
|
});
|
|
});
|
|
|
|
// ─── --dry-run flag ──────────────────────────────────────────────────────────
|
|
|
|
describe("parseCliArgs() — --dry-run flag", () => {
|
|
it("sets dryRun: true", () => {
|
|
const cfg = parseCliArgs(["--dry-run"], buildCliEnv());
|
|
expect(cfg.dryRun).toBe(true);
|
|
});
|
|
|
|
it("dryRun defaults to false when flag is absent", () => {
|
|
const cfg = parseCliArgs([], buildCliEnv());
|
|
expect(cfg.dryRun).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ─── --mock flag ─────────────────────────────────────────────────────────────
|
|
|
|
describe("parseCliArgs() — --mock flag", () => {
|
|
it("sets useMock: true", () => {
|
|
const cfg = parseCliArgs(["--mock"], buildCliEnv());
|
|
expect(cfg.useMock).toBe(true);
|
|
});
|
|
|
|
it("useMock defaults to false when flag is absent", () => {
|
|
const cfg = parseCliArgs([], buildCliEnv());
|
|
expect(cfg.useMock).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ─── --ogmios-url override ───────────────────────────────────────────────────
|
|
|
|
describe("parseCliArgs() — --ogmios-url flag overrides env", () => {
|
|
it("sets ogmiosUrl to the flag value, ignoring env OGMIOS_URL", () => {
|
|
const env = { ...buildCliEnv(), OGMIOS_URL: "https://env.ogmios.example.com" };
|
|
const cfg = parseCliArgs(["--ogmios-url", "https://flag.ogmios.example.com"], env);
|
|
expect(cfg.ogmiosUrl).toBe("https://flag.ogmios.example.com");
|
|
});
|
|
});
|
|
|
|
// ─── --priv-key override ─────────────────────────────────────────────────────
|
|
|
|
describe("parseCliArgs() — --priv-key flag overrides env", () => {
|
|
it("sets privKeyHex from the flag value", () => {
|
|
// 64-char hex string different from the env value
|
|
const flagPrivKey = "1".repeat(64);
|
|
const cfg = parseCliArgs(["--priv-key", flagPrivKey], buildCliEnv());
|
|
expect(cfg.privKeyHex).toBe(flagPrivKey);
|
|
});
|
|
|
|
it("throws when --priv-key value is not 64 hex chars", () => {
|
|
expect(() =>
|
|
parseCliArgs(["--priv-key", "short"], buildCliEnv()),
|
|
).toThrow();
|
|
});
|
|
});
|
|
|
|
// ─── OGMIOS_URL default for testnet ──────────────────────────────────────────
|
|
|
|
describe("parseCliArgs() — OGMIOS_URL default", () => {
|
|
it("defaults ogmiosUrl to 'https://ogmios.vector.testnet.apexfusion.org' when OGMIOS_URL is absent and NETWORK_ID='0'", () => {
|
|
const env = { ...buildCliEnv() };
|
|
delete env.OGMIOS_URL;
|
|
const cfg = parseCliArgs([], env);
|
|
expect(cfg.ogmiosUrl).toBe("https://ogmios.vector.testnet.apexfusion.org");
|
|
});
|
|
});
|
|
|
|
// ─── Missing required env vars ───────────────────────────────────────────────
|
|
|
|
describe("parseCliArgs() — missing required env vars", () => {
|
|
it("throws when SUPPLIER_PRIV_KEY_HEX is absent", () => {
|
|
const env = { ...buildCliEnv() };
|
|
delete env.SUPPLIER_PRIV_KEY_HEX;
|
|
expect(() => parseCliArgs([], env)).toThrow(/SUPPLIER_PRIV_KEY_HEX/i);
|
|
});
|
|
|
|
it("throws when NETWORK_ID is absent", () => {
|
|
const env = { ...buildCliEnv() };
|
|
delete env.NETWORK_ID;
|
|
expect(() => parseCliArgs([], env)).toThrow(/NETWORK_ID/i);
|
|
});
|
|
|
|
it("throws when ADVERT_CAPABILITY_ID is absent", () => {
|
|
const env = { ...buildCliEnv() };
|
|
delete env.ADVERT_CAPABILITY_ID;
|
|
expect(() => parseCliArgs([], env)).toThrow(/ADVERT_CAPABILITY_ID/i);
|
|
});
|
|
|
|
it("throws when ADVERT_MODEL is absent", () => {
|
|
const env = { ...buildCliEnv() };
|
|
delete env.ADVERT_MODEL;
|
|
expect(() => parseCliArgs([], env)).toThrow(/ADVERT_MODEL/i);
|
|
});
|
|
|
|
it("throws when ADVERT_PRICE_LOVELACE is absent", () => {
|
|
const env = { ...buildCliEnv() };
|
|
delete env.ADVERT_PRICE_LOVELACE;
|
|
expect(() => parseCliArgs([], env)).toThrow(/ADVERT_PRICE_LOVELACE/i);
|
|
});
|
|
|
|
it("throws when ADVERT_ENDPOINT_URL is absent", () => {
|
|
const env = { ...buildCliEnv() };
|
|
delete env.ADVERT_ENDPOINT_URL;
|
|
expect(() => parseCliArgs([], env)).toThrow(/ADVERT_ENDPOINT_URL/i);
|
|
});
|
|
});
|
|
|
|
// ─── Malformed env values ─────────────────────────────────────────────────────
|
|
|
|
describe("parseCliArgs() — malformed env values", () => {
|
|
it("throws when ADVERT_PRICE_LOVELACE is non-numeric", () => {
|
|
expect(() =>
|
|
parseCliArgs([], { ...buildCliEnv(), ADVERT_PRICE_LOVELACE: "not-a-number" }),
|
|
).toThrow();
|
|
});
|
|
|
|
it("throws when NETWORK_ID is '2'", () => {
|
|
expect(() =>
|
|
parseCliArgs([], { ...buildCliEnv(), NETWORK_ID: "2" }),
|
|
).toThrow();
|
|
});
|
|
|
|
it("throws when NETWORK_ID is 'mainnet' (not a numeric 0/1)", () => {
|
|
expect(() =>
|
|
parseCliArgs([], { ...buildCliEnv(), NETWORK_ID: "mainnet" }),
|
|
).toThrow();
|
|
});
|
|
});
|
|
|
|
// ─── --await-timeout-ms ───────────────────────────────────────────────────────
|
|
|
|
describe("parseCliArgs() — --await-timeout-ms flag", () => {
|
|
it("parses --await-timeout-ms as integer ms", () => {
|
|
const cfg = parseCliArgs(["--await-timeout-ms", "60000"], buildCliEnv());
|
|
expect(cfg.awaitTimeoutMs).toBe(60_000);
|
|
});
|
|
|
|
it("defaults awaitTimeoutMs to 120_000 when flag is absent", () => {
|
|
const cfg = parseCliArgs([], buildCliEnv());
|
|
expect(cfg.awaitTimeoutMs).toBe(120_000);
|
|
});
|
|
});
|
|
|
|
// ─── Unknown flag ─────────────────────────────────────────────────────────────
|
|
|
|
describe("parseCliArgs() — unknown flags", () => {
|
|
it("throws with 'unknown flag: --unknown-flag' when an unrecognised flag is passed", () => {
|
|
expect(() =>
|
|
parseCliArgs(["--unknown-flag"], buildCliEnv()),
|
|
).toThrow(/unknown flag: --unknown-flag/);
|
|
});
|
|
|
|
it("throws on any unrecognised --flag regardless of position", () => {
|
|
expect(() =>
|
|
parseCliArgs(["--dry-run", "--bogus"], buildCliEnv()),
|
|
).toThrow(/unknown flag: --bogus/);
|
|
});
|
|
});
|
|
|
|
// ─── ADVERT_DETAIL_HASH default ───────────────────────────────────────────────
|
|
|
|
describe("parseCliArgs() — ADVERT_DETAIL_HASH default", () => {
|
|
it("defaults detailHash to sha256('') when ADVERT_DETAIL_HASH is absent", () => {
|
|
const env = { ...buildCliEnv() };
|
|
delete env.ADVERT_DETAIL_HASH;
|
|
const cfg = parseCliArgs([], env);
|
|
expect(cfg.detailHash).toBe(SHA256_EMPTY);
|
|
});
|
|
|
|
it("defaults detailHash to sha256('') when ADVERT_DETAIL_HASH is empty string", () => {
|
|
const cfg = parseCliArgs([], { ...buildCliEnv(), ADVERT_DETAIL_HASH: "" });
|
|
expect(cfg.detailHash).toBe(SHA256_EMPTY);
|
|
});
|
|
|
|
it("uses supplied detailHash when it is 64 hex chars", () => {
|
|
const hash = "b".repeat(64);
|
|
const cfg = parseCliArgs([], { ...buildCliEnv(), ADVERT_DETAIL_HASH: hash });
|
|
expect(cfg.detailHash).toBe(hash);
|
|
});
|
|
});
|
|
|
|
// ─── Bond defaults ────────────────────────────────────────────────────────────
|
|
|
|
describe("parseCliArgs() — bond defaults", () => {
|
|
it("defaults supplierBondLovelace to 1_000_000n when ADVERT_SUPPLIER_BOND_LOVELACE is absent", () => {
|
|
const env = { ...buildCliEnv() };
|
|
delete env.ADVERT_SUPPLIER_BOND_LOVELACE;
|
|
const cfg = parseCliArgs([], env);
|
|
expect(cfg.supplierBondLovelace).toBe(1_000_000n);
|
|
});
|
|
|
|
it("defaults buyerBondLovelace to 1_000_000n when ADVERT_BUYER_BOND_LOVELACE is absent", () => {
|
|
const env = { ...buildCliEnv() };
|
|
delete env.ADVERT_BUYER_BOND_LOVELACE;
|
|
const cfg = parseCliArgs([], env);
|
|
expect(cfg.buyerBondLovelace).toBe(1_000_000n);
|
|
});
|
|
});
|