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)
290 lines
12 KiB
TypeScript
290 lines
12 KiB
TypeScript
/**
|
|
* receipt-build-sign-verify.test.ts — RED phase tests for packages/shared/src/receipt/
|
|
*
|
|
* Covers:
|
|
* A. buildReceipt() — schema, field validation, adversarial inputs
|
|
* B. signReceipt() — Ed25519 signature, output shape
|
|
* C. verifyReceipt() — correct key passes, tampered field fails
|
|
* D. receiptResultHash() — deterministic sha256(canonical({receipt, signature}))
|
|
* E. Round-trip: build → sign → verify → hash
|
|
*/
|
|
|
|
import { describe, it, expect } from "vitest";
|
|
import { buildReceipt } from "../../packages/shared/src/receipt/build.js";
|
|
import { signReceipt, receiptResultHash } from "../../packages/shared/src/receipt/sign.js";
|
|
import { verifyReceipt } from "../../packages/shared/src/receipt/verify.js";
|
|
import {
|
|
SUPPLIER_PRIVATE_KEY_HEX,
|
|
SUPPLIER_PUB_KEY_HEX,
|
|
SUPPLIER_PKH,
|
|
} from "../fixtures/supplier-side/wallet-keys.js";
|
|
|
|
// ─── Fixture data ──────────────────────────────────────────────────────────
|
|
|
|
const VALID_PROMPT_HASH = "a".repeat(64);
|
|
const VALID_RESPONSE_HASH = "b".repeat(64);
|
|
const VALID_ESCROW_TX_HASH = "f".repeat(64);
|
|
const VALID_ESCROW_REF = `${VALID_ESCROW_TX_HASH}#0`;
|
|
const VALID_MODEL = "qwen2.5:0.5b";
|
|
|
|
function validReceiptParams() {
|
|
return {
|
|
prompt_hash: VALID_PROMPT_HASH,
|
|
response_hash: VALID_RESPONSE_HASH,
|
|
model: VALID_MODEL,
|
|
prompt_tokens: 12,
|
|
completion_tokens: 48,
|
|
wallclock_ms: 3200,
|
|
supplier_pkh: SUPPLIER_PKH,
|
|
escrow_ref: VALID_ESCROW_REF,
|
|
};
|
|
}
|
|
|
|
// ─── A. buildReceipt ────────────────────────────────────────────────────────
|
|
|
|
describe("buildReceipt()", () => {
|
|
it("returns a Receipt with all required fields", () => {
|
|
const receipt = buildReceipt(validReceiptParams());
|
|
expect(receipt.prompt_hash).toBe(VALID_PROMPT_HASH);
|
|
expect(receipt.response_hash).toBe(VALID_RESPONSE_HASH);
|
|
expect(receipt.model).toBe(VALID_MODEL);
|
|
expect(receipt.prompt_tokens).toBe(12);
|
|
expect(receipt.completion_tokens).toBe(48);
|
|
expect(receipt.wallclock_ms).toBe(3200);
|
|
expect(receipt.supplier_pkh).toBe(SUPPLIER_PKH);
|
|
expect(receipt.escrow_ref).toBe(VALID_ESCROW_REF);
|
|
});
|
|
|
|
it("escrow_ref is formatted as <64-char-hex>#<int>", () => {
|
|
const receipt = buildReceipt(validReceiptParams());
|
|
expect(receipt.escrow_ref).toMatch(/^[0-9a-fA-F]{64}#\d+$/);
|
|
});
|
|
|
|
it("throws on empty supplier_pkh", () => {
|
|
expect(() =>
|
|
buildReceipt({ ...validReceiptParams(), supplier_pkh: "" })
|
|
).toThrow();
|
|
});
|
|
|
|
it("throws on response_hash shorter than 32 bytes (< 64 hex chars)", () => {
|
|
// 30-byte response_hash (60 hex chars)
|
|
expect(() =>
|
|
buildReceipt({ ...validReceiptParams(), response_hash: "b".repeat(60) })
|
|
).toThrow();
|
|
});
|
|
|
|
it("throws on prompt_hash shorter than 32 bytes (< 64 hex chars)", () => {
|
|
expect(() =>
|
|
buildReceipt({ ...validReceiptParams(), prompt_hash: "a".repeat(60) })
|
|
).toThrow();
|
|
});
|
|
|
|
it("throws on missing escrow_ref (empty string)", () => {
|
|
expect(() =>
|
|
buildReceipt({ ...validReceiptParams(), escrow_ref: "" })
|
|
).toThrow();
|
|
});
|
|
|
|
it("throws on malformed escrow_ref (no hash separator)", () => {
|
|
expect(() =>
|
|
buildReceipt({ ...validReceiptParams(), escrow_ref: "not-valid-ref" })
|
|
).toThrow();
|
|
});
|
|
|
|
it("throws on response_hash that is not hex", () => {
|
|
expect(() =>
|
|
buildReceipt({ ...validReceiptParams(), response_hash: "z".repeat(64) })
|
|
).toThrow();
|
|
});
|
|
|
|
it("accepts index 0 in escrow_ref", () => {
|
|
const receipt = buildReceipt({ ...validReceiptParams(), escrow_ref: `${"0".repeat(64)}#0` });
|
|
expect(receipt.escrow_ref).toBe(`${"0".repeat(64)}#0`);
|
|
});
|
|
|
|
it("accepts index > 0 in escrow_ref", () => {
|
|
const receipt = buildReceipt({ ...validReceiptParams(), escrow_ref: `${"0".repeat(64)}#3` });
|
|
expect(receipt.escrow_ref).toBe(`${"0".repeat(64)}#3`);
|
|
});
|
|
});
|
|
|
|
// ─── B. signReceipt ─────────────────────────────────────────────────────────
|
|
|
|
describe("signReceipt()", () => {
|
|
it("returns an object with receipt and signature fields", () => {
|
|
const receipt = buildReceipt(validReceiptParams());
|
|
const signed = signReceipt(receipt, SUPPLIER_PRIVATE_KEY_HEX);
|
|
expect(signed.receipt).toEqual(receipt);
|
|
expect(typeof signed.signature).toBe("string");
|
|
});
|
|
|
|
it("signature is 64-byte hex (128 chars)", () => {
|
|
const receipt = buildReceipt(validReceiptParams());
|
|
const signed = signReceipt(receipt, SUPPLIER_PRIVATE_KEY_HEX);
|
|
expect(signed.signature).toMatch(/^[0-9a-fA-F]{128}$/);
|
|
});
|
|
|
|
it("signature is deterministic for the same key and receipt", () => {
|
|
const receipt = buildReceipt(validReceiptParams());
|
|
const sig1 = signReceipt(receipt, SUPPLIER_PRIVATE_KEY_HEX).signature;
|
|
const sig2 = signReceipt(receipt, SUPPLIER_PRIVATE_KEY_HEX).signature;
|
|
// Ed25519 is deterministic (RFC 8032)
|
|
expect(sig1).toBe(sig2);
|
|
});
|
|
|
|
it("different receipts produce different signatures", () => {
|
|
const r1 = buildReceipt(validReceiptParams());
|
|
const r2 = buildReceipt({ ...validReceiptParams(), prompt_tokens: 99 });
|
|
const sig1 = signReceipt(r1, SUPPLIER_PRIVATE_KEY_HEX).signature;
|
|
const sig2 = signReceipt(r2, SUPPLIER_PRIVATE_KEY_HEX).signature;
|
|
expect(sig1).not.toBe(sig2);
|
|
});
|
|
});
|
|
|
|
// ─── C. verifyReceipt ───────────────────────────────────────────────────────
|
|
|
|
describe("verifyReceipt()", () => {
|
|
it("returns true for a correctly signed receipt using the matching public key", () => {
|
|
const receipt = buildReceipt(validReceiptParams());
|
|
const signed = signReceipt(receipt, SUPPLIER_PRIVATE_KEY_HEX);
|
|
expect(verifyReceipt(signed, SUPPLIER_PUB_KEY_HEX)).toBe(true);
|
|
});
|
|
|
|
it("returns false when prompt_hash is tampered", () => {
|
|
const receipt = buildReceipt(validReceiptParams());
|
|
const signed = signReceipt(receipt, SUPPLIER_PRIVATE_KEY_HEX);
|
|
const tampered = {
|
|
receipt: { ...signed.receipt, prompt_hash: "0".repeat(64) },
|
|
signature: signed.signature,
|
|
};
|
|
expect(verifyReceipt(tampered, SUPPLIER_PUB_KEY_HEX)).toBe(false);
|
|
});
|
|
|
|
it("returns false when response_hash is tampered", () => {
|
|
const receipt = buildReceipt(validReceiptParams());
|
|
const signed = signReceipt(receipt, SUPPLIER_PRIVATE_KEY_HEX);
|
|
const tampered = {
|
|
receipt: { ...signed.receipt, response_hash: "0".repeat(64) },
|
|
signature: signed.signature,
|
|
};
|
|
expect(verifyReceipt(tampered, SUPPLIER_PUB_KEY_HEX)).toBe(false);
|
|
});
|
|
|
|
it("returns false when model is tampered", () => {
|
|
const receipt = buildReceipt(validReceiptParams());
|
|
const signed = signReceipt(receipt, SUPPLIER_PRIVATE_KEY_HEX);
|
|
const tampered = {
|
|
receipt: { ...signed.receipt, model: "evil-model" },
|
|
signature: signed.signature,
|
|
};
|
|
expect(verifyReceipt(tampered, SUPPLIER_PUB_KEY_HEX)).toBe(false);
|
|
});
|
|
|
|
it("returns false when prompt_tokens is tampered", () => {
|
|
const receipt = buildReceipt(validReceiptParams());
|
|
const signed = signReceipt(receipt, SUPPLIER_PRIVATE_KEY_HEX);
|
|
const tampered = {
|
|
receipt: { ...signed.receipt, prompt_tokens: 9999 },
|
|
signature: signed.signature,
|
|
};
|
|
expect(verifyReceipt(tampered, SUPPLIER_PUB_KEY_HEX)).toBe(false);
|
|
});
|
|
|
|
it("returns false when supplier_pkh is tampered", () => {
|
|
const receipt = buildReceipt(validReceiptParams());
|
|
const signed = signReceipt(receipt, SUPPLIER_PRIVATE_KEY_HEX);
|
|
const tampered = {
|
|
receipt: { ...signed.receipt, supplier_pkh: "9".repeat(56) },
|
|
signature: signed.signature,
|
|
};
|
|
expect(verifyReceipt(tampered, SUPPLIER_PUB_KEY_HEX)).toBe(false);
|
|
});
|
|
|
|
it("returns false when escrow_ref is tampered", () => {
|
|
const receipt = buildReceipt(validReceiptParams());
|
|
const signed = signReceipt(receipt, SUPPLIER_PRIVATE_KEY_HEX);
|
|
const tampered = {
|
|
receipt: { ...signed.receipt, escrow_ref: `${"0".repeat(64)}#99` },
|
|
signature: signed.signature,
|
|
};
|
|
expect(verifyReceipt(tampered, SUPPLIER_PUB_KEY_HEX)).toBe(false);
|
|
});
|
|
|
|
it("returns false when signature is all zeros", () => {
|
|
const receipt = buildReceipt(validReceiptParams());
|
|
const signed = signReceipt(receipt, SUPPLIER_PRIVATE_KEY_HEX);
|
|
const tampered = { receipt: signed.receipt, signature: "0".repeat(128) };
|
|
expect(verifyReceipt(tampered, SUPPLIER_PUB_KEY_HEX)).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ─── D. receiptResultHash ───────────────────────────────────────────────────
|
|
|
|
describe("receiptResultHash()", () => {
|
|
it("returns a 32-byte (64-char) lowercase hex string", () => {
|
|
const receipt = buildReceipt(validReceiptParams());
|
|
const signed = signReceipt(receipt, SUPPLIER_PRIVATE_KEY_HEX);
|
|
const hash = receiptResultHash(signed);
|
|
expect(hash).toMatch(/^[0-9a-f]{64}$/);
|
|
});
|
|
|
|
it("is deterministic for the same signed receipt", () => {
|
|
const receipt = buildReceipt(validReceiptParams());
|
|
const signed = signReceipt(receipt, SUPPLIER_PRIVATE_KEY_HEX);
|
|
expect(receiptResultHash(signed)).toBe(receiptResultHash(signed));
|
|
});
|
|
|
|
it("changes when receipt field changes", () => {
|
|
const r1 = buildReceipt(validReceiptParams());
|
|
const r2 = buildReceipt({ ...validReceiptParams(), wallclock_ms: 9999 });
|
|
const signed1 = signReceipt(r1, SUPPLIER_PRIVATE_KEY_HEX);
|
|
const signed2 = signReceipt(r2, SUPPLIER_PRIVATE_KEY_HEX);
|
|
expect(receiptResultHash(signed1)).not.toBe(receiptResultHash(signed2));
|
|
});
|
|
|
|
it("changes when signature changes (different key)", () => {
|
|
const receipt = buildReceipt(validReceiptParams());
|
|
const signed1 = signReceipt(receipt, SUPPLIER_PRIVATE_KEY_HEX);
|
|
// Simulate a different key by constructing a different-looking signature
|
|
const altSigned = { receipt: signed1.receipt, signature: "1" + signed1.signature.slice(1) };
|
|
// Hash must differ because canonical({receipt, signature}) includes signature
|
|
expect(receiptResultHash(signed1)).not.toBe(receiptResultHash(altSigned));
|
|
});
|
|
});
|
|
|
|
// ─── E. Round-trip ───────────────────────────────────────────────────────────
|
|
|
|
describe("receipt round-trip (build → sign → verify → hash)", () => {
|
|
it("full happy path: build receipt, sign, verify true, get on-chain hash", () => {
|
|
const params = validReceiptParams();
|
|
const receipt = buildReceipt(params);
|
|
const signed = signReceipt(receipt, SUPPLIER_PRIVATE_KEY_HEX);
|
|
|
|
// Verify signature is valid
|
|
expect(verifyReceipt(signed, SUPPLIER_PUB_KEY_HEX)).toBe(true);
|
|
|
|
// result hash is 32-byte hex — suitable for EscrowDatum.result_receipt_hash
|
|
const resultHash = receiptResultHash(signed);
|
|
expect(resultHash).toMatch(/^[0-9a-f]{64}$/);
|
|
});
|
|
|
|
it("tampered receipt after sign yields verify=false (no forgery)", () => {
|
|
const receipt = buildReceipt(validReceiptParams());
|
|
const signed = signReceipt(receipt, SUPPLIER_PRIVATE_KEY_HEX);
|
|
|
|
const attacker = {
|
|
receipt: { ...signed.receipt, completion_tokens: 0 },
|
|
signature: signed.signature,
|
|
};
|
|
expect(verifyReceipt(attacker, SUPPLIER_PUB_KEY_HEX)).toBe(false);
|
|
});
|
|
|
|
it("two distinct prompts produce two distinct result hashes", () => {
|
|
const p1 = buildReceipt(validReceiptParams());
|
|
const p2 = buildReceipt({ ...validReceiptParams(), prompt_hash: "1".repeat(64) });
|
|
const h1 = receiptResultHash(signReceipt(p1, SUPPLIER_PRIVATE_KEY_HEX));
|
|
const h2 = receiptResultHash(signReceipt(p2, SUPPLIER_PRIVATE_KEY_HEX));
|
|
expect(h1).not.toBe(h2);
|
|
});
|
|
});
|