Files
agents-marketplace/tests/unit/tx-post-escrow-live-time.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

357 lines
15 KiB
TypeScript

/**
* tx-post-escrow-live-time.test.ts — RED phase tests for M1-F-time-cleanup
*
* Verifies that buildPostEscrowTx() with a LiveOgmiosProvider derives time
* fields using Date.now() (real POSIX ms) rather than mockSlotToWallclockMs(tipSlot).
*
* Key production decisions baked in:
* - Live backend: posted_at = Date.now()
* - Live backend: deliver_by = Date.now() + advert.max_processing_ms + NETWORK_BUFFER_MS
* - Mock backend (existing): posted_at = mockSlotToWallclockMs(tipSlot) — UNCHANGED
*
* All tests use vi.useFakeTimers + vi.setSystemTime to lock Date.now() to a
* fixed reference. Restored in afterEach.
*
* Mock-backend tests in tx-post-escrow.test.ts are NOT modified.
*
* M1-F-time-cleanup RED — fail until Catherine updates postEscrow.ts live path.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { LiveOgmiosProvider } from "../../packages/shared/src/chain/LiveOgmiosProvider.js";
import { MockChainProvider } from "../../packages/shared/src/chain/MockChainProvider.js";
import { buildPostEscrowTx } from "../../packages/shared/src/tx/escrow/postEscrow.js";
import { decodeEscrowDatum } from "../../packages/shared/src/cbor/EscrowDatum.js";
import { encodeAdvertDatum } from "../../packages/shared/src/cbor/AdvertDatum.js";
import { loadBlueprint } from "../../packages/shared/src/tx/blueprint.js";
import { NETWORK_BUFFER_MS, mockSlotToWallclockMs } from "../../packages/shared/src/tx/internal/constants.js";
import { buildBuyerWalletKey } from "../fixtures/buyer-side/wallet-keys.js";
import { buildSupplierWalletKey } from "../fixtures/supplier-side/wallet-keys.js";
import type { AdvertDatum } from "../../packages/shared/src/cbor/types.js";
import type { ChatMessage } from "../../packages/shared/src/tx/types.js";
// ─── Fixed fake time ──────────────────────────────────────────────────────────
const FAKE_NOW_DATE = new Date("2026-04-27T12:00:00Z");
const FAKE_NOW_MS = FAKE_NOW_DATE.getTime(); // 1745748000000
// ─── Fixtures ──────────────────────────────────────────────────────────────────
const ADVERT_TX_HASH = "b".repeat(64);
const ADVERT_INDEX = 0;
const SAMPLE_MESSAGES: ChatMessage[] = [{ role: "user", content: "What is 2+2?" }];
const PAYMENT = 2_000_000n;
const MAX_PROCESSING_MS = 60_000;
const mockFetch = vi.fn();
beforeEach(() => {
mockFetch.mockReset();
vi.useFakeTimers();
vi.setSystemTime(FAKE_NOW_DATE);
});
afterEach(() => {
vi.useRealTimers();
});
function makeActiveAdvert(): AdvertDatum {
const supplier = buildSupplierWalletKey();
return {
supplier_pkh: supplier.pubKeyHash,
capability_id: "llm.text.generate.v1",
model: "qwen2.5:0.5b",
max_output_tokens: 512,
max_processing_ms: MAX_PROCESSING_MS,
price_lovelace: PAYMENT,
supplier_bond_lovelace: 1_000_000n,
buyer_bond_lovelace: 1_000_000n,
endpoint_url: "https://supplier.example.com/v1",
detail_uri: "ipfs://Qm000",
detail_hash: "a".repeat(64),
advertised_at: 1_745_500_000_000,
status: "Active",
};
}
function rpcOk<T>(result: T) {
return { ok: true, json: async () => ({ jsonrpc: "2.0", result }) };
}
function makeOgmiosUtxo(txId: string, index: number, address: string, lovelace: number, datumHex?: string) {
return {
transaction: { id: txId },
index,
address,
value: { ada: { lovelace } },
datum: datumHex ?? null,
datumHash: null,
script: null,
};
}
function setupLiveHappyPath(): void {
const buyer = buildBuyerWalletKey();
const advert = makeActiveAdvert();
const advertDatumHex = encodeAdvertDatum(advert);
mockFetch.mockImplementation(async (_url: string, init?: RequestInit) => {
const body = JSON.parse((init?.body as string) ?? "{}");
const method: string = body.method ?? "";
if (method === "queryLedgerState/protocolParameters") {
return rpcOk({
minFeeCoefficient: 44,
minFeeConstant: { ada: { lovelace: 155381 } },
maxTransactionSize: { bytes: 16384 },
maxValueSize: { bytes: 5000 },
stakeCredentialDeposit: { ada: { lovelace: 2000000 } },
stakePoolDeposit: { ada: { lovelace: 500000000 } },
prices: { memory: "0.0577", steps: "0.0000721" },
maxExecutionUnitsPerTransaction: { memory: 14000000, cpu: 10000000000 },
coinsPerUtxoByte: { ada: { lovelace: 4310 } },
collateralPercentage: 150,
maxCollateralInputs: 3,
plutusCostModels: { "plutus:v1": {}, "plutus:v2": {}, "plutus:v3": {} },
monetaryExpansion: "0.003",
treasuryExpansion: "0.2",
minStakePoolCost: { ada: { lovelace: 340000000 } },
minFeeReferenceScripts: { base: 15 },
governanceActionDeposit: { ada: { lovelace: 100000000000 } },
delegateRepresentativeDeposit: { ada: { lovelace: 500000000 } },
});
}
if (method === "queryLedgerState/utxo") {
const params = body.params ?? {};
if (params.outputReferences) {
return rpcOk([makeOgmiosUtxo(ADVERT_TX_HASH, ADVERT_INDEX, "addr_advert", 2_000_000, advertDatumHex)]);
}
return rpcOk([makeOgmiosUtxo("c".repeat(64), 0, buyer.address, 100_000_000)]);
}
if (method === "queryNetwork/tip") {
// Tip slot is arbitrary for live path — time fields come from Date.now(), not tip
return rpcOk({ slot: 100_000, id: "a".repeat(64) });
}
if (method === "submitTransaction") {
return rpcOk({ transaction: { id: "d".repeat(64) } });
}
return rpcOk({});
});
}
// ─── A. posted_at uses Date.now() on live backend ────────────────────────────
describe("buildPostEscrowTx() [live] — posted_at is Date.now() not slot*1000", () => {
it("escrowDatum.posted_at equals faked Date.now() on live backend", async () => {
setupLiveHappyPath();
const chain = new LiveOgmiosProvider({ ogmiosUrl: "http://ogmios:1337", fetch: mockFetch });
const buyer = buildBuyerWalletKey();
const result = await buildPostEscrowTx({
chain,
buyerKey: buyer,
advertRef: { txHash: ADVERT_TX_HASH, index: ADVERT_INDEX },
messages: SAMPLE_MESSAGES,
payment_lovelace: PAYMENT,
});
// Decode the datum from the tx CBOR to inspect posted_at
// The datum hex is embedded inside the CBOR; we can find it by looking for
// the encoded escrow datum substring. Instead, we rebuild from the result
// escrowOutputRef and verify via mock chain state.
// The produced tx CBOR must contain the encoded datum.
// We assert the datum decoded from the tx CBOR contains posted_at = FAKE_NOW_MS.
// Strategy: since we control Date.now() via fake timers, and the live path
// must use Date.now(), the datum's posted_at field must equal FAKE_NOW_MS.
// We verify indirectly: the CBOR result has the datum embedded; we locate
// the expected deliver_by based on FAKE_NOW_MS and the advert spec.
const expectedPostedAt = FAKE_NOW_MS;
const expectedDeliverBy = FAKE_NOW_MS + MAX_PROCESSING_MS + NETWORK_BUFFER_MS;
// The result CBOR must contain the encoded datum which holds posted_at.
// We can reconstruct the expected datum hex and check it appears in the CBOR.
// This is the same approach as tx-claim-live.test.ts "contains datum hex".
const advert = makeActiveAdvert();
const { encodeEscrowDatum } = await import("../../packages/shared/src/cbor/EscrowDatum.js");
const { canonicalize } = await import("../../packages/shared/src/cbor/canonical.js");
const { createHash } = await import("crypto");
const promptHash = createHash("sha256").update(canonicalize(SAMPLE_MESSAGES)).digest("hex");
const requestSpecHash = createHash("sha256")
.update(canonicalize({ capability_id: advert.capability_id, max_output_tokens: advert.max_output_tokens, model: advert.model }))
.digest("hex");
const expectedDatumHex = encodeEscrowDatum({
buyer_pkh: buyer.pubKeyHash,
supplier_pkh: advert.supplier_pkh,
advert_ref: { txHash: ADVERT_TX_HASH, index: ADVERT_INDEX },
capability_id: advert.capability_id,
request_spec_hash: requestSpecHash,
prompt_hash: promptHash,
payment_lovelace: PAYMENT,
buyer_bond_lovelace: advert.buyer_bond_lovelace,
supplier_bond_lovelace: advert.supplier_bond_lovelace,
deliver_by: expectedDeliverBy,
posted_at: expectedPostedAt,
submitted_at: null,
result_receipt_hash: null,
state: "Open",
});
expect(result.txCborHex).toContain(expectedDatumHex);
});
it("escrowDatum.posted_at is NOT slot*1000 on live backend (mock convention absent)", async () => {
setupLiveHappyPath();
const chain = new LiveOgmiosProvider({ ogmiosUrl: "http://ogmios:1337", fetch: mockFetch });
const buyer = buildBuyerWalletKey();
const result = await buildPostEscrowTx({
chain,
buyerKey: buyer,
advertRef: { txHash: ADVERT_TX_HASH, index: ADVERT_INDEX },
messages: SAMPLE_MESSAGES,
payment_lovelace: PAYMENT,
});
// The tip slot is 100_000; mock convention would give 100_000_000 ms.
// Real Date.now() (faked) gives FAKE_NOW_MS = 1745748000000.
// These must NOT be equal.
const MOCK_CONVENTION_POSTED_AT = mockSlotToWallclockMs(100_000); // = 100_000_000
const { encodeEscrowDatum } = await import("../../packages/shared/src/cbor/EscrowDatum.js");
const { canonicalize } = await import("../../packages/shared/src/cbor/canonical.js");
const { createHash } = await import("crypto");
const advert = makeActiveAdvert();
const promptHash = createHash("sha256").update(canonicalize(SAMPLE_MESSAGES)).digest("hex");
const requestSpecHash = createHash("sha256")
.update(canonicalize({ capability_id: advert.capability_id, max_output_tokens: advert.max_output_tokens, model: advert.model }))
.digest("hex");
const mockConventionDatumHex = encodeEscrowDatum({
buyer_pkh: buyer.pubKeyHash,
supplier_pkh: advert.supplier_pkh,
advert_ref: { txHash: ADVERT_TX_HASH, index: ADVERT_INDEX },
capability_id: advert.capability_id,
request_spec_hash: requestSpecHash,
prompt_hash: promptHash,
payment_lovelace: PAYMENT,
buyer_bond_lovelace: advert.buyer_bond_lovelace,
supplier_bond_lovelace: advert.supplier_bond_lovelace,
deliver_by: MOCK_CONVENTION_POSTED_AT + MAX_PROCESSING_MS + NETWORK_BUFFER_MS,
posted_at: MOCK_CONVENTION_POSTED_AT,
submitted_at: null,
result_receipt_hash: null,
state: "Open",
});
// The CBOR must NOT contain the mock-convention datum
expect(result.txCborHex).not.toContain(mockConventionDatumHex);
});
});
// ─── B. deliver_by = Date.now() + max_processing_ms + NETWORK_BUFFER_MS ──────
describe("buildPostEscrowTx() [live] — deliver_by is Date.now() + processing + buffer", () => {
it("deliver_by in live datum = Date.now() + max_processing_ms + 30_000", async () => {
setupLiveHappyPath();
const chain = new LiveOgmiosProvider({ ogmiosUrl: "http://ogmios:1337", fetch: mockFetch });
const buyer = buildBuyerWalletKey();
const expectedDeliverBy = FAKE_NOW_MS + MAX_PROCESSING_MS + NETWORK_BUFFER_MS;
const result = await buildPostEscrowTx({
chain,
buyerKey: buyer,
advertRef: { txHash: ADVERT_TX_HASH, index: ADVERT_INDEX },
messages: SAMPLE_MESSAGES,
payment_lovelace: PAYMENT,
});
const { encodeEscrowDatum } = await import("../../packages/shared/src/cbor/EscrowDatum.js");
const { canonicalize } = await import("../../packages/shared/src/cbor/canonical.js");
const { createHash } = await import("crypto");
const advert = makeActiveAdvert();
const promptHash = createHash("sha256").update(canonicalize(SAMPLE_MESSAGES)).digest("hex");
const requestSpecHash = createHash("sha256")
.update(canonicalize({ capability_id: advert.capability_id, max_output_tokens: advert.max_output_tokens, model: advert.model }))
.digest("hex");
const expectedDatumHex = encodeEscrowDatum({
buyer_pkh: buyer.pubKeyHash,
supplier_pkh: advert.supplier_pkh,
advert_ref: { txHash: ADVERT_TX_HASH, index: ADVERT_INDEX },
capability_id: advert.capability_id,
request_spec_hash: requestSpecHash,
prompt_hash: promptHash,
payment_lovelace: PAYMENT,
buyer_bond_lovelace: advert.buyer_bond_lovelace,
supplier_bond_lovelace: advert.supplier_bond_lovelace,
deliver_by: expectedDeliverBy,
posted_at: FAKE_NOW_MS,
submitted_at: null,
result_receipt_hash: null,
state: "Open",
});
expect(result.txCborHex).toContain(expectedDatumHex);
});
});
// ─── C. Mock backend still uses slot-based convention ────────────────────────
/**
* Decode the synthetic "testTxBody" hex format used by the mock CBOR path.
* Format: [4 bytes BE uint32 JSON length][JSON UTF-8][optional trailer]
* Returns the parsed TestTxBody object.
*/
function decodeSyntheticTxBody(txCborHex: string): Record<string, unknown> {
const lengthHex = txCborHex.slice(0, 8);
const jsonByteLength = parseInt(lengthHex, 16);
const jsonHex = txCborHex.slice(8, 8 + jsonByteLength * 2);
const jsonStr = Buffer.from(jsonHex, "hex").toString("utf8");
return JSON.parse(jsonStr) as Record<string, unknown>;
}
describe("buildPostEscrowTx() [mock] — posted_at unchanged (still slot*1000)", () => {
it("mock backend posted_at = mockSlotToWallclockMs(tipSlot), NOT Date.now()", async () => {
// Mock path must remain unchanged — Tier-1 tests must not regress.
const TIP_SLOT = 1_000;
const expectedPostedAt = mockSlotToWallclockMs(TIP_SLOT); // = 1_000_000
const advert = makeActiveAdvert();
const advertDatumHex = encodeAdvertDatum(advert);
const chain = new MockChainProvider();
chain.advanceSlot(TIP_SLOT);
// Seed the advert UTxO
chain.seed({
ref: { txHash: ADVERT_TX_HASH, index: ADVERT_INDEX },
address: "addr_advert",
lovelace: 2_000_000n,
assets: {},
datumHex: advertDatumHex,
scriptRef: null,
});
const buyer = buildBuyerWalletKey();
const result = await buildPostEscrowTx({
chain,
buyerKey: buyer,
advertRef: { txHash: ADVERT_TX_HASH, index: ADVERT_INDEX },
messages: SAMPLE_MESSAGES,
payment_lovelace: PAYMENT,
});
// Decode the mock-path synthetic CBOR to verify posted_at
const decoded = decodeSyntheticTxBody(result.txCborHex);
const outputs = decoded.outputs as Array<{ datumHex?: string }>;
const escrowOutput = outputs.find((o) => o.datumHex);
expect(escrowOutput).toBeDefined();
const datum = decodeEscrowDatum(escrowOutput!.datumHex!);
// Mock convention: posted_at must be slot * 1000, NOT Date.now() (1745748000000)
expect(datum.posted_at).toBe(expectedPostedAt);
expect(datum.posted_at).not.toBe(FAKE_NOW_MS);
});
});