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)
589 lines
27 KiB
TypeScript
589 lines
27 KiB
TypeScript
/**
|
|
* tests/unit/indexer-block-processor.test.ts — blockProcessor + chain follower tests (Category B)
|
|
*
|
|
* Injects synthetic blocks via MockChainSyncSource to test event detection.
|
|
* Tests call processBlock() directly.
|
|
*
|
|
* M1-D-fix changes:
|
|
* - AcceptEscrow / ReleaseEscrow tests now assert EXCLUSIVE event emission
|
|
* (Accept → no Release; Release → no Accept) driven by tx.redeemers.
|
|
* - Added: fallback test (ambiguous Submitted-spend → 1 AcceptEscrow + console.warn).
|
|
* - Added: redeemer/state mismatch tests (e.g. Accept redeemer on Open → no event + warn).
|
|
* - DELETED (3 tests): old tests that asserted both AcceptEscrow AND ReleaseEscrow
|
|
* were emitted on the same Submitted-spend via .some() — those encoded the broken
|
|
* double-emit behaviour Catherine's blockProcessor.ts must no longer exhibit.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
|
|
import { processBlock } from "../../indexer/src/follower/blockProcessor.js";
|
|
import type { ScriptAddresses } from "../../indexer/src/follower/blockProcessor.js";
|
|
import {
|
|
buildMockBlock,
|
|
buildPostAdvertTx,
|
|
buildUpdateAdvertTx,
|
|
buildRetireAdvertTx,
|
|
buildPostEscrowTx,
|
|
buildClaimEscrowTx,
|
|
buildSubmitEscrowTx,
|
|
buildAcceptEscrowTx,
|
|
buildReclaimEscrowTx,
|
|
buildReleaseEscrowTx,
|
|
buildAmbiguousSubmittedSpendTx,
|
|
buildMalformedDatumTx,
|
|
buildByronBlock,
|
|
buildAdvertDatumHex,
|
|
buildEscrowDatumHex,
|
|
ADVERT_SCRIPT_ADDRESS,
|
|
ESCROW_SCRIPT_ADDRESS,
|
|
INDEXER_SUPPLIER_PKH,
|
|
SAMPLE_RECEIPT_HASH,
|
|
SAMPLE_SUBMITTED_AT,
|
|
} from "../fixtures/indexer-side/sample-blocks.js";
|
|
|
|
const ADDRESSES: ScriptAddresses = {
|
|
advertAddress: ADVERT_SCRIPT_ADDRESS,
|
|
escrowAddress: ESCROW_SCRIPT_ADDRESS,
|
|
};
|
|
|
|
// Restore console.warn after any test that spies on it.
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
// ─── PostAdvert ───────────────────────────────────────────────────────────────
|
|
|
|
describe("blockProcessor — PostAdvert", () => {
|
|
it("PostAdvert tx at advertAddress emits PostAdvert event", () => {
|
|
const tx = buildPostAdvertTx({ txId: "a".repeat(64) });
|
|
const block = buildMockBlock({ slot: 1_000_000, transactions: [tx] });
|
|
const result = processBlock(block, new Map(), ADDRESSES);
|
|
expect(result.events.length).toBe(1);
|
|
expect(result.events[0].type).toBe("PostAdvert");
|
|
expect(result.events[0].txHash).toBe("a".repeat(64));
|
|
expect(result.events[0].utxoRef).toBe("a".repeat(64) + "#0");
|
|
expect(result.events[0].slot).toBe(1_000_000);
|
|
});
|
|
|
|
it("PostAdvert event datumHex decodes to valid AdvertDatum", () => {
|
|
const datumHex = buildAdvertDatumHex({ supplierPkh: INDEXER_SUPPLIER_PKH });
|
|
const tx = buildPostAdvertTx({ txId: "a".repeat(64), advertDatumOpts: { supplierPkh: INDEXER_SUPPLIER_PKH } });
|
|
const block = buildMockBlock({ slot: 1_000_000, transactions: [tx] });
|
|
const result = processBlock(block, new Map(), ADDRESSES);
|
|
expect(result.events[0].datumHex).toBe(datumHex);
|
|
});
|
|
|
|
it("PostAdvert address is ADVERT_SCRIPT_ADDRESS", () => {
|
|
const tx = buildPostAdvertTx();
|
|
const block = buildMockBlock({ slot: 1_000_000, transactions: [tx] });
|
|
const result = processBlock(block, new Map(), ADDRESSES);
|
|
expect(result.events[0].address).toBe(ADVERT_SCRIPT_ADDRESS);
|
|
});
|
|
});
|
|
|
|
// ─── UpdateAdvert ─────────────────────────────────────────────────────────────
|
|
|
|
describe("blockProcessor — UpdateAdvert", () => {
|
|
it("UpdateAdvert tx spends old advert UTxO and emits UpdateAdvert event", () => {
|
|
const postTxId = "a".repeat(64);
|
|
const knownUtxos = new Map([
|
|
[postTxId + "#0", { address: ADVERT_SCRIPT_ADDRESS, datumHex: buildAdvertDatumHex() }],
|
|
]);
|
|
const tx = buildUpdateAdvertTx({
|
|
txId: "b".repeat(64),
|
|
spentRef: { txId: postTxId, index: 0 },
|
|
newAdvertDatumOpts: { maxOutputTokens: 1024 },
|
|
});
|
|
const block = buildMockBlock({ slot: 1_000_100, transactions: [tx] });
|
|
const result = processBlock(block, knownUtxos, ADDRESSES);
|
|
expect(result.events.some(e => e.type === "UpdateAdvert")).toBe(true);
|
|
});
|
|
|
|
it("UpdateAdvert: advertisements table reflects new datum after update", () => {
|
|
const postTxId = "a".repeat(64);
|
|
const knownUtxos = new Map([
|
|
[postTxId + "#0", { address: ADVERT_SCRIPT_ADDRESS, datumHex: buildAdvertDatumHex() }],
|
|
]);
|
|
const newDatumHex = buildAdvertDatumHex({ maxOutputTokens: 1024 });
|
|
const tx = buildUpdateAdvertTx({
|
|
txId: "b".repeat(64),
|
|
spentRef: { txId: postTxId, index: 0 },
|
|
newAdvertDatumOpts: { maxOutputTokens: 1024 },
|
|
});
|
|
const block = buildMockBlock({ slot: 1_000_100, transactions: [tx] });
|
|
const result = processBlock(block, knownUtxos, ADDRESSES);
|
|
const ev = result.events.find(e => e.type === "UpdateAdvert");
|
|
expect(ev!.datumHex).toBe(newDatumHex);
|
|
});
|
|
});
|
|
|
|
// ─── RetireAdvert ─────────────────────────────────────────────────────────────
|
|
|
|
describe("blockProcessor — RetireAdvert", () => {
|
|
it("RetireAdvert tx spends advert with no continuing script output → RetireAdvert event", () => {
|
|
const postTxId = "a".repeat(64);
|
|
const knownUtxos = new Map([
|
|
[postTxId + "#0", { address: ADVERT_SCRIPT_ADDRESS, datumHex: buildAdvertDatumHex() }],
|
|
]);
|
|
const tx = buildRetireAdvertTx({ txId: "b".repeat(64), spentRef: { txId: postTxId, index: 0 } });
|
|
const block = buildMockBlock({ slot: 1_000_200, transactions: [tx] });
|
|
const result = processBlock(block, knownUtxos, ADDRESSES);
|
|
expect(result.events.some(e => e.type === "RetireAdvert")).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ─── PostEscrow ───────────────────────────────────────────────────────────────
|
|
|
|
describe("blockProcessor — PostEscrow", () => {
|
|
it("PostEscrow tx at escrowAddress with Open state emits PostEscrow event", () => {
|
|
const tx = buildPostEscrowTx({ txId: "c".repeat(64) });
|
|
const block = buildMockBlock({ slot: 1_001_000, transactions: [tx] });
|
|
const result = processBlock(block, new Map(), ADDRESSES);
|
|
expect(result.events.some(e => e.type === "PostEscrow")).toBe(true);
|
|
});
|
|
|
|
it("PostEscrow event has state=Open in decoded datum", () => {
|
|
const datumHex = buildEscrowDatumHex({ state: "Open" });
|
|
const tx = buildPostEscrowTx({ txId: "c".repeat(64) });
|
|
const block = buildMockBlock({ slot: 1_001_000, transactions: [tx] });
|
|
const result = processBlock(block, new Map(), ADDRESSES);
|
|
const ev = result.events.find(e => e.type === "PostEscrow");
|
|
expect(ev!.datumHex).toBe(datumHex);
|
|
});
|
|
});
|
|
|
|
// ─── ClaimEscrow ──────────────────────────────────────────────────────────────
|
|
|
|
describe("blockProcessor — ClaimEscrow", () => {
|
|
it("ClaimEscrow tx spends Open escrow, produces Claimed output → ClaimEscrow event", () => {
|
|
const postTxId = "c".repeat(64);
|
|
const knownUtxos = new Map([
|
|
[postTxId + "#0", { address: ESCROW_SCRIPT_ADDRESS, datumHex: buildEscrowDatumHex({ state: "Open" }) }],
|
|
]);
|
|
const tx = buildClaimEscrowTx({
|
|
txId: "d".repeat(64),
|
|
spentRef: { txId: postTxId, index: 0 },
|
|
});
|
|
const block = buildMockBlock({ slot: 1_001_100, transactions: [tx] });
|
|
const result = processBlock(block, knownUtxos, ADDRESSES);
|
|
expect(result.events.some(e => e.type === "ClaimEscrow")).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ─── SubmitEscrow ─────────────────────────────────────────────────────────────
|
|
|
|
describe("blockProcessor — SubmitEscrow", () => {
|
|
it("SubmitEscrow tx spends Claimed escrow, produces Submitted output with receipt hash", () => {
|
|
const claimTxId = "d".repeat(64);
|
|
const knownUtxos = new Map([
|
|
[claimTxId + "#0", { address: ESCROW_SCRIPT_ADDRESS, datumHex: buildEscrowDatumHex({ state: "Claimed" }) }],
|
|
]);
|
|
const tx = buildSubmitEscrowTx({
|
|
txId: "e".repeat(64),
|
|
spentRef: { txId: claimTxId, index: 0 },
|
|
receiptHash: SAMPLE_RECEIPT_HASH,
|
|
});
|
|
const block = buildMockBlock({ slot: 1_001_200, transactions: [tx] });
|
|
const result = processBlock(block, knownUtxos, ADDRESSES);
|
|
expect(result.events.some(e => e.type === "SubmitEscrow")).toBe(true);
|
|
});
|
|
|
|
it("SubmitEscrow event datumHex has Submitted state and result_receipt_hash set", () => {
|
|
const claimTxId = "d".repeat(64);
|
|
const knownUtxos = new Map([
|
|
[claimTxId + "#0", { address: ESCROW_SCRIPT_ADDRESS, datumHex: buildEscrowDatumHex({ state: "Claimed" }) }],
|
|
]);
|
|
const tx = buildSubmitEscrowTx({
|
|
txId: "e".repeat(64),
|
|
spentRef: { txId: claimTxId, index: 0 },
|
|
receiptHash: SAMPLE_RECEIPT_HASH,
|
|
});
|
|
const block = buildMockBlock({ slot: 1_001_200, transactions: [tx] });
|
|
const result = processBlock(block, knownUtxos, ADDRESSES);
|
|
const ev = result.events.find(e => e.type === "SubmitEscrow");
|
|
// SPEC FIX 2026-04-25: Submit redeemer also stamps submitted_at per ARCHITECTURE §4.3 ("New datum sets state = Submitted, submitted_at = <upper bound>"); fixture builder includes it, so expectation must too.
|
|
expect(ev!.datumHex).toBe(buildEscrowDatumHex({ state: "Submitted", submittedAt: SAMPLE_SUBMITTED_AT, resultReceiptHash: SAMPLE_RECEIPT_HASH }));
|
|
});
|
|
});
|
|
|
|
// ─── AcceptEscrow ─────────────────────────────────────────────────────────────
|
|
//
|
|
// M1-D-fix: Accept redeemer → AcceptEscrow ONLY. No ReleaseEscrow must appear.
|
|
|
|
describe("blockProcessor — AcceptEscrow (redeemer-aware)", () => {
|
|
it("buildAcceptEscrowTx → exactly one AcceptEscrow event emitted", () => {
|
|
const submitTxId = "e".repeat(64);
|
|
const knownUtxos = new Map([
|
|
[submitTxId + "#0", { address: ESCROW_SCRIPT_ADDRESS, datumHex: buildEscrowDatumHex({ state: "Submitted" }) }],
|
|
]);
|
|
const tx = buildAcceptEscrowTx({
|
|
txId: "f".repeat(64),
|
|
spentRef: { txId: submitTxId, index: 0 },
|
|
});
|
|
const block = buildMockBlock({ slot: 1_001_300, transactions: [tx] });
|
|
const result = processBlock(block, knownUtxos, ADDRESSES);
|
|
const acceptEvents = result.events.filter(e => e.type === "AcceptEscrow");
|
|
expect(acceptEvents.length).toBe(1);
|
|
});
|
|
|
|
it("buildAcceptEscrowTx → NO ReleaseEscrow event emitted", () => {
|
|
const submitTxId = "e".repeat(64);
|
|
const knownUtxos = new Map([
|
|
[submitTxId + "#0", { address: ESCROW_SCRIPT_ADDRESS, datumHex: buildEscrowDatumHex({ state: "Submitted" }) }],
|
|
]);
|
|
const tx = buildAcceptEscrowTx({
|
|
txId: "f".repeat(64),
|
|
spentRef: { txId: submitTxId, index: 0 },
|
|
});
|
|
const block = buildMockBlock({ slot: 1_001_300, transactions: [tx] });
|
|
const result = processBlock(block, knownUtxos, ADDRESSES);
|
|
expect(result.events.some(e => e.type === "ReleaseEscrow")).toBe(false);
|
|
});
|
|
|
|
it("AcceptEscrow event carries correct utxoRef, slot, txHash", () => {
|
|
const submitTxId = "e".repeat(64);
|
|
const knownUtxos = new Map([
|
|
[submitTxId + "#0", { address: ESCROW_SCRIPT_ADDRESS, datumHex: buildEscrowDatumHex({ state: "Submitted" }) }],
|
|
]);
|
|
const tx = buildAcceptEscrowTx({
|
|
txId: "f".repeat(64),
|
|
spentRef: { txId: submitTxId, index: 0 },
|
|
});
|
|
const block = buildMockBlock({ slot: 1_001_300, transactions: [tx] });
|
|
const result = processBlock(block, knownUtxos, ADDRESSES);
|
|
const ev = result.events.find(e => e.type === "AcceptEscrow");
|
|
expect(ev).toBeDefined();
|
|
expect(ev!.utxoRef).toBe(submitTxId + "#0");
|
|
expect(ev!.slot).toBe(1_001_300);
|
|
expect(ev!.txHash).toBe("f".repeat(64));
|
|
});
|
|
});
|
|
|
|
// ─── ReclaimEscrow ────────────────────────────────────────────────────────────
|
|
|
|
describe("blockProcessor — ReclaimEscrow", () => {
|
|
it("ReclaimEscrow tx spends Open escrow after deliver_by → ReclaimEscrow event", () => {
|
|
const postTxId = "c".repeat(64);
|
|
const knownUtxos = new Map([
|
|
[postTxId + "#0", { address: ESCROW_SCRIPT_ADDRESS, datumHex: buildEscrowDatumHex({ state: "Open" }) }],
|
|
]);
|
|
const tx = buildReclaimEscrowTx({
|
|
txId: "g".repeat(64),
|
|
spentRef: { txId: postTxId, index: 0 },
|
|
});
|
|
const block = buildMockBlock({ slot: 1_002_000, transactions: [tx] });
|
|
const result = processBlock(block, knownUtxos, ADDRESSES);
|
|
expect(result.events.some(e => e.type === "ReclaimEscrow")).toBe(true);
|
|
});
|
|
|
|
it("ReclaimEscrow from Claimed state also emits ReclaimEscrow", () => {
|
|
const claimTxId = "d".repeat(64);
|
|
const knownUtxos = new Map([
|
|
[claimTxId + "#0", { address: ESCROW_SCRIPT_ADDRESS, datumHex: buildEscrowDatumHex({ state: "Claimed" }) }],
|
|
]);
|
|
const tx = buildReclaimEscrowTx({
|
|
txId: "h".repeat(64),
|
|
spentRef: { txId: claimTxId, index: 0 },
|
|
});
|
|
const block = buildMockBlock({ slot: 1_002_000, transactions: [tx] });
|
|
const result = processBlock(block, knownUtxos, ADDRESSES);
|
|
expect(result.events.some(e => e.type === "ReclaimEscrow")).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ─── ReleaseEscrow ────────────────────────────────────────────────────────────
|
|
//
|
|
// M1-D-fix: Release redeemer → ReleaseEscrow ONLY. No AcceptEscrow must appear.
|
|
|
|
describe("blockProcessor — ReleaseEscrow (redeemer-aware)", () => {
|
|
it("buildReleaseEscrowTx → exactly one ReleaseEscrow event emitted", () => {
|
|
const submitTxId = "e".repeat(64);
|
|
const knownUtxos = new Map([
|
|
[submitTxId + "#0", { address: ESCROW_SCRIPT_ADDRESS, datumHex: buildEscrowDatumHex({ state: "Submitted" }) }],
|
|
]);
|
|
const tx = buildReleaseEscrowTx({
|
|
txId: "i".repeat(64),
|
|
spentRef: { txId: submitTxId, index: 0 },
|
|
});
|
|
const block = buildMockBlock({ slot: 1_002_000, transactions: [tx] });
|
|
const result = processBlock(block, knownUtxos, ADDRESSES);
|
|
const releaseEvents = result.events.filter(e => e.type === "ReleaseEscrow");
|
|
expect(releaseEvents.length).toBe(1);
|
|
});
|
|
|
|
it("buildReleaseEscrowTx → NO AcceptEscrow event emitted", () => {
|
|
const submitTxId = "e".repeat(64);
|
|
const knownUtxos = new Map([
|
|
[submitTxId + "#0", { address: ESCROW_SCRIPT_ADDRESS, datumHex: buildEscrowDatumHex({ state: "Submitted" }) }],
|
|
]);
|
|
const tx = buildReleaseEscrowTx({
|
|
txId: "i".repeat(64),
|
|
spentRef: { txId: submitTxId, index: 0 },
|
|
});
|
|
const block = buildMockBlock({ slot: 1_002_000, transactions: [tx] });
|
|
const result = processBlock(block, knownUtxos, ADDRESSES);
|
|
expect(result.events.some(e => e.type === "AcceptEscrow")).toBe(false);
|
|
});
|
|
|
|
it("ReleaseEscrow event carries correct utxoRef, slot, txHash", () => {
|
|
const submitTxId = "e".repeat(64);
|
|
const knownUtxos = new Map([
|
|
[submitTxId + "#0", { address: ESCROW_SCRIPT_ADDRESS, datumHex: buildEscrowDatumHex({ state: "Submitted" }) }],
|
|
]);
|
|
const tx = buildReleaseEscrowTx({
|
|
txId: "i".repeat(64),
|
|
spentRef: { txId: submitTxId, index: 0 },
|
|
});
|
|
const block = buildMockBlock({ slot: 1_002_000, transactions: [tx] });
|
|
const result = processBlock(block, knownUtxos, ADDRESSES);
|
|
const ev = result.events.find(e => e.type === "ReleaseEscrow");
|
|
expect(ev).toBeDefined();
|
|
expect(ev!.utxoRef).toBe(submitTxId + "#0");
|
|
expect(ev!.slot).toBe(1_002_000);
|
|
expect(ev!.txHash).toBe("i".repeat(64));
|
|
});
|
|
});
|
|
|
|
// ─── Redeemer fallback (ambiguous Submitted-spend) ────────────────────────────
|
|
//
|
|
// M1-D-fix: When a Submitted-spend has NO redeemer, blockProcessor must emit
|
|
// exactly ONE AcceptEscrow event (defensive default) and call console.warn.
|
|
// This handles real-world Ogmios responses where redeemer info may be absent.
|
|
|
|
describe("blockProcessor — redeemer fallback (ambiguous Submitted-spend)", () => {
|
|
it("ambiguous Submitted-spend → exactly ONE AcceptEscrow event (no ReleaseEscrow)", () => {
|
|
const submitTxId = "e".repeat(64);
|
|
const knownUtxos = new Map([
|
|
[submitTxId + "#0", { address: ESCROW_SCRIPT_ADDRESS, datumHex: buildEscrowDatumHex({ state: "Submitted" }) }],
|
|
]);
|
|
const tx = buildAmbiguousSubmittedSpendTx({
|
|
txId: "z".repeat(64),
|
|
spentRef: { txId: submitTxId, index: 0 },
|
|
});
|
|
const block = buildMockBlock({ slot: 1_009_000, transactions: [tx] });
|
|
const result = processBlock(block, knownUtxos, ADDRESSES);
|
|
const acceptEvents = result.events.filter(e => e.type === "AcceptEscrow");
|
|
const releaseEvents = result.events.filter(e => e.type === "ReleaseEscrow");
|
|
expect(acceptEvents.length).toBe(1);
|
|
expect(releaseEvents.length).toBe(0);
|
|
});
|
|
|
|
it("ambiguous Submitted-spend → console.warn is called with a message mentioning missing redeemer", () => {
|
|
const submitTxId = "e".repeat(64);
|
|
const knownUtxos = new Map([
|
|
[submitTxId + "#0", { address: ESCROW_SCRIPT_ADDRESS, datumHex: buildEscrowDatumHex({ state: "Submitted" }) }],
|
|
]);
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
const tx = buildAmbiguousSubmittedSpendTx({
|
|
txId: "z".repeat(64),
|
|
spentRef: { txId: submitTxId, index: 0 },
|
|
});
|
|
const block = buildMockBlock({ slot: 1_009_000, transactions: [tx] });
|
|
processBlock(block, knownUtxos, ADDRESSES);
|
|
expect(warnSpy).toHaveBeenCalled();
|
|
// At least one warn call should mention the missing redeemer
|
|
const warnArgs = warnSpy.mock.calls.flat().join(" ");
|
|
expect(warnArgs).toMatch(/redeemer/i);
|
|
});
|
|
|
|
it("ambiguous Submitted-spend → total event count is exactly 1", () => {
|
|
const submitTxId = "e".repeat(64);
|
|
const knownUtxos = new Map([
|
|
[submitTxId + "#0", { address: ESCROW_SCRIPT_ADDRESS, datumHex: buildEscrowDatumHex({ state: "Submitted" }) }],
|
|
]);
|
|
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
const tx = buildAmbiguousSubmittedSpendTx({
|
|
txId: "z".repeat(64),
|
|
spentRef: { txId: submitTxId, index: 0 },
|
|
});
|
|
const block = buildMockBlock({ slot: 1_009_000, transactions: [tx] });
|
|
const result = processBlock(block, knownUtxos, ADDRESSES);
|
|
expect(result.events.length).toBe(1);
|
|
});
|
|
});
|
|
|
|
// ─── Redeemer / state mismatch ────────────────────────────────────────────────
|
|
//
|
|
// M1-D-fix: When the redeemer disagrees with the on-chain prior state,
|
|
// blockProcessor must emit NO event and call console.warn.
|
|
// e.g. "Accept" redeemer on an Open escrow is invalid.
|
|
// e.g. "Reclaim" redeemer on a Submitted escrow is invalid.
|
|
|
|
describe("blockProcessor — redeemer / state mismatch", () => {
|
|
it("Accept redeemer on Open escrow → no event emitted, console.warn called", () => {
|
|
const postTxId = "c".repeat(64);
|
|
const knownUtxos = new Map([
|
|
[postTxId + "#0", { address: ESCROW_SCRIPT_ADDRESS, datumHex: buildEscrowDatumHex({ state: "Open" }) }],
|
|
]);
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
// Inline mismatch: Accept redeemer on an Open-state escrow is nonsensical
|
|
const tx = {
|
|
id: "m1".repeat(32),
|
|
inputs: [{ transaction: { id: postTxId }, index: 0 }],
|
|
outputs: [{
|
|
address: "addr_test1vqfrg4ncjz4ummcjx3t83y9tehh3ydzk0zg2hn00zg69v7q7sa96j",
|
|
value: { ada: { lovelace: 4_000_000 } },
|
|
}],
|
|
redeemers: { [postTxId + "#0"]: "Accept" as const },
|
|
};
|
|
const block = buildMockBlock({ slot: 1_010_000, transactions: [tx] });
|
|
const result = processBlock(block, knownUtxos, ADDRESSES);
|
|
expect(result.events.length).toBe(0);
|
|
expect(warnSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it("Reclaim redeemer on Submitted escrow → no event emitted, console.warn called", () => {
|
|
const submitTxId = "e".repeat(64);
|
|
const knownUtxos = new Map([
|
|
[submitTxId + "#0", { address: ESCROW_SCRIPT_ADDRESS, datumHex: buildEscrowDatumHex({ state: "Submitted" }) }],
|
|
]);
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
// Reclaim on Submitted is invalid per ARCHITECTURE.md §4.3
|
|
const tx = {
|
|
id: "n1".repeat(32),
|
|
inputs: [{ transaction: { id: submitTxId }, index: 0 }],
|
|
outputs: [{
|
|
address: "addr_test1vqfrg4ncjz4ummcjx3t83y9tehh3ydzk0zg2hn00zg69v7q7sa96j",
|
|
value: { ada: { lovelace: 4_000_000 } },
|
|
}],
|
|
redeemers: { [submitTxId + "#0"]: "Reclaim" as const },
|
|
};
|
|
const block = buildMockBlock({ slot: 1_010_100, transactions: [tx] });
|
|
const result = processBlock(block, knownUtxos, ADDRESSES);
|
|
expect(result.events.length).toBe(0);
|
|
expect(warnSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it("Release redeemer on Open escrow → no event emitted, console.warn called", () => {
|
|
const postTxId = "c".repeat(64);
|
|
const knownUtxos = new Map([
|
|
[postTxId + "#0", { address: ESCROW_SCRIPT_ADDRESS, datumHex: buildEscrowDatumHex({ state: "Open" }) }],
|
|
]);
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
const tx = {
|
|
id: "o1".repeat(32),
|
|
inputs: [{ transaction: { id: postTxId }, index: 0 }],
|
|
outputs: [{
|
|
address: "addr_test1vqfrg4ncjz4ummcjx3t83y9tehh3ydzk0zg2hn00zg69v7q7sa96j",
|
|
value: { ada: { lovelace: 4_000_000 } },
|
|
}],
|
|
redeemers: { [postTxId + "#0"]: "Release" as const },
|
|
};
|
|
const block = buildMockBlock({ slot: 1_010_200, transactions: [tx] });
|
|
const result = processBlock(block, knownUtxos, ADDRESSES);
|
|
expect(result.events.length).toBe(0);
|
|
expect(warnSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it("Accept redeemer on Claimed escrow → no event emitted, console.warn called", () => {
|
|
const claimTxId = "d".repeat(64);
|
|
const knownUtxos = new Map([
|
|
[claimTxId + "#0", { address: ESCROW_SCRIPT_ADDRESS, datumHex: buildEscrowDatumHex({ state: "Claimed" }) }],
|
|
]);
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
const tx = {
|
|
id: "p1".repeat(32),
|
|
inputs: [{ transaction: { id: claimTxId }, index: 0 }],
|
|
outputs: [{
|
|
address: "addr_test1vqfrg4ncjz4ummcjx3t83y9tehh3ydzk0zg2hn00zg69v7q7sa96j",
|
|
value: { ada: { lovelace: 4_000_000 } },
|
|
}],
|
|
redeemers: { [claimTxId + "#0"]: "Accept" as const },
|
|
};
|
|
const block = buildMockBlock({ slot: 1_010_300, transactions: [tx] });
|
|
const result = processBlock(block, knownUtxos, ADDRESSES);
|
|
expect(result.events.length).toBe(0);
|
|
expect(warnSpy).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// ─── Error handling ───────────────────────────────────────────────────────────
|
|
|
|
describe("blockProcessor — error handling", () => {
|
|
it("malformed datum at advertAddress → no event emitted (does not throw)", () => {
|
|
const tx = buildMalformedDatumTx({ txId: "j".repeat(64), address: ADVERT_SCRIPT_ADDRESS });
|
|
const block = buildMockBlock({ slot: 1_003_000, transactions: [tx] });
|
|
// Should not throw — malformed datum is logged and skipped
|
|
const result = processBlock(block, new Map(), ADDRESSES);
|
|
expect(result.events.length).toBe(0);
|
|
});
|
|
|
|
it("malformed datum at escrowAddress → no event emitted (does not throw)", () => {
|
|
const tx = buildMalformedDatumTx({ txId: "k".repeat(64), address: ESCROW_SCRIPT_ADDRESS });
|
|
const block = buildMockBlock({ slot: 1_003_100, transactions: [tx] });
|
|
const result = processBlock(block, new Map(), ADDRESSES);
|
|
expect(result.events.length).toBe(0);
|
|
});
|
|
|
|
it("Byron-era block with no transactions field is handled gracefully", () => {
|
|
const block = buildByronBlock({ slot: 500_000 }) as any;
|
|
// transactions field is absent — must not throw
|
|
const result = processBlock(block, new Map(), ADDRESSES);
|
|
expect(result.events.length).toBe(0);
|
|
});
|
|
|
|
it("block with transactions: null is handled gracefully", () => {
|
|
const block = { ...buildMockBlock({ slot: 600_000 }), transactions: null };
|
|
const result = processBlock(block as any, new Map(), ADDRESSES);
|
|
expect(result.events.length).toBe(0);
|
|
});
|
|
|
|
it("tx at untracked address generates no events", () => {
|
|
const tx = {
|
|
id: "l".repeat(64),
|
|
inputs: [{ transaction: { id: "prev" + "l".repeat(60) }, index: 0 }],
|
|
outputs: [{ address: "addr_test1quntracked", value: { ada: { lovelace: 1_000_000 } }, datum: buildAdvertDatumHex() }],
|
|
};
|
|
const block = buildMockBlock({ slot: 1_004_000, transactions: [tx] });
|
|
const result = processBlock(block, new Map(), ADDRESSES);
|
|
expect(result.events.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── Spend detection ──────────────────────────────────────────────────────────
|
|
|
|
describe("blockProcessor — spend-driven detection", () => {
|
|
it("spentRefs includes known UTxO refs consumed by the block", () => {
|
|
const postTxId = "c".repeat(64);
|
|
const knownUtxos = new Map([
|
|
[postTxId + "#0", { address: ESCROW_SCRIPT_ADDRESS, datumHex: buildEscrowDatumHex({ state: "Open" }) }],
|
|
]);
|
|
const tx = buildClaimEscrowTx({ txId: "d".repeat(64), spentRef: { txId: postTxId, index: 0 } });
|
|
const block = buildMockBlock({ slot: 1_001_100, transactions: [tx] });
|
|
const result = processBlock(block, knownUtxos, ADDRESSES);
|
|
expect(result.spentRefs.some(r => r.ref === postTxId + "#0")).toBe(true);
|
|
});
|
|
|
|
it("spentRefs does not include unknown UTxO refs", () => {
|
|
const tx = buildPostAdvertTx();
|
|
const block = buildMockBlock({ slot: 1_000_000, transactions: [tx] });
|
|
const result = processBlock(block, new Map(), ADDRESSES);
|
|
// All inputs are from untracked prev-txs
|
|
expect(result.spentRefs.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── Rollback via worker ──────────────────────────────────────────────────────
|
|
|
|
describe("blockProcessor — rollback integration note", () => {
|
|
it("rollback to before a Submit: escrow returns to Claimed state (via cache rollback)", () => {
|
|
// This test documents rollback semantics at the worker level.
|
|
// The actual mechanic is tested in sqlite-cache.test.ts (rollbackToSlot).
|
|
// Here we verify processBlock correctly tracks which events were at which slots.
|
|
const claimTxId = "d".repeat(64);
|
|
const knownUtxos = new Map([
|
|
[claimTxId + "#0", { address: ESCROW_SCRIPT_ADDRESS, datumHex: buildEscrowDatumHex({ state: "Claimed" }) }],
|
|
]);
|
|
const submitTx = buildSubmitEscrowTx({
|
|
txId: "e".repeat(64),
|
|
spentRef: { txId: claimTxId, index: 0 },
|
|
});
|
|
const block = buildMockBlock({ slot: 1_001_200, transactions: [submitTx] });
|
|
const result = processBlock(block, knownUtxos, ADDRESSES);
|
|
const ev = result.events.find(e => e.type === "SubmitEscrow");
|
|
expect(ev!.slot).toBe(1_001_200);
|
|
// If we rolled back to slot 1_001_100, this SubmitEscrow event at 1_001_200
|
|
// would be soft-deleted. The cache test validates that directly.
|
|
});
|
|
});
|