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)
322 lines
12 KiB
TypeScript
322 lines
12 KiB
TypeScript
/**
|
|
* MockChainProvider-specific behavior tests — M0-B RED phase
|
|
*
|
|
* Tests the test-only extensions NOT present on the ChainProvider interface:
|
|
* - advanceSlot(n)
|
|
* - seed(utxo)
|
|
* - custom evaluator injection
|
|
* - UTxO spending on submitTx
|
|
* - concurrency safety on reads
|
|
* - idempotent submitTx
|
|
*
|
|
* These tests MUST FAIL until Catherine implements MockChainProvider in M0-C.
|
|
*
|
|
* Convention for synthetic tx CBOR used in spending tests:
|
|
* MockChainProvider parses a simple JSON-in-hex encoding for test-only txs:
|
|
* hex( JSON.stringify({ inputs: [{ txHash, index }], ... }) )
|
|
* This is documented here so Catherine implements the same convention.
|
|
* Production code NEVER uses this — it only applies to MockChainProvider
|
|
* test interaction. The exact encoding must be agreed between these tests
|
|
* and the MockChainProvider implementation.
|
|
*
|
|
* Encoding helper: textToHex / hexToText below.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach } from "vitest";
|
|
import { MockChainProvider } from "../../packages/shared/src/chain/MockChainProvider.js";
|
|
import type { Utxo, OutputReference } from "../../packages/shared/src/chain/ChainProvider.js";
|
|
|
|
// ─── Fixture Helpers ──────────────────────────────────────────────────────────
|
|
|
|
function textToHex(s: string): string {
|
|
return Array.from(new TextEncoder().encode(s))
|
|
.map((b) => b.toString(16).padStart(2, "0"))
|
|
.join("");
|
|
}
|
|
|
|
/** Build a synthetic Utxo for seeding. Address is bech32 per design decision #4. */
|
|
function makeUtxo(ref: OutputReference, address?: string): Utxo {
|
|
return {
|
|
ref,
|
|
address: address ?? "addr_test1vpqthemefkuvelrqprngush5adstneyesq2a4yh7jrfpheq3azpck",
|
|
lovelace: 2_000_000n,
|
|
assets: {},
|
|
datumHex: null,
|
|
scriptRef: null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build a synthetic spending-tx CBOR hex that MockChainProvider can parse.
|
|
*
|
|
* Convention (MUST match MockChainProvider M0-C implementation):
|
|
* hex(JSON.stringify({ inputs: [{ txHash: string, index: number }, ...] }))
|
|
*
|
|
* The mock does NOT validate the tx structure beyond inputs — it just removes
|
|
* any seeded UTxOs that appear in `inputs`.
|
|
*/
|
|
function buildSpendingTxHex(inputs: OutputReference[]): string {
|
|
return textToHex(JSON.stringify({ inputs }));
|
|
}
|
|
|
|
// ─── advanceSlot() ────────────────────────────────────────────────────────────
|
|
|
|
describe("MockChainProvider.advanceSlot()", () => {
|
|
it("advances tip() by exactly n slots", async () => {
|
|
const mock = new MockChainProvider({});
|
|
const before = await mock.tip();
|
|
mock.advanceSlot(50);
|
|
const after = await mock.tip();
|
|
expect(after).toBe(before + 50);
|
|
});
|
|
|
|
it("advances tip() by exactly n=1", async () => {
|
|
const mock = new MockChainProvider({});
|
|
const before = await mock.tip();
|
|
mock.advanceSlot(1);
|
|
const after = await mock.tip();
|
|
expect(after).toBe(before + 1);
|
|
});
|
|
|
|
it("advances tip() cumulatively across multiple calls", async () => {
|
|
const mock = new MockChainProvider({});
|
|
const before = await mock.tip();
|
|
mock.advanceSlot(10);
|
|
mock.advanceSlot(20);
|
|
mock.advanceSlot(5);
|
|
const after = await mock.tip();
|
|
expect(after).toBe(before + 35);
|
|
});
|
|
|
|
it("does NOT auto-advance slot between read calls", async () => {
|
|
const mock = new MockChainProvider({});
|
|
const t1 = await mock.tip();
|
|
const t2 = await mock.tip();
|
|
expect(t1).toBe(t2);
|
|
});
|
|
|
|
it("n=0 leaves tip unchanged", async () => {
|
|
const mock = new MockChainProvider({});
|
|
const before = await mock.tip();
|
|
mock.advanceSlot(0);
|
|
const after = await mock.tip();
|
|
expect(after).toBe(before);
|
|
});
|
|
});
|
|
|
|
// ─── Custom evaluator injection ───────────────────────────────────────────────
|
|
|
|
describe("MockChainProvider — custom evaluator injection", () => {
|
|
it("default evaluator returns {ok: true} for any input", async () => {
|
|
const mock = new MockChainProvider({});
|
|
const result = await mock.evaluateTx("deadbeef");
|
|
expect(result.ok).toBe(true);
|
|
});
|
|
|
|
it("injected evaluator returning failure is respected", async () => {
|
|
const mock = new MockChainProvider({
|
|
evaluator: () => ({ ok: false, error: "nope" }),
|
|
});
|
|
const result = await mock.evaluateTx("deadbeef");
|
|
expect(result.ok).toBe(false);
|
|
expect(result.error).toBe("nope");
|
|
});
|
|
|
|
it("injected evaluator is called with the exact txCborHex passed in", async () => {
|
|
const captured: string[] = [];
|
|
const mock = new MockChainProvider({
|
|
evaluator: (hex) => {
|
|
captured.push(hex);
|
|
return { ok: true };
|
|
},
|
|
});
|
|
await mock.evaluateTx("cafecafe");
|
|
expect(captured).toEqual(["cafecafe"]);
|
|
});
|
|
|
|
it("injected evaluator can return cost alongside ok=true", async () => {
|
|
const mock = new MockChainProvider({
|
|
evaluator: () => ({ ok: true, cost: { memory: 512, steps: 1024 } }),
|
|
});
|
|
const result = await mock.evaluateTx("00");
|
|
expect(result.ok).toBe(true);
|
|
expect(result.cost?.memory).toBe(512);
|
|
expect(result.cost?.steps).toBe(1024);
|
|
});
|
|
});
|
|
|
|
// ─── seed() and UTxO queries ──────────────────────────────────────────────────
|
|
|
|
describe("MockChainProvider.seed()", () => {
|
|
it("after seeding, queryUtxo returns the seeded UTxO", async () => {
|
|
const mock = new MockChainProvider({});
|
|
const ref: OutputReference = { txHash: "a".repeat(64), index: 0 };
|
|
const utxo = makeUtxo(ref);
|
|
mock.seed(utxo);
|
|
|
|
const result = await mock.queryUtxo(ref);
|
|
expect(result).not.toBeNull();
|
|
expect(result?.ref.txHash).toBe(ref.txHash);
|
|
expect(result?.ref.index).toBe(ref.index);
|
|
});
|
|
|
|
it("after seeding, queryUtxosByAddress returns the seeded UTxO", async () => {
|
|
const mock = new MockChainProvider({});
|
|
const address = "addr_test1vz2fs4y3q9ekzs7zrr5xkjhq5dzl2wkxqh5hqz6v3l9kqaqklyp2";
|
|
const ref: OutputReference = { txHash: "b".repeat(64), index: 1 };
|
|
const utxo = makeUtxo(ref, address);
|
|
mock.seed(utxo);
|
|
|
|
const results = await mock.queryUtxosByAddress(address);
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0].ref.txHash).toBe(ref.txHash);
|
|
});
|
|
|
|
it("queryUtxo returns null for a ref with same txHash but different index", async () => {
|
|
const mock = new MockChainProvider({});
|
|
const ref: OutputReference = { txHash: "c".repeat(64), index: 0 };
|
|
mock.seed(makeUtxo(ref));
|
|
|
|
const result = await mock.queryUtxo({ txHash: ref.txHash, index: 1 });
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("multiple seeds at the same address are all returned", async () => {
|
|
const mock = new MockChainProvider({});
|
|
const address = "addr_test1vpqthemefkuvelrqprngush5adstneyesq2a4yh7jrfpheq3azpck";
|
|
mock.seed(makeUtxo({ txHash: "d".repeat(64), index: 0 }, address));
|
|
mock.seed(makeUtxo({ txHash: "e".repeat(64), index: 0 }, address));
|
|
|
|
const results = await mock.queryUtxosByAddress(address);
|
|
expect(results).toHaveLength(2);
|
|
});
|
|
|
|
it("seed preserves all UTxO fields (lovelace, assets, datumHex, scriptRef)", async () => {
|
|
const mock = new MockChainProvider({});
|
|
const ref: OutputReference = { txHash: "f".repeat(64), index: 2 };
|
|
const utxo: Utxo = {
|
|
ref,
|
|
address: "addr_test1vpqthemefkuvelrqprngush5adstneyesq2a4yh7jrfpheq3azpck",
|
|
lovelace: 5_000_000n,
|
|
assets: { abcdef123456: 1n },
|
|
datumHex: "deadbeef",
|
|
scriptRef: null,
|
|
};
|
|
mock.seed(utxo);
|
|
|
|
const result = await mock.queryUtxo(ref);
|
|
expect(result?.lovelace).toBe(5_000_000n);
|
|
expect(result?.assets).toEqual({ abcdef123456: 1n });
|
|
expect(result?.datumHex).toBe("deadbeef");
|
|
expect(result?.scriptRef).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ─── UTxO spending via submitTx ───────────────────────────────────────────────
|
|
|
|
describe("MockChainProvider — UTxO spending via submitTx", () => {
|
|
it("after submitting a tx that consumes a seeded UTxO, queryUtxo returns null", async () => {
|
|
const mock = new MockChainProvider({});
|
|
const ref: OutputReference = { txHash: "1".repeat(64), index: 0 };
|
|
mock.seed(makeUtxo(ref));
|
|
|
|
// Verify UTxO is present before spending
|
|
expect(await mock.queryUtxo(ref)).not.toBeNull();
|
|
|
|
// Submit a tx that spends this UTxO
|
|
const spendHex = buildSpendingTxHex([ref]);
|
|
await mock.submitTx(spendHex);
|
|
|
|
// Now it should be gone
|
|
expect(await mock.queryUtxo(ref)).toBeNull();
|
|
});
|
|
|
|
it("spending a UTxO removes it from queryUtxosByAddress as well", async () => {
|
|
const mock = new MockChainProvider({});
|
|
const address = "addr_test1vz2fs4y3q9ekzs7zrr5xkjhq5dzl2wkxqh5hqz6v3l9kqaqklyp2";
|
|
const ref: OutputReference = { txHash: "2".repeat(64), index: 0 };
|
|
mock.seed(makeUtxo(ref, address));
|
|
|
|
await mock.submitTx(buildSpendingTxHex([ref]));
|
|
|
|
const results = await mock.queryUtxosByAddress(address);
|
|
expect(results).toHaveLength(0);
|
|
});
|
|
|
|
it("spending only removes the specific input ref, not unrelated UTxOs", async () => {
|
|
const mock = new MockChainProvider({});
|
|
const address = "addr_test1vpqthemefkuvelrqprngush5adstneyesq2a4yh7jrfpheq3azpck";
|
|
const ref1: OutputReference = { txHash: "3".repeat(64), index: 0 };
|
|
const ref2: OutputReference = { txHash: "4".repeat(64), index: 0 };
|
|
mock.seed(makeUtxo(ref1, address));
|
|
mock.seed(makeUtxo(ref2, address));
|
|
|
|
await mock.submitTx(buildSpendingTxHex([ref1]));
|
|
|
|
expect(await mock.queryUtxo(ref1)).toBeNull();
|
|
expect(await mock.queryUtxo(ref2)).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
// ─── Concurrency ─────────────────────────────────────────────────────────────
|
|
|
|
describe("MockChainProvider — concurrency", () => {
|
|
it("two parallel queryUtxo calls return consistent results", async () => {
|
|
const mock = new MockChainProvider({});
|
|
const ref: OutputReference = { txHash: "5".repeat(64), index: 0 };
|
|
mock.seed(makeUtxo(ref));
|
|
|
|
// Fire both queries without awaiting individually
|
|
const [r1, r2] = await Promise.all([
|
|
mock.queryUtxo(ref),
|
|
mock.queryUtxo(ref),
|
|
]);
|
|
expect(r1).not.toBeNull();
|
|
expect(r2).not.toBeNull();
|
|
expect(r1?.ref.txHash).toBe(r2?.ref.txHash);
|
|
});
|
|
|
|
it("ten parallel queryUtxosByAddress calls all return the same result", async () => {
|
|
const mock = new MockChainProvider({});
|
|
const address = "addr_test1vz2fs4y3q9ekzs7zrr5xkjhq5dzl2wkxqh5hqz6v3l9kqaqklyp2";
|
|
mock.seed(makeUtxo({ txHash: "6".repeat(64), index: 0 }, address));
|
|
|
|
const results = await Promise.all(
|
|
Array.from({ length: 10 }, () => mock.queryUtxosByAddress(address)),
|
|
);
|
|
|
|
for (const r of results) {
|
|
expect(r).toHaveLength(1);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── submitTx idempotency ─────────────────────────────────────────────────────
|
|
|
|
describe("MockChainProvider — submitTx idempotency", () => {
|
|
it("re-submitting identical CBOR returns the same txHash", async () => {
|
|
const mock = new MockChainProvider({});
|
|
const cbor = "aabbccdd11223344";
|
|
const hash1 = await mock.submitTx(cbor);
|
|
const hash2 = await mock.submitTx(cbor);
|
|
expect(hash1).toBe(hash2);
|
|
});
|
|
|
|
it("re-submitting identical CBOR does not double-register in known-tx set", async () => {
|
|
const mock = new MockChainProvider({});
|
|
const cbor = "11223344aabbccdd";
|
|
const txHash = await mock.submitTx(cbor);
|
|
await mock.submitTx(cbor); // idempotent re-submit
|
|
|
|
// awaitTx should still resolve in one shot (no error from duplicate)
|
|
await expect(mock.awaitTx(txHash, 200)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("different CBOR inputs produce different txHashes", async () => {
|
|
const mock = new MockChainProvider({});
|
|
const h1 = await mock.submitTx("aabb");
|
|
const h2 = await mock.submitTx("ccdd");
|
|
expect(h1).not.toBe(h2);
|
|
});
|
|
});
|