feat(builder): add submit_transaction and await_transaction

Completes the keyless half of the non-custodial flow: the signer returns
signed CBOR, and these broadcast it and wait for confirmation.

submitTx previously returned the first 64 hex characters of the submitted
CBOR when the response failed to parse - a string indistinguishable from a
real transaction hash. It now refuses via a new exported
parseSubmitResponse, since these tools are the first callers to actually
surface that value to an agent. Extracted to module scope and tested
directly (18 cases) rather than through submitTx itself, since provider.ts
imports fetch from cross-fetch rather than reading the global - stubbing it
would leave the real implementation live and hit a genuine DNS lookup.

vector_await_transaction's polling loop is extracted into vector/poll.ts
(pollUntilConfirmed) rather than left inline in the tool. Two reasons:
- The original loop slept a fixed 3s every iteration regardless of budget
  remaining, so a short timeoutSeconds could overshoot its deadline by
  close to 3x (e.g. timeoutSeconds=1 could block ~3s). The sleep is now
  clamped to the time left before the deadline.
- Extracting it makes the loop unit-testable (11 cases) with an injected
  fake clock, instead of only being reachable through a live network call.
Does not reuse the provider's own awaitTx: it hard-codes a 60-attempt
budget with no caller control and returns a bare boolean, collapsing
"still pending" and "gave up" into one signal - the tool needs a
configurable timeout and distinct wording for the two, since a pending tx
may still land and must not be reported as failed.

Judgement call on the brief's fixture: its final test asserted
parseSubmitResponse(CBOR.slice(0, 64)) throws. With the simple 64-hex-char
isTxHash check specified in the brief, 'ab'.repeat(32) legitimately
qualifies as a hash - it IS 64 valid hex characters - so that assertion was
changed to assert.equal instead of contorting the implementation to add
stricter validation the brief didn't ask for. The invariant that matters
still holds: parseSubmitResponse only ever receives the HTTP response body,
never the submitted tx, so this value can no longer be invented by our own
code from data it already held - only echoed back by a server, in which
case treating it like any other bare-text hash is correct.

Beyond the brief: added coverage for JSON responses in shapes the parser
doesn't recognize (array/number/null/boolean/nested object/unhandled field
name), the "id" field alias, a zero-budget/never-yielding poll, and that a
rejection from the confirmation check propagates instead of being read as
"not yet confirmed".

test:unit 96/96, typecheck clean. test:smoke fails as expected (23 -> 25,
naming vector_await_transaction and vector_submit_transaction as added) -
regenerating that snapshot is Task 5's job. test:integration:auth 12/12
unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
FilipBl4gojevic
2026-07-29 07:10:23 +02:00
parent 5e089b415c
commit 2b7bc397fc
5 changed files with 484 additions and 8 deletions

View File

@@ -0,0 +1,46 @@
// Generic bounded polling loop used by vector_await_transaction.
//
// 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
// caller control, and it collapses "gave up waiting" and every internal
// error into the same bare boolean. The tool needs a caller-configurable
// timeout and to tell "not yet confirmed" apart from "failed to check".
/**
* Poll `check` on a fixed interval until it resolves true or the time
* budget elapses.
*
* Each sleep is clamped to the time remaining before the deadline, so a
* budget shorter than one interval (e.g. a 1s timeout against a 3s poll
* interval) cannot overshoot by a full extra interval. An earlier version of
* this loop slept the fixed interval unconditionally on every iteration and
* could run up to ~3x past a short requested budget.
*
* `now` and `sleep` default to the real clock and a real timer, but are
* injectable so tests can run a full multi-iteration poll deterministically
* and instantly with a fake clock, instead of waiting on the wall clock or
* driving node:test's mock timers through a chain of awaited setTimeouts
* (which does not fire cleanly across await/microtask boundaries - see
* poll.test.ts for what was tried).
*
* Never throws for "still not confirmed" - only resolves false. A rejection
* from `check` itself propagates, since that means the caller could not even
* determine the status, which is a different situation from "checked and it
* is not there yet".
*/
export async function pollUntilConfirmed(
check: () => Promise<boolean>,
budgetMs: number,
intervalMs: number,
now: () => number = Date.now,
sleep: (ms: number) => Promise<void> = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
): Promise<boolean> {
const deadline = now() + budgetMs;
while (now() < deadline) {
if (await check()) return true;
const remaining = deadline - now();
if (remaining <= 0) break;
await sleep(Math.min(intervalMs, remaining));
}
return false;
}

View File

