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)
148 lines
6.3 KiB
TypeScript
148 lines
6.3 KiB
TypeScript
/**
|
|
* MockChainProvider.buildTestTx round-trip equivalence — M0-D
|
|
*
|
|
* Verifies that buildTestTx (static helper) and the inline buildSpendingTxHex
|
|
* convention used in mock-chain-provider.test.ts produce hex strings that
|
|
* MockChainProvider interprets identically — i.e. both remove the same UTxOs
|
|
* on submitTx.
|
|
*
|
|
* This test is intentionally a direct, standalone test of buildTestTx.
|
|
* The existing mock-chain-provider.test.ts exercises spending only via
|
|
* the inline helper; this suite verifies the static method produces the
|
|
* same round-trip behaviour.
|
|
*/
|
|
|
|
import { describe, it, expect } 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 makeUtxo(ref: OutputReference, address?: string): Utxo {
|
|
return {
|
|
ref,
|
|
address: address ?? "addr_test1vpqthemefkuvelrqprngush5adstneyesq2a4yh7jrfpheq3azpck",
|
|
lovelace: 2_000_000n,
|
|
assets: {},
|
|
datumHex: null,
|
|
scriptRef: null,
|
|
};
|
|
}
|
|
|
|
/** Inline convention from mock-chain-provider.test.ts (the reference implementation). */
|
|
function buildSpendingTxHex(inputs: OutputReference[]): string {
|
|
return Array.from(new TextEncoder().encode(JSON.stringify({ inputs })))
|
|
.map((b) => b.toString(16).padStart(2, "0"))
|
|
.join("");
|
|
}
|
|
|
|
// ─── Static buildTestTx equivalence ──────────────────────────────────────────
|
|
|
|
describe("MockChainProvider.buildTestTx — static helper equivalence", () => {
|
|
it("buildTestTx with inputs produces the same hex as the inline buildSpendingTxHex convention", () => {
|
|
const ref: OutputReference = { txHash: "a".repeat(64), index: 0 };
|
|
const fromStatic = MockChainProvider.buildTestTx({ inputs: [ref] });
|
|
const fromInline = buildSpendingTxHex([ref]);
|
|
expect(fromStatic).toBe(fromInline);
|
|
});
|
|
|
|
it("buildTestTx hex is non-empty and lowercase hex", () => {
|
|
const ref: OutputReference = { txHash: "b".repeat(64), index: 1 };
|
|
const hex = MockChainProvider.buildTestTx({ inputs: [ref] });
|
|
expect(hex.length).toBeGreaterThan(0);
|
|
expect(hex).toMatch(/^[0-9a-f]+$/);
|
|
});
|
|
|
|
it("buildTestTx is deterministic (same inputs → same hex)", () => {
|
|
const ref: OutputReference = { txHash: "c".repeat(64), index: 2 };
|
|
const h1 = MockChainProvider.buildTestTx({ inputs: [ref] });
|
|
const h2 = MockChainProvider.buildTestTx({ inputs: [ref] });
|
|
expect(h1).toBe(h2);
|
|
});
|
|
|
|
it("buildTestTx with different inputs produces different hex", () => {
|
|
const ref1: OutputReference = { txHash: "d".repeat(64), index: 0 };
|
|
const ref2: OutputReference = { txHash: "e".repeat(64), index: 0 };
|
|
const h1 = MockChainProvider.buildTestTx({ inputs: [ref1] });
|
|
const h2 = MockChainProvider.buildTestTx({ inputs: [ref2] });
|
|
expect(h1).not.toBe(h2);
|
|
});
|
|
});
|
|
|
|
// ─── Round-trip equivalence: both helpers produce tx the mock understands ─────
|
|
|
|
describe("MockChainProvider — buildTestTx vs inline helper round-trip equivalence", () => {
|
|
it("UTxO spent via buildTestTx is removed — identical to inline helper behaviour", async () => {
|
|
const ref: OutputReference = { txHash: "f".repeat(64), index: 0 };
|
|
|
|
// Provider A uses the static helper
|
|
const mockA = new MockChainProvider({});
|
|
mockA.seed(makeUtxo(ref));
|
|
await mockA.submitTx(MockChainProvider.buildTestTx({ inputs: [ref] }));
|
|
const resultA = await mockA.queryUtxo(ref);
|
|
|
|
// Provider B uses the inline convention
|
|
const mockB = new MockChainProvider({});
|
|
mockB.seed(makeUtxo(ref));
|
|
await mockB.submitTx(buildSpendingTxHex([ref]));
|
|
const resultB = await mockB.queryUtxo(ref);
|
|
|
|
// Both should have consumed the UTxO
|
|
expect(resultA).toBeNull();
|
|
expect(resultB).toBeNull();
|
|
});
|
|
|
|
it("the hex produced by both helpers is identical (same JSON encoding)", () => {
|
|
const ref: OutputReference = { txHash: "1".repeat(64), index: 3 };
|
|
const staticHex = MockChainProvider.buildTestTx({ inputs: [ref] });
|
|
const inlineHex = buildSpendingTxHex([ref]);
|
|
expect(staticHex).toBe(inlineHex);
|
|
});
|
|
|
|
it("spending multiple UTxOs via buildTestTx removes exactly those UTxOs", async () => {
|
|
const mock = new MockChainProvider({});
|
|
const address = "addr_test1vpqthemefkuvelrqprngush5adstneyesq2a4yh7jrfpheq3azpck";
|
|
const ref1: OutputReference = { txHash: "2".repeat(64), index: 0 };
|
|
const ref2: OutputReference = { txHash: "3".repeat(64), index: 0 };
|
|
const ref3: OutputReference = { txHash: "4".repeat(64), index: 0 };
|
|
|
|
mock.seed(makeUtxo(ref1, address));
|
|
mock.seed(makeUtxo(ref2, address));
|
|
mock.seed(makeUtxo(ref3, address));
|
|
|
|
// Spend ref1 and ref2 via buildTestTx
|
|
const tx = MockChainProvider.buildTestTx({ inputs: [ref1, ref2] });
|
|
await mock.submitTx(tx);
|
|
|
|
expect(await mock.queryUtxo(ref1)).toBeNull();
|
|
expect(await mock.queryUtxo(ref2)).toBeNull();
|
|
expect(await mock.queryUtxo(ref3)).not.toBeNull();
|
|
});
|
|
|
|
it("buildTestTx with only inputs field is accepted (outputs field is optional — no crash)", async () => {
|
|
const mock = new MockChainProvider({});
|
|
const ref: OutputReference = { txHash: "5".repeat(64), index: 0 };
|
|
mock.seed(makeUtxo(ref));
|
|
|
|
// Shape without optional outputs — MockChainProvider must not crash
|
|
const tx = MockChainProvider.buildTestTx({ inputs: [ref] });
|
|
await expect(mock.submitTx(tx)).resolves.toMatch(/^[0-9a-f]{64}$/);
|
|
expect(await mock.queryUtxo(ref)).toBeNull();
|
|
});
|
|
|
|
it("submitTx with identical buildTestTx hex is idempotent (does not double-spend)", async () => {
|
|
const mock = new MockChainProvider({});
|
|
const ref: OutputReference = { txHash: "7".repeat(64), index: 0 };
|
|
mock.seed(makeUtxo(ref));
|
|
|
|
const tx = MockChainProvider.buildTestTx({ inputs: [ref] });
|
|
const hash1 = await mock.submitTx(tx);
|
|
const hash2 = await mock.submitTx(tx);
|
|
|
|
// Same hash returned — idempotent
|
|
expect(hash1).toBe(hash2);
|
|
// UTxO was spent on the first submit, still null after second
|
|
expect(await mock.queryUtxo(ref)).toBeNull();
|
|
});
|
|
});
|