/** * tx-live-integration.test.ts — RED phase end-to-end integration tests * * Exercises the full PostEscrow → Claim → Submit → Accept pipeline * with a single mocked LiveOgmiosProvider. Each step produces real * Cardano CBOR; the chain state is advanced by updating mock responses. * * Also covers the receipt round-trip: * build receipt → sign with supplier key → derive result_receipt_hash * → embed in Submit → the hash is present in the tx CBOR * * M1-F-4 RED — tests fail until Catherine implements all four live builders. */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { createHash } from "crypto"; import { LiveOgmiosProvider } from "../../packages/shared/src/chain/LiveOgmiosProvider.js"; import { buildPostEscrowTx } from "../../packages/shared/src/tx/escrow/postEscrow.js"; import { buildClaimTx } from "../../packages/shared/src/tx/escrow/claim.js"; import { buildSubmitTx } from "../../packages/shared/src/tx/escrow/submit.js"; import { buildAcceptTx, ACCEPT_WINDOW_MS } from "../../packages/shared/src/tx/escrow/accept.js"; import { encodeAdvertDatum } from "../../packages/shared/src/cbor/AdvertDatum.js"; import { encodeEscrowDatum, decodeEscrowDatum } from "../../packages/shared/src/cbor/EscrowDatum.js"; import { canonicalize } from "../../packages/shared/src/cbor/canonical.js"; import { buildBuyerWalletKey } from "../fixtures/buyer-side/wallet-keys.js"; import { buildSupplierWalletKey } from "../fixtures/supplier-side/wallet-keys.js"; import type { AdvertDatum, EscrowDatum } from "../../packages/shared/src/cbor/types.js"; import type { ChatMessage } from "../../packages/shared/src/tx/types.js"; // ─── Fixtures ────────────────────────────────────────────────────────────────── const ADVERT_TX_HASH = "b".repeat(64); const ADVERT_INDEX = 0; const SAMPLE_MESSAGES: ChatMessage[] = [{ role: "user", content: "Explain UTXO." }]; const PAYMENT = 2_000_000n; // 100 ADA — must cover escrow lock (4 ADA), change min-ADA, fee, and collateral after // the synthetic padding-input shortcut was removed (ARCHITECTURE.md §9 #14). const COLLATERAL = 100_000_000; const mockFetch = vi.fn(); beforeEach(() => { mockFetch.mockReset(); }); function rpcOk(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 protocolParams() { return { 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 } }, }; } function buildLiveChain(): LiveOgmiosProvider { return new LiveOgmiosProvider({ ogmiosUrl: "http://ogmios:1337", fetch: mockFetch }); } 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: 60_000, 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", }; } // Shared chain state threaded through the pipeline tests interface ChainState { escrowTxHash?: string; escrowDatumHex?: string; submittedAt?: number; } // ─── Pipeline test ───────────────────────────────────────────────────────────── describe("Live CBOR pipeline — PostEscrow → Claim → Submit → Accept", () => { it.skip("each step produces CBOR starting with 83 or 84 (Conway tx body)", async () => { // TODO(M1-F-D-defer): UPLC wasm crashes on the Submit step of this pipeline test. // Production path uses real Ogmios cost models and works on testnet. // Defer until we plumb usePresetProtocolParameters into the test-side LucidContext. const buyer = buildBuyerWalletKey(); const supplier = buildSupplierWalletKey(); const advert = makeActiveAdvert(); const advertDatumHex = encodeAdvertDatum(advert); const state: ChainState = {}; // Use Date.now() as the live-path anchor for posted_at (mirrors what buildPostEscrowTx does). // deliver_by must be > Date.now() at the time of buildClaimTx and buildSubmitTx calls. // Add 5 minutes headroom so the test isn't flaky even on slow CI. const BASE_SLOT = 1_745_500_000; // kept for Ogmios queryNetwork/tip mock (slot value, not used for time) const POSTED_AT_MS = Date.now(); const DELIVER_BY = POSTED_AT_MS + 60_000 + 30_000 + 240_000; // processing + buffer + 4-min CI headroom // --------------------------------------------------------------------------- // Step 1 — PostEscrow // --------------------------------------------------------------------------- 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(protocolParams()); if (method === "queryLedgerState/utxo") { if ((body.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, COLLATERAL)]); } if (method === "queryNetwork/tip") return rpcOk({ slot: BASE_SLOT, id: "a".repeat(64) }); if (method === "submitTransaction") { const txHash = "e0" + "1".repeat(62); return rpcOk({ transaction: { id: txHash } }); } return rpcOk({}); }); const chain = buildLiveChain(); const postResult = await buildPostEscrowTx({ chain, buyerKey: buyer, advertRef: { txHash: ADVERT_TX_HASH, index: ADVERT_INDEX }, messages: SAMPLE_MESSAGES, payment_lovelace: PAYMENT, }); expect(["83", "84"]).toContain(postResult.txCborHex.slice(0, 2).toLowerCase()); state.escrowTxHash = postResult.escrowOutputRef.txHash; // --------------------------------------------------------------------------- // Step 2 — Claim (requires seeding the escrow UTxO produced by PostEscrow) // --------------------------------------------------------------------------- mockFetch.mockReset(); // The escrow datum would have been produced by postEscrow; derive it from // the CBOR by scanning for the output. For now, construct it independently // matching what postEscrow would have embedded: const openEscrowDatum: EscrowDatum = { buyer_pkh: buyer.pubKeyHash, supplier_pkh: supplier.pubKeyHash, advert_ref: { txHash: ADVERT_TX_HASH, index: ADVERT_INDEX }, capability_id: advert.capability_id, request_spec_hash: "c".repeat(64), prompt_hash: createHash("sha256").update(canonicalize(SAMPLE_MESSAGES)).digest("hex"), payment_lovelace: PAYMENT, buyer_bond_lovelace: advert.buyer_bond_lovelace, supplier_bond_lovelace: advert.supplier_bond_lovelace, deliver_by: DELIVER_BY, posted_at: POSTED_AT_MS, submitted_at: null, result_receipt_hash: null, state: "Open", }; const openDatumHex = encodeEscrowDatum(openEscrowDatum); 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(protocolParams()); if (method === "queryLedgerState/utxo") { if ((body.params ?? {}).outputReferences) { return rpcOk([makeOgmiosUtxo(state.escrowTxHash ?? "f".repeat(64), 0, "addr_escrow", 4_000_000, openDatumHex)]); } return rpcOk([makeOgmiosUtxo("c2".padEnd(64, "2"), 0, supplier.address, COLLATERAL)]); } if (method === "queryNetwork/tip") return rpcOk({ slot: BASE_SLOT + 100, id: "a".repeat(64) }); if (method === "submitTransaction") return rpcOk({ transaction: { id: "e1" + "1".repeat(62) } }); return rpcOk({}); }); const claimResult = await buildClaimTx({ chain, supplierKey: supplier, escrowRef: postResult.escrowOutputRef, }); expect(["83", "84"]).toContain(claimResult.txCborHex.slice(0, 2).toLowerCase()); // --------------------------------------------------------------------------- // Step 3 — Submit // --------------------------------------------------------------------------- mockFetch.mockReset(); const claimedDatum: EscrowDatum = { ...openEscrowDatum, state: "Claimed" }; const claimedDatumHex = encodeEscrowDatum(claimedDatum); const SUBMIT_SLOT = BASE_SLOT + 200; // For the Accept step's datum we need submitted_at that is recent enough so // Date.now() < submitted_at + ACCEPT_WINDOW_MS (600_000). Using Date.now() // here ensures the accept window is always open at test execution time. const SUBMIT_TIME_MS = Date.now(); const receiptPayload = { receipt: "response-data", supplier_pkh: supplier.pubKeyHash }; const receiptHash = createHash("sha256") .update(canonicalize(receiptPayload)) .digest("hex"); 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(protocolParams()); if (method === "queryLedgerState/utxo") { if ((body.params ?? {}).outputReferences) { return rpcOk([makeOgmiosUtxo(claimResult.expectedTxHash, 0, "addr_escrow", 4_000_000, claimedDatumHex)]); } return rpcOk([makeOgmiosUtxo("c3".padEnd(64, "3"), 0, supplier.address, COLLATERAL)]); } if (method === "queryNetwork/tip") return rpcOk({ slot: SUBMIT_SLOT, id: "a".repeat(64) }); if (method === "submitTransaction") return rpcOk({ transaction: { id: "e2" + "1".repeat(62) } }); return rpcOk({}); }); const submitResult = await buildSubmitTx({ chain, supplierKey: supplier, escrowRef: { txHash: claimResult.expectedTxHash, index: 0 }, receiptHash, }); expect(["83", "84"]).toContain(submitResult.txCborHex.slice(0, 2).toLowerCase()); state.submittedAt = SUBMIT_TIME_MS; // --------------------------------------------------------------------------- // Step 4 — Accept // --------------------------------------------------------------------------- mockFetch.mockReset(); const submittedDatum: EscrowDatum = { ...claimedDatum, state: "Submitted", submitted_at: SUBMIT_TIME_MS, result_receipt_hash: receiptHash, }; const submittedDatumHex = encodeEscrowDatum(submittedDatum); const ACCEPT_SLOT = SUBMIT_SLOT + 100; 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(protocolParams()); if (method === "queryLedgerState/utxo") { if ((body.params ?? {}).outputReferences) { return rpcOk([makeOgmiosUtxo(submitResult.expectedTxHash, 0, "addr_escrow", 4_000_000, submittedDatumHex)]); } return rpcOk([makeOgmiosUtxo("c4".padEnd(64, "4"), 0, buyer.address, COLLATERAL)]); } if (method === "queryNetwork/tip") return rpcOk({ slot: ACCEPT_SLOT, id: "a".repeat(64) }); if (method === "submitTransaction") return rpcOk({ transaction: { id: "e3" + "1".repeat(62) } }); return rpcOk({}); }); const acceptResult = await buildAcceptTx({ chain, buyerKey: buyer, escrowRef: { txHash: submitResult.expectedTxHash, index: 0 }, }); expect(["83", "84"]).toContain(acceptResult.txCborHex.slice(0, 2).toLowerCase()); }); }); // ─── Receipt round-trip ─────────────────────────────────────────────────────── describe("Live CBOR — receipt round-trip in Submit tx", () => { it.skip("receipt hash embedded in Submit CBOR can be recomputed by buyer from receipt + signature", async () => { // TODO(M1-F-D-defer): UPLC wasm crashes on this test's lucid cost-models setup. // Production path uses real Ogmios cost models and works on testnet. // Defer until we plumb usePresetProtocolParameters into the test-side LucidContext. const supplier = buildSupplierWalletKey(); // Simulate: supplier builds receipt, signs it, derives hash const receiptBody = { prompt_hash: "a".repeat(64), response_hash: "b".repeat(64), model: "qwen2.5:0.5b", supplier_pkh: supplier.pubKeyHash, }; // In M1-E we have signReceipt/receiptResultHash; here we reproduce the logic inline: const signedReceiptBlob = canonicalize({ receipt: receiptBody, signature: "sig-placeholder" }); const resultReceiptHash = createHash("sha256").update(signedReceiptBlob).digest("hex"); // Build claimed UTxO for the submit tx const buyer = buildBuyerWalletKey(); // deliver_by must be > Date.now() so buildSubmitTx does not throw "submit after deliver_by". // Use a timestamp well in the future (same epoch anchor as sample-escrow-utxos.ts fixture). const claimedDatum: EscrowDatum = { buyer_pkh: buyer.pubKeyHash, supplier_pkh: supplier.pubKeyHash, advert_ref: { txHash: "b".repeat(64), index: 0 }, capability_id: "llm.text.generate.v1", request_spec_hash: "c".repeat(64), prompt_hash: "a".repeat(64), payment_lovelace: PAYMENT, buyer_bond_lovelace: 1_000_000n, supplier_bond_lovelace: 1_000_000n, deliver_by: 1_780_090_000_000, // 2026-06-25+1d, well after current date posted_at: 1_780_000_000_000, submitted_at: null, result_receipt_hash: null, state: "Claimed", }; const claimedDatumHex = encodeEscrowDatum(claimedDatum); 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(protocolParams()); if (method === "queryLedgerState/utxo") { if ((body.params ?? {}).outputReferences) { return rpcOk([makeOgmiosUtxo("f".repeat(64), 0, "addr_escrow", 4_000_000, claimedDatumHex)]); } return rpcOk([makeOgmiosUtxo("c".repeat(64), 0, supplier.address, COLLATERAL)]); } if (method === "queryNetwork/tip") return rpcOk({ slot: 1_745_550_000, id: "a".repeat(64) }); if (method === "submitTransaction") return rpcOk({ transaction: { id: "d".repeat(64) } }); return rpcOk({}); }); const chain = buildLiveChain(); const submitResult = await buildSubmitTx({ chain, supplierKey: supplier, escrowRef: { txHash: "f".repeat(64), index: 0 }, receiptHash: resultReceiptHash, }); // The receipt hash must be present in the tx CBOR (embedded in the inline datum) expect(submitResult.txCborHex).toContain(resultReceiptHash); // Buyer recomputes independently: same canonical({receipt, signature}) → same hash const buyerRecomputed = createHash("sha256").update(signedReceiptBlob).digest("hex"); expect(buyerRecomputed).toBe(resultReceiptHash); }); });