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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
56
packages/shared/test/provider-confirmation.test.ts
Normal file
56
packages/shared/test/provider-confirmation.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user