fix(builder): prefer Koios tx_status for await_transaction, stop swallowing check errors

vector_await_transaction's confirmation check only ever looked for an
unspent output at index 0 - a heuristic that can read a landed transaction
as unconfirmed, since these tools broadcast CBOR signed elsewhere with no
guarantee of that output existing or staying unspent. The check also wrapped
the lookup in .catch(() => []), collapsing genuine infrastructure failures
(Ogmios down, malformed response) into the same "not confirmed yet" result,
defeating pollUntilConfirmed's documented contract that a check rejection
should propagate.

OgmiosProvider.getKoiosTxStatus + koiosIndicatesConfirmed (shared/provider.ts)
now provide a direct, non-swallowing Koios tx_status check; poll.ts's new
buildConfirmationCheck picks it when VECTOR_KOIOS_URL is configured and
falls back to the outRef heuristic (also no longer swallowing errors)
otherwise - the same preference order OgmiosProvider.awaitTx already used,
without reusing awaitTx itself (still not caller-configurable, still
swallows every failure). awaitTx stays in place only because the Provider
interface requires it; nothing else calls it.

buildConfirmationCheck and koiosIndicatesConfirmed are unit tested via
dependency injection, the same pattern poll.test.ts already uses for
now/sleep, with no network access required.
This commit is contained in:
FilipBl4gojevic
2026-07-29 08:08:26 +02:00
parent 104f08a691
commit ab21f32ca9
5 changed files with 268 additions and 8 deletions

View File

@@ -1,4 +1,5 @@
// Generic bounded polling loop used by vector_await_transaction.
// Generic bounded polling loop used by vector_await_transaction, plus the
// logic that picks what each poll attempt actually checks.
//
// OgmiosProvider already has an `awaitTx`, but it is not reused here: it
// hard-codes a 60-attempt budget (~3 minutes at a fixed 3s interval) with no
@@ -44,3 +45,27 @@ export async function pollUntilConfirmed(
}
return false;
}
/**
* Choose which confirmation check `pollUntilConfirmed` should run: Koios's
* `tx_status` when configured (a direct signal), the outRef heuristic when
* it is not - the same preference order as `OgmiosProvider.awaitTx`, without
* reusing it (see the file header above).
*
* Takes both checks as already-built functions, rather than a provider and
* txHash, so the selection itself can be pinned with fakes the same way
* `pollUntilConfirmed`'s own `now`/`sleep` are injected above - no network
* call happens here. Whichever function is selected is returned as-is, not
* wrapped in a try/catch, so a rejection from it propagates unchanged
* through `pollUntilConfirmed` to the tool's outer `try/catch` - it is not
* reinterpreted as "not yet confirmed". `checkKoios` and `checkOutRef`
* carrying that same no-swallow contract is the caller's responsibility
* (see the vector_await_transaction call site).
*/
export function buildConfirmationCheck(
koiosConfigured: boolean,
checkKoios: () => Promise<boolean>,
checkOutRef: () => Promise<boolean>,
): () => Promise<boolean> {
return koiosConfigured ? checkKoios : checkOutRef;
}

View File