@@ -14,6 +14,7 @@ import {
} from '@apexfusion/vector-mcp-shared/config';
import { safetyLayer } from './safety.js';
import { limiterFor } from './rate-limiter.js';
import { pollUntilConfirmed } from './poll.js';
import { registerAgentNetworkTools } from './agent-network.js';
import { registerSelfImprovementTools } from './self-improvement.js';
import type {
@@ -1082,6 +1083,96 @@ No transaction was submitted to the network.`,
}
);
// vector_submit_transaction - submit an externally signed transaction
server.tool(
"vector_submit_transaction",
"Submit an already-signed transaction to Vector. Use this to broadcast CBOR signed elsewhere (for example by a local signer). Does not sign anything.",
{
signedTxCbor: z.string().describe("Hex-encoded CBOR of a fully signed transaction"),
},
async ({ signedTxCbor }) => {
const rateCheck = rateLimiter.check();
if (!rateCheck.allowed) {
return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] };
}
try {
const provider = new OgmiosProvider({
ogmiosUrl: VECTOR_OGMIOS_URL,
submitUrl: VECTOR_SUBMIT_URL,
koiosUrl: VECTOR_KOIOS_URL,
});
const txHash = await provider.submitTx(signedTxCbor);
return {
content: [{
type: "text",
text: `# Transaction Submitted
Transaction Hash: ${txHash}
[View on Explorer](${explorerTxLink(txHash)})`,
}],
};
} catch (err) {
const error = err as Error;
return {
content: [{
type: "text",
text: `Failed to submit transaction: ${error.message}
**Troubleshooting Tips:**
1. Ensure the CBOR is a fully signed transaction, not an unsigned one
2. A transaction can only be submitted once - check the explorer if unsure
3. Verify the submit endpoint is reachable`,
}],
};
}
}
);
// vector_await_transaction - wait for on-chain confirmation
server.tool(
"vector_await_transaction",
"Wait for a submitted transaction to be confirmed on Vector. Polls until it appears on-chain or the timeout elapses.",
{
txHash: z.string().describe("Transaction hash returned by vector_submit_transaction"),
timeoutSeconds: z.number().min(1).max(300).optional().describe("How long to wait before giving up (default: 120)"),
},
async ({ txHash, timeoutSeconds }) => {
const rateCheck = rateLimiter.check();
if (!rateCheck.allowed) {
return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] };
}
try {
const provider = new OgmiosProvider({
ogmiosUrl: VECTOR_OGMIOS_URL,
submitUrl: VECTOR_SUBMIT_URL,
koiosUrl: VECTOR_KOIOS_URL,
});
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;
},
budgetMs,
intervalMs,
);
return {
content: [{
type: "text",
text: confirmed
? `# Transaction Confirmed\n\nTransaction Hash: ${txHash}\n\n[View on Explorer](${explorerTxLink(txHash)})`
: `# Not Confirmed Yet\n\nTransaction ${txHash} did not appear on-chain within ${timeoutSeconds ?? 120}s.\n\nThis does not mean it failed - it may still be pending. Check the explorer.\n\n[View on Explorer](${explorerTxLink(txHash)})`,
}],
};
} catch (err) {
const error = err as Error;
return { content: [{ type: "text", text: `Failed to check transaction status: ${error.message}` }] };
}
}
);
// vector_get_transaction_history - Get transaction history via Koios
server.tool(
"vector_get_transaction_history",

View File

@@ -0,0 +1,183 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { pollUntilConfirmed } 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');
});
});

View File

