diff --git a/packages/builder/src/vector/poll.ts b/packages/builder/src/vector/poll.ts index 31d16ee..0db02b9 100644 --- a/packages/builder/src/vector/poll.ts +++ b/packages/builder/src/vector/poll.ts @@ -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, + checkOutRef: () => Promise, +): () => Promise { + return koiosConfigured ? checkKoios : checkOutRef; +} diff --git a/packages/builder/src/vector/vector.ts b/packages/builder/src/vector/vector.ts index 6f6f508..9cceda7 100644 --- a/packages/builder/src/vector/vector.ts +++ b/packages/builder/src/vector/vector.ts @@ -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, ); diff --git a/packages/builder/test/poll.test.ts b/packages/builder/test/poll.test.ts index 46cc936..c5256e2 100644 --- a/packages/builder/test/poll.test.ts +++ b/packages/builder/test/poll.test.ts @@ -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]); + }); + }); +}); diff --git a/packages/shared/src/provider.ts b/packages/shared/src/provider.ts index 8d787fe..8f48852 100644 --- a/packages/shared/src/provider.ts +++ b/packages/shared/src/provider.ts @@ -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 { + 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 { // Poll for tx confirmation const maxAttempts = 60; // ~3 minutes at 3s intervals diff --git a/packages/shared/test/provider-confirmation.test.ts b/packages/shared/test/provider-confirmation.test.ts new file mode 100644 index 0000000..f75a624 --- /dev/null +++ b/packages/shared/test/provider-confirmation.test.ts @@ -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, + ); + }); +});