diff --git a/gateway/src/account/routes.ts b/gateway/src/account/routes.ts index 885cd21..11df4bd 100644 --- a/gateway/src/account/routes.ts +++ b/gateway/src/account/routes.ts @@ -19,6 +19,26 @@ import { badRequest, forbidden } from "../openai/errors.js"; const ACTIVE_ESCROW_STATES = new Set(["Open", "Claimed", "Submitted"]); +/** Bound on per-request escrow liveness lookups; rows past the cap count as + * locked unverified (a key with this many active escrows has bigger problems). */ +const LIVENESS_CHECK_CAP = 32; + +/** True when the escrow UTxO behind an indexer row is still unspent. The + * indexer never updates rows on terminal spends (Accept/Reclaim/Release + * consume the escrow without a new escrow output), so "active" rows can be + * long spent. Unparseable refs and failed lookups count as live so errors + * inflate the locked figure rather than hide funds. */ +async function escrowUtxoIsLive(deps: GatewayDeps, utxoRef: string): Promise { + const m = /^([0-9a-fA-F]{64})#(\d+)$/.exec(utxoRef); + if (!m) return true; + try { + const utxo = await deps.chain.queryUtxo({ txHash: m[1], index: Number(m[2]) }); + return utxo !== null; + } catch { + return true; + } +} + function ap3x(lovelace: bigint): string { return (Number(lovelace) / 1e6).toFixed(2); } @@ -74,8 +94,15 @@ export function makeAccountHandler(deps: GatewayDeps) { if (r.ok) { const rows = (await r.json()) as Array>; if (Array.isArray(rows)) { - for (const row of rows) { - if (!ACTIVE_ESCROW_STATES.has(String(row.state))) continue; + const active = rows.filter((row) => ACTIVE_ESCROW_STATES.has(String(row.state))); + const liveness = await Promise.all( + active.map((row, i) => + i < LIVENESS_CHECK_CAP ? escrowUtxoIsLive(deps, String(row.utxo_ref ?? "")) : Promise.resolve(true), + ), + ); + for (let i = 0; i < active.length; i++) { + if (!liveness[i]) continue; + const row = active[i]; lockedInEscrow += BigInt(String(row.payment_lovelace ?? "0")) + BigInt(String(row.buyer_bond_lovelace ?? "0")) + diff --git a/tests/unit/gateway-account-locked.test.ts b/tests/unit/gateway-account-locked.test.ts new file mode 100644 index 0000000..e0fe0ee --- /dev/null +++ b/tests/unit/gateway-account-locked.test.ts @@ -0,0 +1,132 @@ +/** + * gateway-account-locked.test.ts — /account locked_in_escrow_lovelace accuracy. + * + * The indexer never updates escrow rows on terminal spends (Accept/Reclaim/ + * Release consume the UTxO without producing a new escrow output), so rows + * can sit in an "active" state long after the funds moved. The account + * handler must verify each active row's UTxO is still unspent on-chain + * before counting it as locked; a spent ref is a stale row, not locked funds. + * + * Note: supplier_bond stays in the sum on purpose — the buyer fronts + * payment + buyer_bond + supplier_bond at PostEscrow (see postEscrow.ts + * totalLocked), so all three legs are the key's money while the escrow lives. + */ + +import { describe, it, expect, vi } from "vitest"; +import type { Request, Response } from "express"; +import { makeAccountHandler } from "../../gateway/src/account/routes.js"; + +const LIVE_REF = "a".repeat(64) + "#0"; +const STALE_REF = "b".repeat(64) + "#1"; + +function escrowRow(utxoRef: string, state: string) { + return { + utxo_ref: utxoRef, + state, + payment_lovelace: "2000000", + buyer_bond_lovelace: "1000000", + supplier_bond_lovelace: "1000000", + }; +} + +function fakeKeyRow() { + return { + id: "key-1", + key_hash: "h", + key_prefix: "vk_live_abc", + label: null, + wallet_pkh: "c".repeat(56), + deposit_address: "addr1_deposit", + enc_priv_nonce: "", + enc_priv_ct: "", + enc_priv_tag: "", + master_key_version: 1, + created_at: 0, + disabled: 0, + demo: 0, + }; +} + +function makeDeps(rows: Array>, opts?: { queryUtxo?: (ref: { txHash: string; index: number }) => Promise }) { + const queryUtxo = + opts?.queryUtxo ?? + (async (ref: { txHash: string; index: number }) => { + const canonical = `${ref.txHash}#${ref.index}`; + return canonical === LIVE_REF ? { ref, lovelace: 4_000_000n } : null; + }); + return { + config: { indexerUrl: "http://indexer.test", networkId: 1, masterKeyHex: "00" }, + store: { + sumCostLovelace: () => 0n, + countUsage: () => 0, + listUsage: () => [], + }, + chain: { + queryUtxosByAddress: async () => [], + queryUtxo: vi.fn(queryUtxo), + }, + registry: {}, + fetchFn: vi.fn(async () => ({ + ok: true, + json: async () => rows, + })), + } as never; +} + +/** + * asyncHandler returns a fire-and-forget (req, res) => void wrapper, so the + * test must await the res.json() call itself, not the handler invocation. + * Errors also surface through json() (sendError maps them to a JSON body). + */ +async function callAccount(deps: never): Promise> { + const handler = makeAccountHandler(deps); + const req = { gatewayKey: fakeKeyRow() } as unknown as Request; + const body = await new Promise((resolveBody, rejectBody) => { + const timer = setTimeout(() => rejectBody(new Error("handler never responded")), 2_000); + const res = { + status: (_code: number) => res, + json: (b: unknown) => { + clearTimeout(timer); + resolveBody(b); + return res; + }, + } as unknown as Response; + handler(req, res); + }); + if ((body as { error?: unknown }).error) { + throw new Error(`handler returned error body: ${JSON.stringify(body)}`); + } + return body as Record; +} + +describe("GET /account — locked_in_escrow_lovelace", () => { + it("counts payment + both bonds for an active escrow whose UTxO is live", async () => { + const body = await callAccount(makeDeps([escrowRow(LIVE_REF, "Open")])); + expect(body.balance.locked_in_escrow_lovelace).toBe("4000000"); + }); + + it("excludes an active-state row whose UTxO is terminally spent (stale indexer row)", async () => { + const body = await callAccount( + makeDeps([escrowRow(LIVE_REF, "Open"), escrowRow(STALE_REF, "Submitted")]), + ); + // Only the live escrow counts: 4_000_000, not 8_000_000. + expect(body.balance.locked_in_escrow_lovelace).toBe("4000000"); + }); + + it("skips terminal-state rows without a chain lookup", async () => { + const deps = makeDeps([escrowRow(LIVE_REF, "Accepted"), escrowRow(STALE_REF, "Reclaimed")]); + const body = await callAccount(deps); + expect(body.balance.locked_in_escrow_lovelace).toBe("0"); + expect((deps as { chain: { queryUtxo: ReturnType } }).chain.queryUtxo).not.toHaveBeenCalled(); + }); + + it("counts a row conservatively when the chain lookup fails (best-effort)", async () => { + const deps = makeDeps([escrowRow(LIVE_REF, "Open")], { + queryUtxo: async () => { + throw new Error("ogmios unreachable"); + }, + }); + const body = await callAccount(deps); + expect(body.balance.locked_in_escrow_lovelace).toBe("4000000"); + }); +});