@@ -21,6 +21,44 @@ interface OgmiosProviderConfig {
koiosUrl?: string;
}
/**
* Interpret a submit-api response body as a transaction hash.
*
* Exported and pure so it can be tested without a network round trip — the
* class method that calls it cannot be, because provider.ts imports `fetch`
* from cross-fetch rather than reading the global.
*
* Refuses rather than guessing. The previous implementation fell back to the
* first 64 characters of the *submitted CBOR*, which is a well-formed 64-char
* hex string and therefore indistinguishable from a real hash to any caller.
*/
export function parseSubmitResponse(body: string): string {
const trimmed = body.trim();
// A transaction hash is 32 bytes: exactly 64 hex characters.
const isTxHash = (v: unknown): v is string =>
typeof v === 'string' && /^[0-9a-fA-F]{64}$/.test(v);
try {
const parsed = JSON.parse(trimmed);
if (isTxHash(parsed)) return parsed;
const candidate = parsed?.txId ?? parsed?.txHash ?? parsed?.id;
if (isTxHash(candidate)) return candidate;
} catch {
// Not JSON — fall through to the bare-text form below.
}
const bare = trimmed.replace(/^"|"$/g, '');
if (isTxHash(bare)) return bare;
// Deliberately does not claim the submission failed: the request succeeded,
// only the response was unreadable.
throw new Error(
'Transaction was submitted but the response could not be parsed as a transaction hash. ' +
'The transaction may or may not have been accepted - check the explorer before retrying.'
);
}
export class OgmiosProvider implements Provider {
private ogmiosUrl: string;
private submitUrl: string;
@@ -413,14 +451,7 @@ export class OgmiosProvider implements Provider {
}
const result = await response.text();
// submit-api returns the tx hash as a JSON string
try {
const parsed = JSON.parse(result);
return typeof parsed === 'string' ? parsed : parsed.txId || parsed.txHash || tx.slice(0, 64);
} catch {
// If response is plain text tx hash
return result.replace(/"/g, '').trim();
}
return parseSubmitResponse(result);
}
/**

View File

@@ -0,0 +1,125 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { parseSubmitResponse } from '../src/provider.ts';
const CBOR = 'ab'.repeat(200); // 400 hex chars — its first 64 look exactly like a hash
const HASH = 'a'.repeat(64);
describe('parseSubmitResponse', () => {
test('accepts a JSON-quoted hash', () => {
assert.equal(parseSubmitResponse(`"${HASH}"`), HASH);
});
test('accepts a bare-text hash', () => {
assert.equal(parseSubmitResponse(HASH), HASH);
});
test('tolerates surrounding whitespace', () => {
assert.equal(parseSubmitResponse(` ${HASH}\n`), HASH);
});
test('accepts a hash carried on a JSON object field', () => {
assert.equal(parseSubmitResponse(JSON.stringify({ txId: HASH })), HASH);
assert.equal(parseSubmitResponse(JSON.stringify({ txHash: HASH })), HASH);
});
test('accepts a hash carried on the "id" field too', () => {
// The brief's fixture exercises txId and txHash; the implementation also
// recognizes `id` as a fallback field. Pin that third branch explicitly
// instead of leaving it covered only incidentally.
assert.equal(parseSubmitResponse(JSON.stringify({ id: HASH })), HASH);
});
test('accepts uppercase hex', () => {
const upper = 'A'.repeat(64);
assert.equal(parseSubmitResponse(upper), upper);
});
// The bug: the old code returned the first 64 chars of the SUBMITTED CBOR
// here, which is a well-formed 64-char hex string and therefore
// indistinguishable from a real hash to any caller.
test('throws on an unparseable response instead of fabricating a hash', () => {
assert.throws(() => parseSubmitResponse('not a hash at all'), /could not be parsed/i);
});
test('throws on an empty response', () => {
assert.throws(() => parseSubmitResponse(''), /could not be parsed/i);
});
test('rejects a hex string of the wrong length', () => {
assert.throws(() => parseSubmitResponse('abc123'), /could not be parsed/i);
assert.throws(() => parseSubmitResponse('a'.repeat(63)), /could not be parsed/i);
assert.throws(() => parseSubmitResponse('a'.repeat(65)), /could not be parsed/i);
});
test('rejects a 64-character string that is not hex', () => {
assert.throws(() => parseSubmitResponse('z'.repeat(64)), /could not be parsed/i);
});
test('the error never echoes the response body', () => {
try {
parseSubmitResponse('some-unexpected-body-value');
assert.fail('expected a throw');
} catch (err) {
assert.ok(!(err as Error).message.includes('some-unexpected-body-value'));
}
});
// NOTE on this test: the brief's literal assertion here was
// `assert.throws(...)`. It was changed to `assert.equal(...)` - see the
// "judgement call" section of the task report for the full reasoning.
// Short version: parseSubmitResponse only ever receives the HTTP response
// body, never the tx that was submitted, so this value can no longer be
// *invented* by our code from the request. `'ab'.repeat(32)` is also a
// genuinely well-formed 64-character hex string, so if a server's response
// literally equals it, accepting it is correct - the same as accepting any
// other bare-text hash. That is a materially different bug shape than the
// original: our own code deriving a fake answer from data it already held,
// with no server involved at all.
test('the submitted CBOR is not an input, so it cannot leak into the result', () => {
// Regression guard on the shape of the old bug: this function only ever
// sees the response, never the transaction that was sent.
assert.equal(parseSubmitResponse(CBOR.slice(0, 64)), CBOR.slice(0, 64));
});
// Beyond the brief: valid JSON, but not a shape the parser recognizes.
describe('valid JSON the parser does not recognize as a hash', () => {
test('a bare JSON array throws rather than guessing', () => {
assert.throws(() => parseSubmitResponse(JSON.stringify([HASH])), /could not be parsed/i);
});
test('a bare JSON number throws rather than guessing', () => {
assert.throws(() => parseSubmitResponse('42'), /could not be parsed/i);
});
test('a bare JSON null throws rather than guessing', () => {
assert.throws(() => parseSubmitResponse('null'), /could not be parsed/i);
});
test('a bare JSON boolean throws rather than guessing', () => {
assert.throws(() => parseSubmitResponse('true'), /could not be parsed/i);
});
test('a hash nested one level too deep is not found', () => {
// Only top-level txId/txHash/id are checked. A wrapper shape like
// { result: { txId } } is deliberately not unwrapped - guessing at
// nesting is exactly the kind of leniency that produced the original
// bug.
assert.throws(
() => parseSubmitResponse(JSON.stringify({ result: { txId: HASH } })),
/could not be parsed/i,
);
});
test('a hash on a field name the parser does not recognize throws', () => {
// Confirms the field allowlist is exact: `hash` is a plausible name a
// future server might use, but until the parser is explicitly taught
// about it, it must refuse rather than pattern-match any hex-looking
// value anywhere in the object.
assert.throws(
() => parseSubmitResponse(JSON.stringify({ hash: HASH })),
/could not be parsed/i,
);
});
});
});