Files
apex-mcp-server/packages/builder/test/poll.test.ts
FilipBl4gojevic ab21f32ca9 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.
2026-07-29 08:08:26 +02:00

294 lines
11 KiB
TypeScript

import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { pollUntilConfirmed, buildConfirmationCheck } from '../src/vector/poll.ts';
/**
* A fake clock: `sleep` advances `t` by exactly the requested amount instead
* of waiting in real time, so a multi-iteration poll runs in microseconds
* and produces a deterministic elapsed-time trace. `now`/`sleep` are the
* same seam vector_await_transaction leaves at their defaults (Date.now and
* a real setTimeout), so this drives the exact loop the tool runs — just
* without waiting on the wall clock.
*
* (node:test's built-in `mock.timers` was tried first, per the convention in
* rate-limiter.test.ts. It works cleanly for synchronous Date reads, but
* this loop chains a fresh `setTimeout` after each awaited `check()`, and
* that next timer isn't registered until a microtask turn after `tick()`
* already returned - a single tick() only fires the one timer that existed
* when it ran, so multi-iteration polls silently stalled after the first
* attempt. Confirmed experimentally before choosing dependency injection
* instead: it is deterministic without needing to interleave manual
* microtask flushes with tick() calls.)
*/
function fakeClock(start = 0) {
let t = start;
const sleeps: number[] = [];
return {
now: () => t,
sleep: async (ms: number) => {
sleeps.push(ms);
t += ms;
},
sleeps,
elapsed: () => t - start,
};
}
describe('pollUntilConfirmed', () => {
test('returns true immediately, without sleeping, when check succeeds on the first attempt', async () => {
const clock = fakeClock();
let calls = 0;
const result = await pollUntilConfirmed(
async () => {
calls++;
return true;
},
9000,
3000,
clock.now,
clock.sleep,
);
assert.equal(result, true);
assert.equal(calls, 1);
assert.deepEqual(clock.sleeps, []);
});
test('returns true on a later attempt once check starts succeeding', async () => {
const clock = fakeClock();
let calls = 0;
const result = await pollUntilConfirmed(
async () => {
calls++;
return calls >= 3;
},
9000,
3000,
clock.now,
clock.sleep,
);
assert.equal(result, true);
assert.equal(calls, 3);
assert.deepEqual(clock.sleeps, [3000, 3000]);
});
test('returns false, not an error, when the budget elapses without confirmation', async () => {
const clock = fakeClock();
const result = await pollUntilConfirmed(async () => false, 9000, 3000, clock.now, clock.sleep);
assert.strictEqual(result, false);
});
test('a transaction that never confirms resolves false, never throws and never resolves true', async () => {
// vector_await_transaction treats `false` as "not yet, might still land"
// and reports that in its own wording - never "failed". That framing
// depends on this function having exactly two outcomes (true/false), no
// third "error" signal smuggled through a throw for the ordinary
// still-pending case.
const clock = fakeClock();
await assert.doesNotReject(() =>
pollUntilConfirmed(async () => false, 6000, 3000, clock.now, clock.sleep),
);
});
test('total simulated elapsed time equals the budget exactly when it divides evenly by the interval', async () => {
const clock = fakeClock();
await pollUntilConfirmed(async () => false, 9000, 3000, clock.now, clock.sleep);
assert.equal(clock.elapsed(), 9000);
});
test('does not overshoot a budget smaller than one interval - the historical bug', async () => {
// The original inline loop slept a fixed 3000ms every iteration
// regardless of how much budget was left, so a timeoutSeconds=1 request
// would actually block for ~3s - a 3x overshoot. This pins the fix: the
// sleep is clamped to the time remaining before the deadline.
const clock = fakeClock();
const result = await pollUntilConfirmed(async () => false, 1000, 3000, clock.now, clock.sleep);
assert.equal(result, false);
assert.deepEqual(clock.sleeps, [1000], 'sleep was clamped to the 1000ms remaining, not the full 3000ms interval');
assert.equal(clock.elapsed(), 1000, 'elapsed time matches the requested budget, not the interval');
});
test('does not overshoot when the budget is not an exact multiple of the interval', async () => {
const clock = fakeClock();
const result = await pollUntilConfirmed(async () => false, 7000, 3000, clock.now, clock.sleep);
assert.equal(result, false);
assert.deepEqual(clock.sleeps, [3000, 3000, 1000]);
assert.equal(clock.elapsed(), 7000);
});
test('a zero budget returns false without ever calling check or sleeping', async () => {
const clock = fakeClock();
let calls = 0;
const result = await pollUntilConfirmed(
async () => {
calls++;
return false;
},
0,
3000,
clock.now,
clock.sleep,
);
assert.equal(result, false);
assert.equal(calls, 0);
assert.deepEqual(clock.sleeps, []);
});
test('always yields between attempts instead of busy-spinning: call count is bounded by budget/interval', async () => {
const clock = fakeClock();
let calls = 0;
await pollUntilConfirmed(
async () => {
calls++;
return false;
},
30_000,
3000,
clock.now,
clock.sleep,
);
// 30000 / 3000 = 10 attempts before the deadline is reached - bounded
// and small, not the thousands of calls a non-yielding spin would rack
// up. Every attempt above also awaits `check()` itself (an async
// function), so even a near-zero interval could not busy-loop the event
// loop synchronously.
assert.equal(calls, 10);
});
test('propagates a rejection from check rather than treating it as "not yet confirmed"', async () => {
const clock = fakeClock();
await assert.rejects(
() =>
pollUntilConfirmed(
async () => {
throw new Error('boom');
},
9000,
3000,
clock.now,
clock.sleep,
),
/boom/,
);
});
test('uses real Date.now and a real timer by default when no clock is injected', async () => {
// Smoke test for the production path: the default parameters (used by
// vector_await_transaction's real call site) must work without a test
// ever having to supply now/sleep explicitly.
const start = Date.now();
const result = await pollUntilConfirmed(async () => true, 5000, 3000);
assert.equal(result, true);
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]);
});
});
});