Files
agents-marketplace/tests/unit/read-only-ogmios-provider.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

483 lines
17 KiB
TypeScript
Raw Permalink Blame History

/**
* read-only-ogmios-provider.test.ts — RED phase tests for ReadOnlyOgmiosProvider
*
* Tests all public methods:
* - tip() → JSON-RPC queryNetwork/tip
* - queryUtxo() → JSON-RPC queryLedgerState/utxo (by ref)
* - queryUtxosByAddress()→ JSON-RPC queryLedgerState/utxo (by address)
* - evaluateTx() → JSON-RPC evaluateTransaction
* - submitTx() → throws NotSupportedError
* - awaitTx() → throws NotSupportedError
*
* Mock global.fetch per apex-dashboard ogmios-client.test.ts pattern.
* NO real network calls.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { ReadOnlyOgmiosProvider } from "../../packages/shared/src/chain/ReadOnlyOgmiosProvider.js";
import { NotSupportedError } from "../../packages/shared/src/chain/ChainProvider.js";
// ─── fetch mock setup ─────────────────────────────────────────────────────<E29480><E29480>──
const mockFetch = vi.fn();
beforeEach(() => {
globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch;
mockFetch.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
const TEST_URL = "http://localhost:1337";
// ─── Constructor ─────────────────────────────────────────────────────────────
describe("ReadOnlyOgmiosProvider — constructor", () => {
it("constructs without throwing", () => {
expect(() => new ReadOnlyOgmiosProvider({ ogmiosUrl: TEST_URL })).not.toThrow();
});
it("accepts any ogmiosUrl string", () => {
const provider = new ReadOnlyOgmiosProvider({ ogmiosUrl: "https://ogmios.vector.testnet.apexfusion.org" });
expect(provider).toBeDefined();
});
});
// ─── tip() ───────────────────────────────────────────────────────────────────
describe("ReadOnlyOgmiosProvider — tip()", () => {
it("sends a POST request to ogmiosUrl", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
jsonrpc: "2.0",
result: { slot: 12345678, id: "a".repeat(64) },
}),
});
const provider = new ReadOnlyOgmiosProvider({ ogmiosUrl: TEST_URL });
await provider.tip();
expect(mockFetch).toHaveBeenCalledWith(
TEST_URL,
expect.objectContaining({ method: "POST" }),
);
});
it("uses JSON-RPC method queryNetwork/tip", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
jsonrpc: "2.0",
result: { slot: 12345678, id: "a".repeat(64) },
}),
});
const provider = new ReadOnlyOgmiosProvider({ ogmiosUrl: TEST_URL });
await provider.tip();
const call = mockFetch.mock.calls[0];
const body = JSON.parse(call[1].body as string);
expect(body.jsonrpc).toBe("2.0");
expect(body.method).toBe("queryNetwork/tip");
});
it("returns the slot number from the response", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
jsonrpc: "2.0",
result: { slot: 12345678, id: "a".repeat(64) },
}),
});
const provider = new ReadOnlyOgmiosProvider({ ogmiosUrl: TEST_URL });
const slot = await provider.tip();
expect(slot).toBe(12345678);
});
it("throws on HTTP error response", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
statusText: "Internal Server Error",
});
const provider = new ReadOnlyOgmiosProvider({ ogmiosUrl: TEST_URL });
await expect(provider.tip()).rejects.toThrow();
});
it("throws on network error (fetch rejection)", async () => {
mockFetch.mockRejectedValueOnce(new Error("ECONNREFUSED"));
const provider = new ReadOnlyOgmiosProvider({ ogmiosUrl: TEST_URL });
await expect(provider.tip()).rejects.toThrow("ECONNREFUSED");
});
it("throws on JSON-RPC error in response", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
jsonrpc: "2.0",
error: { code: -32600, message: "Invalid Request" },
}),
});
const provider = new ReadOnlyOgmiosProvider({ ogmiosUrl: TEST_URL });
await expect(provider.tip()).rejects.toThrow();
});
});
// ─── queryUtxo() ─────────────────────────────────────────────────────────────
describe("ReadOnlyOgmiosProvider — queryUtxo()", () => {
const TEST_REF = { txHash: "a".repeat(64), index: 0 };
it("sends a POST request to ogmiosUrl", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ jsonrpc: "2.0", result: [] }),
});
const provider = new ReadOnlyOgmiosProvider({ ogmiosUrl: TEST_URL });
await provider.queryUtxo(TEST_REF);
expect(mockFetch).toHaveBeenCalledWith(
TEST_URL,
expect.objectContaining({ method: "POST" }),
);
});
it("uses JSON-RPC method queryLedgerState/utxo", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ jsonrpc: "2.0", result: [] }),
});
const provider = new ReadOnlyOgmiosProvider({ ogmiosUrl: TEST_URL });
await provider.queryUtxo(TEST_REF);
const call = mockFetch.mock.calls[0];
const body = JSON.parse(call[1].body as string);
expect(body.method).toBe("queryLedgerState/utxo");
});
it("includes the txHash and index in params", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ jsonrpc: "2.0", result: [] }),
});
const provider = new ReadOnlyOgmiosProvider({ ogmiosUrl: TEST_URL });
await provider.queryUtxo(TEST_REF);
const call = mockFetch.mock.calls[0];
const body = JSON.parse(call[1].body as string);
// Ogmios outputs-by-ref format: { outputReferences: [{transaction: {id}, index}] }
expect(JSON.stringify(body.params)).toContain(TEST_REF.txHash);
expect(JSON.stringify(body.params)).toContain(String(TEST_REF.index));
});
it("returns null when result array is empty (UTxO spent/never existed)", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ jsonrpc: "2.0", result: [] }),
});
const provider = new ReadOnlyOgmiosProvider({ ogmiosUrl: TEST_URL });
const result = await provider.queryUtxo(TEST_REF);
expect(result).toBeNull();
});
it("returns a Utxo when result array has one entry", async () => {
const mockOgmiosUtxo = {
transaction: { id: TEST_REF.txHash },
index: 0,
address: "addr_test1vz2fs4y3q9ekzs7zrr5xkjhq5dzl2wkxqh5hqz6v3l9kqaqklyp2",
value: { ada: { lovelace: 2_000_000 } },
datumHash: null,
datum: null,
script: null,
};
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ jsonrpc: "2.0", result: [mockOgmiosUtxo] }),
});
const provider = new ReadOnlyOgmiosProvider({ ogmiosUrl: TEST_URL });
const result = await provider.queryUtxo(TEST_REF);
expect(result).not.toBeNull();
expect(result!.ref.txHash).toBe(TEST_REF.txHash);
expect(result!.ref.index).toBe(0);
});
it("parses lovelace from ada.lovelace field", async () => {
const mockOgmiosUtxo = {
transaction: { id: TEST_REF.txHash },
index: 0,
address: "addr_test1vz2fs4y3q9ekzs7zrr5xkjhq5dzl2wkxqh5hqz6v3l9kqaqklyp2",
value: { ada: { lovelace: 5_000_000 } },
datum: null,
};
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ jsonrpc: "2.0", result: [mockOgmiosUtxo] }),
});
const provider = new ReadOnlyOgmiosProvider({ ogmiosUrl: TEST_URL });
const result = await provider.queryUtxo(TEST_REF);
expect(result!.lovelace).toBe(5_000_000n);
});
it("parses inline datum hex when present", async () => {
const mockOgmiosUtxo = {
transaction: { id: TEST_REF.txHash },
index: 0,
address: "addr_test1vz2fs4y3q9ekzs7zrr5xkjhq5dzl2wkxqh5hqz6v3l9kqaqklyp2",
value: { ada: { lovelace: 2_000_000 } },
datum: "d87980", // CBOR inline datum hex
};
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ jsonrpc: "2.0", result: [mockOgmiosUtxo] }),
});
const provider = new ReadOnlyOgmiosProvider({ ogmiosUrl: TEST_URL });
const result = await provider.queryUtxo(TEST_REF);
expect(result!.datumHex).toBe("d87980");
});
it("sets datumHex to null when no inline datum", async () => {
const mockOgmiosUtxo = {
transaction: { id: TEST_REF.txHash },
index: 0,
address: "addr_test1vz2fs4y3q9ekzs7zrr5xkjhq5dzl2wkxqh5hqz6v3l9kqaqklyp2",
value: { ada: { lovelace: 2_000_000 } },
datum: null,
};
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ jsonrpc: "2.0", result: [mockOgmiosUtxo] }),
});
const provider = new ReadOnlyOgmiosProvider({ ogmiosUrl: TEST_URL });
const result = await provider.queryUtxo(TEST_REF);
expect(result!.datumHex).toBeNull();
});
});
// ─── queryUtxosByAddress() ────────────────────────────────────────────────────
describe("ReadOnlyOgmiosProvider — queryUtxosByAddress()", () => {
const TEST_ADDR = "addr_test1vz2fs4y3q9ekzs7zrr5xkjhq5dzl2wkxqh5hqz6v3l9kqaqklyp2";
it("sends a POST to ogmiosUrl with queryLedgerState/utxo method", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ jsonrpc: "2.0", result: [] }),
});
const provider = new ReadOnlyOgmiosProvider({ ogmiosUrl: TEST_URL });
await provider.queryUtxosByAddress(TEST_ADDR);
const call = mockFetch.mock.calls[0];
const body = JSON.parse(call[1].body as string);
expect(body.method).toBe("queryLedgerState/utxo");
});
it("passes the address in params", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ jsonrpc: "2.0", result: [] }),
});
const provider = new ReadOnlyOgmiosProvider({ ogmiosUrl: TEST_URL });
await provider.queryUtxosByAddress(TEST_ADDR);
const call = mockFetch.mock.calls[0];
const body = JSON.parse(call[1].body as string);
expect(JSON.stringify(body.params)).toContain(TEST_ADDR);
});
it("returns an empty array when result is empty", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ jsonrpc: "2.0", result: [] }),
});
const provider = new ReadOnlyOgmiosProvider({ ogmiosUrl: TEST_URL });
const result = await provider.queryUtxosByAddress(TEST_ADDR);
expect(result).toEqual([]);
});
it("returns multiple UTxOs when result has multiple entries", async () => {
const mockUtxos = [
{
transaction: { id: "a".repeat(64) },
index: 0,
address: TEST_ADDR,
value: { ada: { lovelace: 2_000_000 } },
datum: null,
},
{
transaction: { id: "b".repeat(64) },
index: 1,
address: TEST_ADDR,
value: { ada: { lovelace: 3_000_000 } },
datum: null,
},
];
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ jsonrpc: "2.0", result: mockUtxos }),
});
const provider = new ReadOnlyOgmiosProvider({ ogmiosUrl: TEST_URL });
const result = await provider.queryUtxosByAddress(TEST_ADDR);
expect(result).toHaveLength(2);
expect(result[0].ref.txHash).toBe("a".repeat(64));
expect(result[1].ref.txHash).toBe("b".repeat(64));
});
});
// ─── evaluateTx() ─────────────────────────────────────────────────────────────
describe("ReadOnlyOgmiosProvider — evaluateTx()", () => {
const DUMMY_TX = "84a5008182582000".repeat(4);
it("sends a POST with JSON-RPC method evaluateTransaction", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
jsonrpc: "2.0",
result: { memory: 1000, steps: 2000 },
}),
});
const provider = new ReadOnlyOgmiosProvider({ ogmiosUrl: TEST_URL });
await provider.evaluateTx(DUMMY_TX);
const call = mockFetch.mock.calls[0];
const body = JSON.parse(call[1].body as string);
expect(body.method).toBe("evaluateTransaction");
});
it("passes the tx CBOR hex in params", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
jsonrpc: "2.0",
result: { memory: 1000, steps: 2000 },
}),
});
const provider = new ReadOnlyOgmiosProvider({ ogmiosUrl: TEST_URL });
await provider.evaluateTx(DUMMY_TX);
const call = mockFetch.mock.calls[0];
const body = JSON.parse(call[1].body as string);
expect(JSON.stringify(body.params)).toContain(DUMMY_TX);
});
it("returns {ok: true, cost: {memory, steps}} on success", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
jsonrpc: "2.0",
result: [{ validator: "escrow", budget: { memory: 5000, cpu: 10000 } }],
}),
});
const provider = new ReadOnlyOgmiosProvider({ ogmiosUrl: TEST_URL });
const result = await provider.evaluateTx(DUMMY_TX);
expect(result.ok).toBe(true);
});
it("returns {ok: false, error: ...} on JSON-RPC error (script failure)", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
jsonrpc: "2.0",
error: { code: 3010, message: "Script execution failure" },
}),
});
const provider = new ReadOnlyOgmiosProvider({ ogmiosUrl: TEST_URL });
const result = await provider.evaluateTx(DUMMY_TX);
expect(result.ok).toBe(false);
expect(typeof result.error).toBe("string");
});
it("throws on HTTP 500 error", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
statusText: "Internal Server Error",
});
const provider = new ReadOnlyOgmiosProvider({ ogmiosUrl: TEST_URL });
await expect(provider.evaluateTx(DUMMY_TX)).rejects.toThrow();
});
});
// ─── submitTx() — throws NotSupportedError ────────────────────────────────────
describe("ReadOnlyOgmiosProvider — submitTx() is not supported", () => {
it("throws NotSupportedError", async () => {
const provider = new ReadOnlyOgmiosProvider({ ogmiosUrl: TEST_URL });
await expect(provider.submitTx("deadbeef")).rejects.toThrow(NotSupportedError);
});
it("error message references 'submitTx'", async () => {
const provider = new ReadOnlyOgmiosProvider({ ogmiosUrl: TEST_URL });
let caught: unknown;
try {
await provider.submitTx("deadbeef");
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(NotSupportedError);
expect((caught as NotSupportedError).op).toBe("submitTx");
});
it("does NOT call fetch when submitTx is called", async () => {
const provider = new ReadOnlyOgmiosProvider({ ogmiosUrl: TEST_URL });
try { await provider.submitTx("deadbeef"); } catch { /* expected */ }
expect(mockFetch).not.toHaveBeenCalled();
});
});
// ─── awaitTx() — throws NotSupportedError ────────────────────────────────────
describe("ReadOnlyOgmiosProvider — awaitTx() is not supported", () => {
it("throws NotSupportedError", async () => {
const provider = new ReadOnlyOgmiosProvider({ ogmiosUrl: TEST_URL });
await expect(provider.awaitTx("a".repeat(64), 1000)).rejects.toThrow(NotSupportedError);
});
it("error message references 'awaitTx'", async () => {
const provider = new ReadOnlyOgmiosProvider({ ogmiosUrl: TEST_URL });
let caught: unknown;
try {
await provider.awaitTx("a".repeat(64), 1000);
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(NotSupportedError);
expect((caught as NotSupportedError).op).toBe("awaitTx");
});
it("does NOT call fetch when awaitTx is called", async () => {
const provider = new ReadOnlyOgmiosProvider({ ogmiosUrl: TEST_URL });
try { await provider.awaitTx("a".repeat(64), 100); } catch { /* expected */ }
expect(mockFetch).not.toHaveBeenCalled();
});
});