@@ -7,14 +7,14 @@ import * as dotenv from 'dotenv';
import { resolve } from 'path';
import { fileURLToPath } from 'url';
import { existsSync } from 'fs';
import { OgmiosProvider } from '@apexfusion/vector-mcp-shared/provider';
import { OgmiosProvider, koiosIndicatesConfirmed } from '@apexfusion/vector-mcp-shared/provider';
import { lovelaceToAda, formatAssetName } from '@apexfusion/vector-mcp-shared/tx';
import {
VECTOR_OGMIOS_URL, VECTOR_SUBMIT_URL, VECTOR_KOIOS_URL, VECTOR_EXPLORER_URL, explorerTxLink,
} from '@apexfusion/vector-mcp-shared/config';
import { safetyLayer } from './safety.js';
import { limiterFor } from './rate-limiter.js';
import { pollUntilConfirmed } from './poll.js';
import { pollUntilConfirmed, buildConfirmationCheck } from './poll.js';
import { registerAgentNetworkTools } from './agent-network.js';
import { registerSelfImprovementTools } from './self-improvement.js';
import type {
@@ -1151,10 +1151,14 @@ Transaction Hash: ${txHash}
const budgetMs = (timeoutSeconds ?? 120) * 1000;
const intervalMs = 3000;
const confirmed = await pollUntilConfirmed(
async () => {
const utxos = await provider.getUtxosByOutRef([{ txHash, outputIndex: 0 }]).catch(() => []);
return utxos.length > 0;
},
buildConfirmationCheck(
Boolean(VECTOR_KOIOS_URL),
async () => koiosIndicatesConfirmed(await provider.getKoiosTxStatus(txHash)),
async () => {
const utxos = await provider.getUtxosByOutRef([{ txHash, outputIndex: 0 }]);
return utxos.length > 0;
},
),
budgetMs,
intervalMs,
);

View File

@@ -1,6 +1,6 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { pollUntilConfirmed } from '../src/vector/poll.ts';
import { pollUntilConfirmed, buildConfirmationCheck } from '../src/vector/poll.ts';
/**
* A fake clock: `sleep` advances `t` by exactly the requested amount instead
@@ -181,3 +181,113 @@ describe('pollUntilConfirmed', () => {
assert.ok(Date.now() - start < 1000, 'first attempt succeeds without waiting a full interval');
});
});
describe('buildConfirmationCheck', () => {
// vector_await_transaction wires this up as:
// buildConfirmationCheck(Boolean(VECTOR_KOIOS_URL), koiosCheck, outRefCheck)
// Both checkKoios/checkOutRef stand in for provider calls (Koios tx_status,
// Ogmios getUtxosByOutRef) without ever touching the network - this pins
// the selection and propagation behavior, not the HTTP calls themselves.
test('selects checkKoios when Koios is configured, and never calls checkOutRef', async () => {
let outRefCalls = 0;
const check = buildConfirmationCheck(
true,
async () => true,
async () => { outRefCalls++; return true; },
);
assert.equal(await check(), true);
assert.equal(outRefCalls, 0, 'the outRef check must not run when Koios is preferred');
});
test('selects checkOutRef when Koios is not configured, and never calls checkKoios', async () => {
let koiosCalls = 0;
const check = buildConfirmationCheck(
false,
async () => { koiosCalls++; return true; },
async () => false,
);
assert.equal(await check(), false);
assert.equal(koiosCalls, 0, 'the Koios check must not run as a fallback is not a retry path');
});
test('propagates the selected check\'s resolved value unchanged, true or false, in either branch', async () => {
assert.equal(await buildConfirmationCheck(true, async () => true, async () => { throw new Error('unused'); })(), true);
assert.equal(await buildConfirmationCheck(true, async () => false, async () => { throw new Error('unused'); })(), false);
assert.equal(await buildConfirmationCheck(false, async () => { throw new Error('unused'); }, async () => true)(), true);
assert.equal(await buildConfirmationCheck(false, async () => { throw new Error('unused'); }, async () => false)(), false);
});
test('propagates a rejection from checkKoios rather than treating it as "not yet confirmed"', async () => {
const check = buildConfirmationCheck(
true,
async () => { throw new Error('Koios tx_status query failed (503): upstream down'); },
async () => false,
);
await assert.rejects(check, /Koios tx_status query failed/);
});
test('propagates a rejection from checkOutRef rather than treating it as "not yet confirmed"', async () => {
const check = buildConfirmationCheck(
false,
async () => false,
async () => { throw new Error('Ogmios RPC error (500): boom'); },
);
await assert.rejects(check, /Ogmios RPC error/);
});
describe('composed with pollUntilConfirmed (matching the real vector_await_transaction call site)', () => {
function fakeClock(start = 0) {
let t = start;
const sleeps: number[] = [];
return {
now: () => t,
sleep: async (ms: number) => { sleeps.push(ms); t += ms; },
sleeps,
};
}
test('a Koios failure on the first attempt rejects immediately, without ever sleeping or falling back to outRef', async () => {
const clock = fakeClock();
let outRefCalls = 0;
const check = buildConfirmationCheck(
true,
async () => { throw new Error('Koios unreachable'); },
async () => { outRefCalls++; return true; },
);
await assert.rejects(
() => pollUntilConfirmed(check, 9000, 3000, clock.now, clock.sleep),
/Koios unreachable/,
);
assert.deepEqual(clock.sleeps, [], 'a check that cannot determine status must not be retried like a pending tx');
assert.equal(outRefCalls, 0);
});
test('outRef polling that never confirms resolves false (not an error) once the budget elapses, with Koios not configured', async () => {
const clock = fakeClock();
let koiosCalls = 0;
const check = buildConfirmationCheck(
false,
async () => { koiosCalls++; return true; },
async () => false,
);
const result = await pollUntilConfirmed(check, 9000, 3000, clock.now, clock.sleep);
assert.equal(result, false);
assert.equal(koiosCalls, 0);
});
test('Koios reporting confirmed on a later attempt resolves true without exhausting the budget', async () => {
const clock = fakeClock();
let attempts = 0;
const check = buildConfirmationCheck(
true,
async () => { attempts++; return attempts >= 2; },
async () => { throw new Error('outRef must not run when Koios is configured'); },
);
const result = await pollUntilConfirmed(check, 9000, 3000, clock.now, clock.sleep);
assert.equal(result, true);
assert.equal(attempts, 2);
assert.deepEqual(clock.sleeps, [3000]);
});
});
});

View File

@@ -59,6 +59,25 @@ export function parseSubmitResponse(body: string): string {
);
}
/**
* Interpret a parsed Koios `tx_status` response body as a confirmation
* signal.
*
* Exported and pure so it can be tested without a network round trip - same
* rationale as `parseSubmitResponse` above. By the time this runs, the
* caller (`OgmiosProvider.getKoiosTxStatus`) has already thrown on a genuine
* transport/HTTP failure, so anything reaching here is a syntactically valid
* response from a reachable Koios. A missing hash (empty array - Koios has
* not indexed it yet), a present-but-zero-confirmation entry, or a body that
* does not match the expected shape are all treated as "not yet confirmed"
* (false) rather than an error: none of them mean the check itself failed.
*/
export function koiosIndicatesConfirmed(data: unknown): boolean {
if (!Array.isArray(data) || data.length === 0) return false;
const entry = data[0] as { num_confirmations?: unknown } | undefined;
return typeof entry?.num_confirmations === 'number' && entry.num_confirmations > 0;
}
export class OgmiosProvider implements Provider {
private ogmiosUrl: string;
private submitUrl: string;
@@ -286,6 +305,52 @@ export class OgmiosProvider implements Provider {
throw new Error(`Datum not found for hash: ${datumHash}. Datum lookups require Koios indexer.`);
}
/**
* Query Koios for a transaction's confirmation status, once.
*
* Thin and deliberately does not interpret the response - see
* `koiosIndicatesConfirmed` above, kept separate so it can be unit tested
* without a network round trip. Unlike the Koios branch inside `awaitTx`
* below (which swallows every failure into "keep polling"), this throws on
* a network failure or a non-OK response: the caller could not determine
* status, which is a different situation from "asked, and it is not
* confirmed yet", and callers such as `pollUntilConfirmed`
* (packages/builder/src/vector/poll.ts) need to be able to tell the two
* apart.
*/
async getKoiosTxStatus(txHash: string): Promise<unknown> {
if (!this.koiosUrl) {
throw new Error(
'Koios URL is not configured. Transaction status checks require Koios indexer. ' +
'Set VECTOR_KOIOS_URL in your environment.'
);
}
const response = await fetch(`${this.koiosUrl}/api/v1/tx_status`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ _tx_hashes: [txHash] }),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Koios tx_status query failed (${response.status}): ${text}`);
}
return response.json();
}
/**
* Required by the @lucid-evolution/lucid `Provider` interface that this
* class implements, but otherwise unused repo-wide: `vector_await_transaction`
* builds its own poll loop on top of `getKoiosTxStatus`/`getUtxosByOutRef`
* (see packages/builder/src/vector/poll.ts) instead of calling this,
* because this method's fixed 60-attempt/3s-interval budget cannot be
* configured by a caller and it swallows every failure - network error,
* non-OK response, malformed body - into "keep polling", which is
* indistinguishable from "checked and it is not there yet". Left
* unmodified rather than deleted or reworked: TypeScript requires it to
* satisfy `implements Provider`, and changing its own retry/error
* semantics is out of scope here since nothing depends on them.
*/
async awaitTx(txHash: string, checkInterval: number = 3000): Promise<boolean> {
// Poll for tx confirmation
const maxAttempts = 60; // ~3 minutes at 3s intervals

View File

@@ -0,0 +1,56 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { koiosIndicatesConfirmed } from '../src/provider.ts';
// koiosIndicatesConfirmed only ever sees a body that Koios already answered
// with an OK response - getKoiosTxStatus throws before this runs for a
// network failure or a non-2xx status. So every case here is "reachable and
// answered", and the only question is whether that answer means confirmed.
describe('koiosIndicatesConfirmed', () => {
test('a positive confirmation count means confirmed', () => {
assert.equal(koiosIndicatesConfirmed([{ tx_hash: 'a'.repeat(64), num_confirmations: 1 }]), true);
});
test('a larger confirmation count still means confirmed', () => {
assert.equal(koiosIndicatesConfirmed([{ tx_hash: 'a'.repeat(64), num_confirmations: 42 }]), true);
});
test('an empty array (Koios has not indexed the hash yet) means not confirmed', () => {
assert.equal(koiosIndicatesConfirmed([]), false);
});
test('zero confirmations means not confirmed', () => {
assert.equal(koiosIndicatesConfirmed([{ tx_hash: 'a'.repeat(64), num_confirmations: 0 }]), false);
});
test('a missing num_confirmations field means not confirmed, not an error', () => {
assert.equal(koiosIndicatesConfirmed([{ tx_hash: 'a'.repeat(64) }]), false);
});
test('a non-array body means not confirmed, not an error', () => {
assert.equal(koiosIndicatesConfirmed(null), false);
assert.equal(koiosIndicatesConfirmed(undefined), false);
assert.equal(koiosIndicatesConfirmed({}), false);
assert.equal(koiosIndicatesConfirmed('unexpected'), false);
});
test('a num_confirmations that is not a number means not confirmed, not an error', () => {
// Defensive: guards against a future Koios response shape change (e.g. a
// stringified count) silently being coerced by a loose truthiness check
// instead of being treated as "cannot tell, so say not yet".
assert.equal(koiosIndicatesConfirmed([{ num_confirmations: '3' }]), false);
assert.equal(koiosIndicatesConfirmed([{ num_confirmations: null }]), false);
});
test('a negative confirmation count means not confirmed', () => {
assert.equal(koiosIndicatesConfirmed([{ num_confirmations: -1 }]), false);
});
test('only the first entry is consulted, matching the single-hash query this is always called with', () => {
assert.equal(
koiosIndicatesConfirmed([{ num_confirmations: 0 }, { num_confirmations: 5 }]),
false,
);
});
});