Files
agents-marketplace/tests/unit/cbor-escrow-datum.test.ts
jelisaveta-talbot cbab81efbd M1-F: end-to-end LLM marketplace lifecycle on Vector testnet
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)
2026-05-06 13:27:48 +02:00

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();
});
});