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)
304 lines
12 KiB
TypeScript
304 lines
12 KiB
TypeScript
/**
|
|
* EscrowDatum CBOR Round-trip Tests — M0-B RED phase
|
|
*
|
|
* Uses cbor-x with the Plutus Tag extension (see apex-dashboard cbor-decoder.ts).
|
|
* Verifies encodeEscrowDatum / decodeEscrowDatum against realistic fixture values
|
|
* from ARCHITECTURE.md §4.2.
|
|
*
|
|
* These tests MUST FAIL until Catherine implements encodeEscrowDatum /
|
|
* decodeEscrowDatum in M0-C.
|
|
*
|
|
* CBOR encoding convention (must match implementation):
|
|
* EscrowDatum → Plutus Constr0 (tag 121) with 14 fields in declaration order.
|
|
* Fields:
|
|
* 0: buyer_pkh — Uint8Array (28 bytes, raw)
|
|
* 1: supplier_pkh — Uint8Array (28 bytes, raw)
|
|
* 2: advert_ref — Plutus OutputReference = Constr0[txHash:bytes, index:int]
|
|
* i.e. Tag(121, [Uint8Array(32), int])
|
|
* 3: capability_id — Uint8Array (UTF-8)
|
|
* 4: request_spec_hash — Uint8Array (32 bytes, raw)
|
|
* 5: prompt_hash — Uint8Array (32 bytes, raw)
|
|
* 6: payment_lovelace — bigint
|
|
* 7: buyer_bond_lovelace — bigint
|
|
* 8: supplier_bond_lovelace — bigint
|
|
* 9: deliver_by — number (int, POSIX ms)
|
|
* 10: posted_at — number (int, POSIX ms)
|
|
* 11: submitted_at — Option: Tag(122, []) = None; Tag(121, [int]) = Some
|
|
* 12: result_receipt_hash — Option: Tag(122, []) = None; Tag(121, [bytes]) = Some
|
|
* 13: state — EscrowState tag:
|
|
* Open=121, Claimed=122, Submitted=123,
|
|
* Accepted=124, Reclaimed=125, Released=126
|
|
* each as Tag(stateTag, [])
|
|
*
|
|
* Reference for advert_ref OutputReference shape:
|
|
* apex-dashboard/server/cbor-decoder.ts:257-260
|
|
* cbor2.CBORTag(121, [bytes.fromhex(claim_txid), int(claim_idx)])
|
|
*/
|
|
|
|
import { describe, it, expect } from "vitest";
|
|
import { encodeEscrowDatum, decodeEscrowDatum } from "../../packages/shared/src/cbor/EscrowDatum.js";
|
|
import type { EscrowDatum, EscrowState } from "../../packages/shared/src/cbor/types.js";
|
|
|
|
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
|
|
|
const BUYER_PKH = "1234567890abcdef1234567890abcdef1234567890abcdef12345678"; // 28-byte hex
|
|
const SUPPLIER_PKH = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef01"; // 28-byte hex
|
|
const ADVERT_TX_HASH = "b".repeat(64); // 32-byte hex
|
|
const REQUEST_HASH = "c".repeat(64); // 32-byte hex
|
|
const PROMPT_HASH = "d".repeat(64); // 32-byte hex
|
|
const RECEIPT_HASH = "e".repeat(64); // 32-byte hex
|
|
|
|
/** Base Open escrow with no submitted fields set. */
|
|
function makeOpenEscrow(): 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: REQUEST_HASH,
|
|
prompt_hash: PROMPT_HASH,
|
|
payment_lovelace: 2_000_000n,
|
|
buyer_bond_lovelace: 1_000_000n,
|
|
supplier_bond_lovelace: 1_000_000n,
|
|
deliver_by: 1745500060000,
|
|
posted_at: 1745500000000,
|
|
submitted_at: null,
|
|
result_receipt_hash: null,
|
|
state: "Open",
|
|
};
|
|
}
|
|
|
|
function makeEscrowWithState(state: EscrowState): EscrowDatum {
|
|
const base = makeOpenEscrow();
|
|
if (state === "Submitted" || state === "Accepted" || state === "Released") {
|
|
return {
|
|
...base,
|
|
submitted_at: 1745500030000,
|
|
result_receipt_hash: RECEIPT_HASH,
|
|
state,
|
|
};
|
|
}
|
|
return { ...base, state };
|
|
}
|
|
|
|
// ─── Round-trip tests ─────────────────────────────────────────────────────────
|
|
|
|
describe("EscrowDatum CBOR round-trip — Open state", () => {
|
|
it("encode then decode returns a deep-equal datum", () => {
|
|
const escrow = makeOpenEscrow();
|
|
const hex = encodeEscrowDatum(escrow);
|
|
const decoded = decodeEscrowDatum(hex);
|
|
expect(decoded).toEqual(escrow);
|
|
});
|
|
|
|
it("encoded result is a non-empty lowercase hex string", () => {
|
|
const hex = encodeEscrowDatum(makeOpenEscrow());
|
|
expect(typeof hex).toBe("string");
|
|
expect(hex.length).toBeGreaterThan(0);
|
|
expect(hex).toMatch(/^[0-9a-f]+$/);
|
|
});
|
|
|
|
it("buyer_pkh round-trips as 28-byte lowercase hex", () => {
|
|
const hex = encodeEscrowDatum(makeOpenEscrow());
|
|
const decoded = decodeEscrowDatum(hex);
|
|
expect(decoded.buyer_pkh).toBe(BUYER_PKH);
|
|
expect(decoded.buyer_pkh).toHaveLength(56);
|
|
});
|
|
|
|
it("supplier_pkh round-trips correctly", () => {
|
|
const hex = encodeEscrowDatum(makeOpenEscrow());
|
|
const decoded = decodeEscrowDatum(hex);
|
|
expect(decoded.supplier_pkh).toBe(SUPPLIER_PKH);
|
|
});
|
|
|
|
it("capability_id round-trips correctly", () => {
|
|
const hex = encodeEscrowDatum(makeOpenEscrow());
|
|
const decoded = decodeEscrowDatum(hex);
|
|
expect(decoded.capability_id).toBe("llm.text.generate.v1");
|
|
});
|
|
|
|
it("request_spec_hash round-trips as 32-byte hex", () => {
|
|
const hex = encodeEscrowDatum(makeOpenEscrow());
|
|
const decoded = decodeEscrowDatum(hex);
|
|
expect(decoded.request_spec_hash).toBe(REQUEST_HASH);
|
|
expect(decoded.request_spec_hash).toHaveLength(64);
|
|
});
|
|
|
|
it("prompt_hash round-trips as 32-byte hex", () => {
|
|
const hex = encodeEscrowDatum(makeOpenEscrow());
|
|
const decoded = decodeEscrowDatum(hex);
|
|
expect(decoded.prompt_hash).toBe(PROMPT_HASH);
|
|
expect(decoded.prompt_hash).toHaveLength(64);
|
|
});
|
|
|
|
it("payment_lovelace round-trips as bigint", () => {
|
|
const hex = encodeEscrowDatum(makeOpenEscrow());
|
|
const decoded = decodeEscrowDatum(hex);
|
|
expect(decoded.payment_lovelace).toBe(2_000_000n);
|
|
expect(typeof decoded.payment_lovelace).toBe("bigint");
|
|
});
|
|
|
|
it("buyer_bond_lovelace round-trips as bigint", () => {
|
|
const hex = encodeEscrowDatum(makeOpenEscrow());
|
|
const decoded = decodeEscrowDatum(hex);
|
|
expect(decoded.buyer_bond_lovelace).toBe(1_000_000n);
|
|
});
|
|
|
|
it("supplier_bond_lovelace round-trips as bigint", () => {
|
|
const hex = encodeEscrowDatum(makeOpenEscrow());
|
|
const decoded = decodeEscrowDatum(hex);
|
|
expect(decoded.supplier_bond_lovelace).toBe(1_000_000n);
|
|
});
|
|
|
|
it("deliver_by round-trips as POSIX time integer", () => {
|
|
const hex = encodeEscrowDatum(makeOpenEscrow());
|
|
const decoded = decodeEscrowDatum(hex);
|
|
expect(decoded.deliver_by).toBe(1745500060000);
|
|
});
|
|
|
|
it("posted_at round-trips as POSIX time integer", () => {
|
|
const hex = encodeEscrowDatum(makeOpenEscrow());
|
|
const decoded = decodeEscrowDatum(hex);
|
|
expect(decoded.posted_at).toBe(1745500000000);
|
|
});
|
|
|
|
it("submitted_at null decodes as null (Option = None)", () => {
|
|
const hex = encodeEscrowDatum(makeOpenEscrow());
|
|
const decoded = decodeEscrowDatum(hex);
|
|
expect(decoded.submitted_at).toBeNull();
|
|
});
|
|
|
|
it("result_receipt_hash null decodes as null (Option = None)", () => {
|
|
const hex = encodeEscrowDatum(makeOpenEscrow());
|
|
const decoded = decodeEscrowDatum(hex);
|
|
expect(decoded.result_receipt_hash).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ─── advert_ref as OutputReference ───────────────────────────────────────────
|
|
|
|
describe("EscrowDatum CBOR — advert_ref as Plutus OutputReference", () => {
|
|
it("advert_ref txHash round-trips correctly", () => {
|
|
const hex = encodeEscrowDatum(makeOpenEscrow());
|
|
const decoded = decodeEscrowDatum(hex);
|
|
expect(decoded.advert_ref.txHash).toBe(ADVERT_TX_HASH);
|
|
});
|
|
|
|
it("advert_ref index round-trips as integer", () => {
|
|
const hex = encodeEscrowDatum(makeOpenEscrow());
|
|
const decoded = decodeEscrowDatum(hex);
|
|
expect(decoded.advert_ref.index).toBe(0);
|
|
expect(Number.isInteger(decoded.advert_ref.index)).toBe(true);
|
|
});
|
|
|
|
it("advert_ref with non-zero index round-trips correctly", () => {
|
|
const escrow: EscrowDatum = {
|
|
...makeOpenEscrow(),
|
|
advert_ref: { txHash: ADVERT_TX_HASH, index: 7 },
|
|
};
|
|
const hex = encodeEscrowDatum(escrow);
|
|
const decoded = decodeEscrowDatum(hex);
|
|
expect(decoded.advert_ref.index).toBe(7);
|
|
});
|
|
});
|
|
|
|
// ─── Option fields — submitted_at and result_receipt_hash ────────────────────
|
|
|
|
describe("EscrowDatum CBOR — Option fields", () => {
|
|
it("submitted_at Some(timestamp) round-trips correctly", () => {
|
|
const escrow: EscrowDatum = {
|
|
...makeOpenEscrow(),
|
|
submitted_at: 1745500030000,
|
|
result_receipt_hash: RECEIPT_HASH,
|
|
state: "Submitted",
|
|
};
|
|
const hex = encodeEscrowDatum(escrow);
|
|
const decoded = decodeEscrowDatum(hex);
|
|
expect(decoded.submitted_at).toBe(1745500030000);
|
|
});
|
|
|
|
it("result_receipt_hash Some(hash) round-trips correctly", () => {
|
|
const escrow: EscrowDatum = {
|
|
...makeOpenEscrow(),
|
|
submitted_at: 1745500030000,
|
|
result_receipt_hash: RECEIPT_HASH,
|
|
state: "Submitted",
|
|
};
|
|
const hex = encodeEscrowDatum(escrow);
|
|
const decoded = decodeEscrowDatum(hex);
|
|
expect(decoded.result_receipt_hash).toBe(RECEIPT_HASH);
|
|
expect(decoded.result_receipt_hash).toHaveLength(64);
|
|
});
|
|
|
|
it("None submitted_at encodes differently than Some(timestamp)", () => {
|
|
const withNone = makeOpenEscrow(); // submitted_at = null
|
|
const withSome: EscrowDatum = { ...withNone, submitted_at: 1745500030000, state: "Submitted", result_receipt_hash: RECEIPT_HASH };
|
|
expect(encodeEscrowDatum(withNone)).not.toBe(encodeEscrowDatum(withSome));
|
|
});
|
|
});
|
|
|
|
// ─── EscrowState variants ─────────────────────────────────────────────────────
|
|
|
|
const ALL_STATES: EscrowState[] = [
|
|
"Open",
|
|
"Claimed",
|
|
"Submitted",
|
|
"Accepted",
|
|
"Reclaimed",
|
|
"Released",
|
|
];
|
|
|
|
describe("EscrowDatum CBOR — all 6 EscrowState variants", () => {
|
|
for (const state of ALL_STATES) {
|
|
it(`state '${state}' round-trips correctly`, () => {
|
|
const escrow = makeEscrowWithState(state);
|
|
const hex = encodeEscrowDatum(escrow);
|
|
const decoded = decodeEscrowDatum(hex);
|
|
expect(decoded.state).toBe(state);
|
|
});
|
|
}
|
|
|
|
it("all state variants encode to distinct hex values", () => {
|
|
const hexes = ALL_STATES.map((s) => encodeEscrowDatum(makeEscrowWithState(s)));
|
|
const unique = new Set(hexes);
|
|
expect(unique.size).toBe(ALL_STATES.length);
|
|
});
|
|
});
|
|
|
|
// ─── Adversarial: missing required field ─────────────────────────────────────
|
|
|
|
describe("EscrowDatum CBOR adversarial — malformed inputs", () => {
|
|
it("decodeEscrowDatum throws for empty hex", () => {
|
|
expect(() => decodeEscrowDatum("")).toThrow();
|
|
});
|
|
|
|
it("decodeEscrowDatum throws for truncated CBOR (half the encoded datum)", () => {
|
|
const full = encodeEscrowDatum(makeOpenEscrow());
|
|
const truncated = full.slice(0, Math.floor(full.length / 2));
|
|
expect(() => decodeEscrowDatum(truncated)).toThrow();
|
|
});
|
|
|
|
it("decodeEscrowDatum throws for a datum with missing fields (only 5 fields instead of 14)", () => {
|
|
// Constr0 (tag 121) with only 5 fields — insufficient for EscrowDatum
|
|
// CBOR: d8 79 85 01 02 03 04 05 = tag(121, [1, 2, 3, 4, 5])
|
|
const shortDatum = "d87985010203 0405".replace(/\s/g, "");
|
|
expect(() => decodeEscrowDatum(shortDatum)).toThrow();
|
|
});
|
|
|
|
it("decodeEscrowDatum throws for a datum with invalid state constructor tag", () => {
|
|
// We build a valid datum and then manually corrupt the state tag byte.
|
|
// This is done by encoding a datum with a known state, then replacing
|
|
// the state's tag bytes with an out-of-range value (e.g. tag 200).
|
|
//
|
|
// Since we can't mutate the hex without knowing exact offsets at this
|
|
// stage, we instead encode a minimal Constr0 with 14 fields where the
|
|
// last field is Constr tag 200 (which is invalid for EscrowState).
|
|
//
|
|
// For now, this test uses the simplest possible adversarial approach:
|
|
// pass a valid AdvertDatum-shaped CBOR where the state position would be
|
|
// a string instead of a Constr tag. The decoder must reject it.
|
|
const wrongStateDatum = "d87981 01".replace(/\s/g, ""); // tag(121, [1]) — integer state
|
|
expect(() => decodeEscrowDatum(wrongStateDatum)).toThrow();
|
|
});
|
|
});
|