1 Commits

Author SHA1 Message Date
FilipBl4gojevic
85ca58e4b2 fix(gateway): stop counting terminally-spent escrows as locked funds
Some checks failed
CI / typecheck + vitest (Node 20) (push) Has been cancelled
The indexer never updates escrow rows on terminal spends (Accept /
Reclaim / Release consume the UTxO without producing a new escrow
output), so /account summed rows whose funds had long moved and
locked_in_escrow_lovelace over-reported for 35+ minutes after
settlement.

The account handler now verifies each active-state row's UTxO is still
unspent (chain.queryUtxo) before counting it, capped at 32 lookups per
request with the tail counted unverified. Unparseable refs and failed
lookups count as live on purpose: errors inflate the figure rather than
hide locked funds. supplier_bond stays in the sum - the buyer fronts
all three legs at PostEscrow (see postEscrow.ts totalLocked), so it is
the key's money while the escrow lives.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 12:33:34 +02:00
2 changed files with 161 additions and 2 deletions

View File

@@ -19,6 +19,26 @@ import { badRequest, forbidden } from "../openai/errors.js";
const ACTIVE_ESCROW_STATES = new Set(["Open", "Claimed", "Submitted"]); 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<boolean> {
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 { function ap3x(lovelace: bigint): string {
return (Number(lovelace) / 1e6).toFixed(2); return (Number(lovelace) / 1e6).toFixed(2);
} }
@@ -74,8 +94,15 @@ export function makeAccountHandler(deps: GatewayDeps) {
if (r.ok) { if (r.ok) {
const rows = (await r.json()) as Array<Record<string, unknown>>; const rows = (await r.json()) as Array<Record<string, unknown>>;
if (Array.isArray(rows)) { if (Array.isArray(rows)) {
for (const row of rows) { const active = rows.filter((row) => ACTIVE_ESCROW_STATES.has(String(row.state)));
if (!ACTIVE_ESCROW_STATES.has(String(row.state))) continue; 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 += lockedInEscrow +=
BigInt(String(row.payment_lovelace ?? "0")) + BigInt(String(row.payment_lovelace ?? "0")) +
BigInt(String(row.buyer_bond_lovelace ?? "0")) + BigInt(String(row.buyer_bond_lovelace ?? "0")) +

View File

@@ -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<Record<string, unknown>>, opts?: { queryUtxo?: (ref: { txHash: string; index: number }) => Promise<unknown> }) {
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<Record<string, never>> {
const handler = makeAccountHandler(deps);
const req = { gatewayKey: fakeKeyRow() } as unknown as Request;
const body = await new Promise<unknown>((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<string, never>;
}
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<typeof vi.fn> } }).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");
});
});