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)
327 lines
14 KiB
TypeScript
327 lines
14 KiB
TypeScript
/**
|
|
* Adversarial datum cases — M0-D
|
|
*
|
|
* Covers: ARCHITECTURE.md §7.3 adversarial cases testable at the datum layer.
|
|
* Each untestable case is documented with a // TODO M1 comment and reason.
|
|
*
|
|
* Cases covered here:
|
|
* - request_spec_hash / prompt_hash mismatch → distinct CBOR hashes
|
|
* - Submit after deliver_by (datum-level: codec accepts, validator rejects)
|
|
* - Accept/Release/Reclaim timing (datum-level: codec permissive)
|
|
* - Double-submit idempotency (mock-level: re-submit same CBOR is idempotent)
|
|
* - Advert updated mid-flight / spec-lock: two adverts same supplier_pkh,
|
|
* different prices → distinct CBOR hashes
|
|
*
|
|
* Cases deferred to M1 (not testable at M0 datum layer):
|
|
* - Escrow to wrong script: requires chain/script validation code
|
|
* - Supplier claims escrow addressed to other supplier: requires validator
|
|
* - Concurrent escrows to single-slot supplier: requires supplier process code
|
|
* - Supplier status lies (free while working): requires HTTP supplier mock
|
|
*/
|
|
|
|
import { describe, it, expect } from "vitest";
|
|
import { encodeAdvertDatum, decodeAdvertDatum } from "../../packages/shared/src/cbor/AdvertDatum.js";
|
|
import { encodeEscrowDatum, decodeEscrowDatum } from "../../packages/shared/src/cbor/EscrowDatum.js";
|
|
import { MockChainProvider } from "../../packages/shared/src/chain/MockChainProvider.js";
|
|
import type { AdvertDatum } from "../../packages/shared/src/cbor/types.js";
|
|
import type { EscrowDatum } from "../../packages/shared/src/cbor/types.js";
|
|
import type { OutputReference, Utxo } from "../../packages/shared/src/chain/ChainProvider.js";
|
|
|
|
// ─── Base fixtures ────────────────────────────────────────────────────────────
|
|
|
|
const SUPPLIER_PKH = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef01";
|
|
const BUYER_PKH = "1234567890abcdef1234567890abcdef1234567890abcdef12345678";
|
|
const ADVERT_TX_HASH = "b".repeat(64);
|
|
|
|
const BASE_POSTED_AT = 1745500000000;
|
|
const BASE_DELIVER_BY = BASE_POSTED_AT + 60_000;
|
|
const BASE_SUBMITTED_AT = BASE_POSTED_AT + 10_000;
|
|
|
|
function makeBaseAdvert(overrides?: Partial<AdvertDatum>): AdvertDatum {
|
|
return {
|
|
supplier_pkh: SUPPLIER_PKH,
|
|
capability_id: "llm.text.generate.v1",
|
|
model: "qwen2.5:0.5b",
|
|
max_output_tokens: 512,
|
|
max_processing_ms: 60_000,
|
|
price_lovelace: 2_000_000n,
|
|
supplier_bond_lovelace: 1_000_000n,
|
|
buyer_bond_lovelace: 1_000_000n,
|
|
endpoint_url: "https://supplier.example.com/v1",
|
|
detail_uri: "ipfs://QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG",
|
|
detail_hash: "a".repeat(64),
|
|
advertised_at: BASE_POSTED_AT,
|
|
status: "Active",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeBaseEscrow(overrides?: Partial<EscrowDatum>): EscrowDatum {
|
|
return {
|
|
buyer_pkh: BUYER_PKH,
|
|
supplier_pkh: SUPPLIER_PKH,
|
|
advert_ref: { txHash: ADVERT_TX_HASH, index: 0 },
|
|
capability_id: "llm.text.generate.v1",
|
|
request_spec_hash: "c".repeat(64),
|
|
prompt_hash: "d".repeat(64),
|
|
payment_lovelace: 2_000_000n,
|
|
buyer_bond_lovelace: 1_000_000n,
|
|
supplier_bond_lovelace: 1_000_000n,
|
|
deliver_by: BASE_DELIVER_BY,
|
|
posted_at: BASE_POSTED_AT,
|
|
submitted_at: null,
|
|
result_receipt_hash: null,
|
|
state: "Open",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeUtxo(ref: OutputReference): Utxo {
|
|
return {
|
|
ref,
|
|
address: "addr_test1vpqthemefkuvelrqprngush5adstneyesq2a4yh7jrfpheq3azpck",
|
|
lovelace: 2_000_000n,
|
|
assets: {},
|
|
datumHex: null,
|
|
scriptRef: null,
|
|
};
|
|
}
|
|
|
|
// ─── §7.3: request_spec_hash / prompt_hash mismatch ──────────────────────────
|
|
|
|
describe("Adversarial §7.3 — request_spec_hash / prompt_hash mismatch", () => {
|
|
it("escrow with different request_spec_hash produces different CBOR", () => {
|
|
const e1 = makeBaseEscrow({ request_spec_hash: "c".repeat(64) });
|
|
const e2 = makeBaseEscrow({ request_spec_hash: "f".repeat(64) });
|
|
expect(encodeEscrowDatum(e1)).not.toBe(encodeEscrowDatum(e2));
|
|
});
|
|
|
|
it("escrow with different prompt_hash produces different CBOR", () => {
|
|
const e1 = makeBaseEscrow({ prompt_hash: "d".repeat(64) });
|
|
const e2 = makeBaseEscrow({ prompt_hash: "9".repeat(64) });
|
|
expect(encodeEscrowDatum(e1)).not.toBe(encodeEscrowDatum(e2));
|
|
});
|
|
|
|
it("request_spec_hash mismatch is detectable after round-trip", () => {
|
|
const correct = makeBaseEscrow({ request_spec_hash: "c".repeat(64) });
|
|
const tampered = makeBaseEscrow({ request_spec_hash: "f".repeat(64) });
|
|
|
|
const hexCorrect = encodeEscrowDatum(correct);
|
|
const hexTampered = encodeEscrowDatum(tampered);
|
|
|
|
const decodedCorrect = decodeEscrowDatum(hexCorrect);
|
|
const decodedTampered = decodeEscrowDatum(hexTampered);
|
|
|
|
expect(decodedCorrect.request_spec_hash).toBe("c".repeat(64));
|
|
expect(decodedTampered.request_spec_hash).toBe("f".repeat(64));
|
|
expect(decodedCorrect.request_spec_hash).not.toBe(decodedTampered.request_spec_hash);
|
|
});
|
|
|
|
it("prompt_hash mismatch is detectable after round-trip", () => {
|
|
const correct = makeBaseEscrow({ prompt_hash: "d".repeat(64) });
|
|
const tampered = makeBaseEscrow({ prompt_hash: "9".repeat(64) });
|
|
|
|
const decodedCorrect = decodeEscrowDatum(encodeEscrowDatum(correct));
|
|
const decodedTampered = decodeEscrowDatum(encodeEscrowDatum(tampered));
|
|
|
|
expect(decodedCorrect.prompt_hash).not.toBe(decodedTampered.prompt_hash);
|
|
});
|
|
|
|
it("both hashes identical across buyer/supplier encode to same CBOR", () => {
|
|
// When hashes match (happy path) — codec produces identical output
|
|
const buyerEscrow = makeBaseEscrow({
|
|
request_spec_hash: "c".repeat(64),
|
|
prompt_hash: "d".repeat(64),
|
|
});
|
|
const supplierView = makeBaseEscrow({
|
|
request_spec_hash: "c".repeat(64),
|
|
prompt_hash: "d".repeat(64),
|
|
});
|
|
expect(encodeEscrowDatum(buyerEscrow)).toBe(encodeEscrowDatum(supplierView));
|
|
});
|
|
});
|
|
|
|
// ─── §7.3: Submit after deliver_by (datum-level) ─────────────────────────────
|
|
|
|
describe("Adversarial §7.3 — Submit after deliver_by (datum-level: codec permissive)", () => {
|
|
it("datum with submitted_at > deliver_by encodes and decodes without error", () => {
|
|
// The codec does NOT validate timing — that is the validator's responsibility.
|
|
const escrow: EscrowDatum = {
|
|
...makeBaseEscrow(),
|
|
state: "Submitted",
|
|
submitted_at: BASE_DELIVER_BY + 5_000, // 5s after deadline
|
|
result_receipt_hash: "e".repeat(64),
|
|
};
|
|
const hex = encodeEscrowDatum(escrow);
|
|
const decoded = decodeEscrowDatum(hex);
|
|
expect(decoded.submitted_at).toBe(BASE_DELIVER_BY + 5_000);
|
|
expect(decoded.state).toBe("Submitted");
|
|
});
|
|
|
|
it("submitted_at equal to deliver_by encodes and decodes without error", () => {
|
|
const escrow: EscrowDatum = {
|
|
...makeBaseEscrow(),
|
|
state: "Submitted",
|
|
submitted_at: BASE_DELIVER_BY,
|
|
result_receipt_hash: "e".repeat(64),
|
|
};
|
|
const decoded = decodeEscrowDatum(encodeEscrowDatum(escrow));
|
|
expect(decoded.submitted_at).toBe(BASE_DELIVER_BY);
|
|
});
|
|
});
|
|
|
|
// ─── §7.3: Accept timing / Accept after ACCEPT_WINDOW ────────────────────────
|
|
|
|
describe("Adversarial §7.3 — Accept/Release/Reclaim timing (datum-level: codec permissive)", () => {
|
|
it("Accepted datum encodes and decodes regardless of elapsed time since Submitted", () => {
|
|
// Timing of Accept is validator-level; codec is permissive.
|
|
const escrow: EscrowDatum = {
|
|
...makeBaseEscrow(),
|
|
state: "Accepted",
|
|
submitted_at: BASE_SUBMITTED_AT,
|
|
result_receipt_hash: "e".repeat(64),
|
|
};
|
|
const decoded = decodeEscrowDatum(encodeEscrowDatum(escrow));
|
|
expect(decoded.state).toBe("Accepted");
|
|
expect(decoded.submitted_at).toBe(BASE_SUBMITTED_AT);
|
|
});
|
|
|
|
it("Released datum (past ACCEPT_WINDOW) encodes and decodes correctly", () => {
|
|
const escrow: EscrowDatum = {
|
|
...makeBaseEscrow(),
|
|
state: "Released",
|
|
submitted_at: BASE_SUBMITTED_AT,
|
|
result_receipt_hash: "e".repeat(64),
|
|
};
|
|
const decoded = decodeEscrowDatum(encodeEscrowDatum(escrow));
|
|
expect(decoded.state).toBe("Released");
|
|
});
|
|
|
|
it("Reclaimed datum before deliver_by encodes without error (codec permissive — validator rejects)", () => {
|
|
// deliver_by = BASE_DELIVER_BY; we set a reclaim before it — codec must not care.
|
|
const escrow: EscrowDatum = {
|
|
...makeBaseEscrow(),
|
|
state: "Reclaimed",
|
|
// No submitted_at or receipt hash for Reclaimed
|
|
};
|
|
const decoded = decodeEscrowDatum(encodeEscrowDatum(escrow));
|
|
expect(decoded.state).toBe("Reclaimed");
|
|
});
|
|
});
|
|
|
|
// ─── §7.3: Double-submit idempotency (mock-level) ────────────────────────────
|
|
|
|
describe("Adversarial §7.3 — double-submit idempotency", () => {
|
|
it("re-submitting the same escrow CBOR to MockChainProvider is idempotent", async () => {
|
|
const mock = new MockChainProvider({});
|
|
const ref: OutputReference = { txHash: "1".repeat(64), index: 0 };
|
|
mock.seed(makeUtxo(ref));
|
|
|
|
const txHex = MockChainProvider.buildTestTx({ inputs: [ref] });
|
|
const hash1 = await mock.submitTx(txHex);
|
|
const hash2 = await mock.submitTx(txHex);
|
|
|
|
// Same hash returned for identical CBOR
|
|
expect(hash1).toBe(hash2);
|
|
});
|
|
|
|
it("double-submit does not corrupt UTxO state", async () => {
|
|
const mock = new MockChainProvider({});
|
|
const ref: OutputReference = { txHash: "2".repeat(64), index: 0 };
|
|
mock.seed(makeUtxo(ref));
|
|
|
|
const txHex = MockChainProvider.buildTestTx({ inputs: [ref] });
|
|
await mock.submitTx(txHex);
|
|
await mock.submitTx(txHex); // idempotent re-submit
|
|
|
|
// UTxO was spent exactly once — still null after second submit
|
|
expect(await mock.queryUtxo(ref)).toBeNull();
|
|
});
|
|
|
|
it("re-submitting escrow datum CBOR (not spending-tx) is also idempotent", async () => {
|
|
const mock = new MockChainProvider({});
|
|
const escrowHex = encodeEscrowDatum(makeBaseEscrow());
|
|
|
|
// escrowHex is not a spending tx (not JSON-in-hex), so MockChainProvider
|
|
// stores it as an opaque transaction — no UTxO side effects.
|
|
const hash1 = await mock.submitTx(escrowHex);
|
|
const hash2 = await mock.submitTx(escrowHex);
|
|
expect(hash1).toBe(hash2);
|
|
});
|
|
});
|
|
|
|
// ─── §7.3: Advert updated mid-flight / spec-lock ─────────────────────────────
|
|
|
|
describe("Adversarial §7.3 — advert spec-lock (mid-flight update)", () => {
|
|
it("two adverts from same supplier with different prices produce distinct CBOR", () => {
|
|
const advert1 = makeBaseAdvert({ price_lovelace: 2_000_000n });
|
|
const advert2 = makeBaseAdvert({ price_lovelace: 3_000_000n });
|
|
|
|
const hex1 = encodeAdvertDatum(advert1);
|
|
const hex2 = encodeAdvertDatum(advert2);
|
|
|
|
expect(hex1).not.toBe(hex2);
|
|
});
|
|
|
|
it("buyer's advert_ref encodes the specific UTxO the buyer locked onto", () => {
|
|
// Spec-lock: the escrow references the exact ad UTxO (txHash#index).
|
|
// If the supplier posts a new ad with a different price at a new UTxO,
|
|
// the escrow still encodes the OLD advert_ref — codec must preserve it.
|
|
const oldAdvertRef = { txHash: ADVERT_TX_HASH, index: 0 };
|
|
const newAdvertRef = { txHash: "e".repeat(64), index: 0 };
|
|
|
|
const escrowAtOldAd = makeBaseEscrow({ advert_ref: oldAdvertRef });
|
|
const escrowAtNewAd = makeBaseEscrow({ advert_ref: newAdvertRef });
|
|
|
|
const decodedOld = decodeEscrowDatum(encodeEscrowDatum(escrowAtOldAd));
|
|
const decodedNew = decodeEscrowDatum(encodeEscrowDatum(escrowAtNewAd));
|
|
|
|
expect(decodedOld.advert_ref.txHash).toBe(ADVERT_TX_HASH);
|
|
expect(decodedNew.advert_ref.txHash).toBe("e".repeat(64));
|
|
// Different advert refs → different CBOR → spec-lock holds at datum level
|
|
expect(encodeEscrowDatum(escrowAtOldAd)).not.toBe(encodeEscrowDatum(escrowAtNewAd));
|
|
});
|
|
|
|
it("two adverts with same supplier_pkh but different prices have distinct CBOR hashes (minimum baseline)", () => {
|
|
const advertV1 = makeBaseAdvert({
|
|
price_lovelace: 2_000_000n,
|
|
advertised_at: BASE_POSTED_AT,
|
|
});
|
|
const advertV2 = makeBaseAdvert({
|
|
price_lovelace: 2_500_000n,
|
|
advertised_at: BASE_POSTED_AT + 60_000,
|
|
});
|
|
|
|
expect(advertV1.supplier_pkh).toBe(advertV2.supplier_pkh);
|
|
expect(encodeAdvertDatum(advertV1)).not.toBe(encodeAdvertDatum(advertV2));
|
|
});
|
|
|
|
it("retired advert encodes differently from active advert (Update → Retire flow)", () => {
|
|
const active = makeBaseAdvert({ status: "Active" });
|
|
const retired = makeBaseAdvert({ status: "Retired" });
|
|
|
|
expect(encodeAdvertDatum(active)).not.toBe(encodeAdvertDatum(retired));
|
|
expect(decodeAdvertDatum(encodeAdvertDatum(retired)).status).toBe("Retired");
|
|
});
|
|
});
|
|
|
|
// ─── §7.3: Deferred to M1 ─────────────────────────────────────────────────────
|
|
// TODO M1: "Escrow to wrong script" — requires a live script hash and on-chain
|
|
// address derivation to test that the escrow UTxO is locked at the wrong script.
|
|
// Not testable at the datum codec layer.
|
|
//
|
|
// TODO M1: "Supplier claims escrow addressed to other supplier" — requires a
|
|
// validator that checks supplier_pkh == signing key hash. The datum codec
|
|
// is permissive; this constraint is enforced on-chain.
|
|
//
|
|
// TODO M1: "Concurrent escrows to single-slot supplier" — requires a running
|
|
// supplier process with a slot-lock mechanism. Not testable at M0 datum layer.
|
|
//
|
|
// TODO M1: "Supplier status lies (free while actually working)" — requires an
|
|
// HTTP supplier mock that can serve /status while also processing a job.
|
|
// Out of scope for the datum-layer test suite.
|
|
//
|
|
// TODO M1: "Replay of Accept/Release" — requires the validator to track spent
|
|
// UTxO refs. The datum codec itself does not prevent replay; that is
|
|
// enforced by the UTXO model (a spent UTxO cannot be re-spent).
|