feat(builder)!: remove vector_get_address and vector_get_spend_limits, make query tools keyless, add custody boundary test
Finishes the builder-side wallet/tx family's non-custodial split (spec PR 6).
Task 4 registered the keyless build_* tools; this task deletes the last
custodial surface in vector.ts and makes every remaining query tool keyless:
- Deleted initLucid, getWalletInfo, the vector_get_address and
vector_get_spend_limits tool registrations, and the now-unused safetyLayer
and Lucid imports. SafetyLayer/safety.ts itself stays (still used by
agent-network.ts and self-improvement.ts until PRs 7-8).
- vector_get_utxos, vector_get_transaction_history: address is now required,
mnemonic param and branch deleted, provider construction routed through
makeProvider().
- vector_dry_run: mnemonic param removed; the outputs-branch now builds via
lucidForAddress + buildMultiOutput (keyless, UNSIGNED) instead of signing
with a mnemonic-derived wallet.
- packages/shared/src/types.ts: removed the now-dead VectorToken,
VectorWalletInfo, VectorAdaTransactionResult, VectorTokenTransactionResult,
VectorBuildTransactionResult, VectorDeployContractResult and
VectorInteractContractResult interfaces (and their re-exports from
vector/index.ts). SpendLimits/SpendStatus/AuditEntry stay - safety.ts is
still live for the two custodial families.
- vector_build_transaction / vector_build_deploy_contract /
vector_build_interact_contract response copy now teaches the full
build -> sign -> submit -> confirm flow (they previously stopped at
submit), matching vector_build_send_apex/send_tokens.
- rate-limit-gating.test.ts: repointed its hermetic probe from the
now-deleted vector_get_spend_limits to vector_build_send_apex with an
invalid changeAddress - lucidForAddress rejects it in-process before any
provider call, so the suite stays hermetic.
- Added packages/builder/test/boundary.test.ts: a mechanical grep-based
custody boundary (design-spec success criterion 1). Verified failing before
the rewrite (vector.ts's mnemonic vocabulary) and passing after; also
verified the guard actually fails by injecting `// mnemonic` into build.ts
and watching it name the exact file:line before reverting.
- Tool surface is now 23 (25 - 2 deletions). tool-inventory.test.ts updated
accordingly.
Snapshot shape widened (this once, deliberately): tool-schemas.snapshot.json
entries are now { description, inputSchema } instead of a bare inputSchema,
so the regression guard also covers the security-teaching copy in tool
descriptions (the sign/submit/confirm flow callers learn from them), not just
JSON Schema shape. tool-inventory.test.ts's per-tool test now deep-compares
the whole entry and is relabeled "schema+description unchanged". This is why
the snapshot diff touches every entry, not just the 10 that actually changed.
Committed the regen script at packages/builder/scripts/regen-tool-schemas.mjs
(previously scratchpad-only) with this shape change applied.
Verified against the feat/builder-auth-and-submit baseline: the snapshot key
delta is exactly 4 renames (send_apex/send_tokens/deploy_contract/
interact_contract -> build_*), 4 changed-in-place (build_transaction,
dry_run, get_utxos, get_transaction_history), and 2 removals (get_address,
get_spend_limits) - confirmed with a structural JSON diff, since the uniform
shape-widening makes raw line-based git diff noisy (e.g. it shows
vector_message_agent's key line as touched even though its inputSchema is
byte-identical old vs new - a Myers-diff line-matching artifact, not real
agent-network.ts drift).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
64
packages/builder/scripts/regen-tool-schemas.mjs
Normal file
64
packages/builder/scripts/regen-tool-schemas.mjs
Normal file
@@ -0,0 +1,64 @@
|
||||
// packages/builder/scripts/regen-tool-schemas.mjs
|
||||
// Boots the BUILT server, lists tools over SSE, rewrites the smoke snapshot
|
||||
// sorted by name. Run from the repo root: npm run build && node packages/builder/scripts/regen-tool-schemas.mjs
|
||||
//
|
||||
// Deviation from the original brief version: the brief used a flat 2000ms
|
||||
// sleep before connecting. On this machine that raced the server's actual
|
||||
// startup (cold module load of lucid-evolution/libsodium takes longer),
|
||||
// producing ECONNREFUSED. Swapped for the same "wait for the 'listening on
|
||||
// port' stderr line" readiness signal that test/setup.ts's startServer()
|
||||
// already uses elsewhere in this repo, with spawn error/exit handling so a
|
||||
// real startup crash fails loudly instead of hanging.
|
||||
//
|
||||
// PR 6 (Task 5) change: each snapshot entry is now { description, inputSchema }
|
||||
// instead of a bare inputSchema, so the fixture also guards the
|
||||
// security-teaching copy in tool descriptions (the sign/submit/confirm flow
|
||||
// callers learn from them), not just the JSON Schema shape. See the docblock
|
||||
// above the schema-snapshot describe() in
|
||||
// packages/builder/test/smoke/tool-inventory.test.ts for the full rationale.
|
||||
import { spawn } from 'node:child_process';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
|
||||
const PORT = 3939;
|
||||
const server = spawn('node', ['packages/builder/build/index.js'], {
|
||||
env: { ...process.env, PORT: String(PORT) },
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
});
|
||||
|
||||
let stderrBuf = '';
|
||||
server.stderr.on('data', (chunk) => { stderrBuf += chunk.toString(); });
|
||||
|
||||
try {
|
||||
await new Promise((resolvePromise, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error(`Server start timeout (20s). stderr so far:\n${stderrBuf}`)), 20_000);
|
||||
const onData = () => {
|
||||
if (stderrBuf.includes('listening on port')) {
|
||||
clearTimeout(timeout);
|
||||
server.stderr.off('data', onData);
|
||||
resolvePromise();
|
||||
}
|
||||
};
|
||||
server.stderr.on('data', onData);
|
||||
server.on('error', (err) => { clearTimeout(timeout); reject(err); });
|
||||
server.on('exit', (code) => { clearTimeout(timeout); reject(new Error(`Server exited with code ${code}. stderr so far:\n${stderrBuf}`)); });
|
||||
});
|
||||
|
||||
const client = new Client({ name: 'regen', version: '0.0.0' });
|
||||
await client.connect(new SSEClientTransport(new URL(`http://localhost:${PORT}/sse`)));
|
||||
const { tools } = await client.listTools();
|
||||
const snapshot = Object.fromEntries(
|
||||
tools
|
||||
.map((t) => [t.name, { description: t.description, inputSchema: t.inputSchema }])
|
||||
.sort(([a], [b]) => a.localeCompare(b)),
|
||||
);
|
||||
writeFileSync(
|
||||
'packages/builder/test/smoke/tool-schemas.snapshot.json',
|
||||
JSON.stringify(snapshot, null, 2) + '\n',
|
||||
);
|
||||
console.log(`wrote ${tools.length} tool schemas`);
|
||||
await client.close();
|
||||
} finally {
|
||||
server.kill();
|
||||
}
|
||||
@@ -1,14 +1,7 @@
|
||||
export { registerVectorTools } from './vector.js';
|
||||
export type {
|
||||
VectorWalletInfo,
|
||||
VectorToken,
|
||||
VectorAdaTransactionResult,
|
||||
VectorTokenTransactionResult,
|
||||
TxOutput,
|
||||
VectorBuildTransactionResult,
|
||||
VectorDryRunResult,
|
||||
VectorTransactionSummary,
|
||||
VectorTransactionHistoryResult,
|
||||
VectorDeployContractResult,
|
||||
VectorInteractContractResult,
|
||||
} from '@apexfusion/vector-mcp-shared/types';
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { z } from "zod/v4";
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { Lucid } from '@lucid-evolution/lucid';
|
||||
|
||||
import * as dotenv from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
@@ -11,14 +10,11 @@ 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, buildConfirmationCheck } from './poll.js';
|
||||
import { registerAgentNetworkTools } from './agent-network.js';
|
||||
import { registerSelfImprovementTools } from './self-improvement.js';
|
||||
import type {
|
||||
VectorToken, VectorWalletInfo, VectorDryRunResult,
|
||||
} from '@apexfusion/vector-mcp-shared/types';
|
||||
import type { VectorDryRunResult } from '@apexfusion/vector-mcp-shared/types';
|
||||
import {
|
||||
lucidForAddress, buildSendApex, buildSendTokens, buildMultiOutput,
|
||||
buildDeployContract, buildInteractContract,
|
||||
@@ -38,96 +34,6 @@ if (existsSync(envPath)) {
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize Lucid instance with Ogmios provider
|
||||
async function initLucid(mnemonic: string, accountIndex: number = 0) {
|
||||
const provider = new OgmiosProvider({
|
||||
ogmiosUrl: VECTOR_OGMIOS_URL,
|
||||
submitUrl: VECTOR_SUBMIT_URL,
|
||||
koiosUrl: VECTOR_KOIOS_URL,
|
||||
});
|
||||
|
||||
// Vector uses --mainnet flag, so addresses are addr1... format
|
||||
const lucid = await Lucid(provider, 'Mainnet');
|
||||
|
||||
if (!mnemonic) {
|
||||
throw new Error('mnemonic is required');
|
||||
}
|
||||
|
||||
const trimmedMnemonic = mnemonic.trim();
|
||||
const words = trimmedMnemonic.split(/\s+/);
|
||||
|
||||
const validLengths = [12, 15, 18, 21, 24];
|
||||
if (!validLengths.includes(words.length)) {
|
||||
throw new Error(`Invalid mnemonic: Expected 12, 15, 18, 21 or 24 words, got ${words.length}`);
|
||||
}
|
||||
|
||||
lucid.selectWallet.fromSeed(trimmedMnemonic, { accountIndex });
|
||||
|
||||
const address = await lucid.wallet().address();
|
||||
if (!address) {
|
||||
throw new Error('Failed to derive address from mnemonic');
|
||||
}
|
||||
|
||||
return lucid;
|
||||
}
|
||||
|
||||
// Get wallet info
|
||||
export async function getWalletInfo(mnemonic: string, accountIndex: number = 0): Promise<VectorWalletInfo> {
|
||||
console.error('[getWalletInfo] Initializing Lucid...');
|
||||
const lucid = await initLucid(mnemonic, accountIndex);
|
||||
console.error('[getWalletInfo] Getting address...');
|
||||
const address = await lucid.wallet().address();
|
||||
console.error('[getWalletInfo] Querying UTxOs...');
|
||||
const utxos = await lucid.utxosAt(address);
|
||||
console.error(`[getWalletInfo] Found ${utxos.length} UTxOs`);
|
||||
|
||||
let adaBalance = '0';
|
||||
let tokenBalances: VectorToken[] = [];
|
||||
|
||||
if (utxos.length > 0) {
|
||||
// Aggregate all UTxO assets
|
||||
const aggregated: Record<string, bigint> = {};
|
||||
for (const utxo of utxos) {
|
||||
for (const [unit, qty] of Object.entries(utxo.assets)) {
|
||||
aggregated[unit] = (aggregated[unit] || 0n) + BigInt(qty);
|
||||
}
|
||||
}
|
||||
|
||||
adaBalance = aggregated['lovelace'] ? lovelaceToAda(aggregated['lovelace']) : '0';
|
||||
|
||||
for (const [unit, quantity] of Object.entries(aggregated)) {
|
||||
if (unit === 'lovelace') continue;
|
||||
|
||||
try {
|
||||
const policyId = unit.slice(0, 56);
|
||||
const assetNameHex = unit.slice(56);
|
||||
const displayName = assetNameHex
|
||||
? formatAssetName(assetNameHex)
|
||||
: `${policyId.substring(0, 8)}...`;
|
||||
|
||||
tokenBalances.push({
|
||||
unit,
|
||||
name: displayName,
|
||||
quantity: quantity.toString(),
|
||||
});
|
||||
} catch {
|
||||
tokenBalances.push({
|
||||
unit,
|
||||
name: unit,
|
||||
quantity: quantity.toString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
address,
|
||||
utxoCount: utxos.length,
|
||||
ada: adaBalance,
|
||||
tokens: tokenBalances,
|
||||
};
|
||||
}
|
||||
|
||||
// Shared provider for the keyless build_* tools (Task 5 reuses this).
|
||||
function makeProvider(): OgmiosProvider {
|
||||
return new OgmiosProvider({
|
||||
@@ -214,91 +120,26 @@ ${tokens.length > 0 ? `Token Holdings (${tokens.length}):\n${tokenList}` : 'No t
|
||||
}
|
||||
);
|
||||
|
||||
// vector_get_address - Get the agent's wallet address and balance
|
||||
server.tool(
|
||||
"vector_get_address",
|
||||
"Get the Vector wallet address, balance, and token holdings derived from a mnemonic",
|
||||
{
|
||||
mnemonic: z.string().describe("15 or 24-word BIP39 mnemonic for the wallet"),
|
||||
},
|
||||
async ({ mnemonic }) => {
|
||||
const rateCheck = rateLimiter.check();
|
||||
if (!rateCheck.allowed) {
|
||||
return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] };
|
||||
}
|
||||
try {
|
||||
const walletInfo = await getWalletInfo(mnemonic);
|
||||
|
||||
const tokenList = walletInfo.tokens.length > 0
|
||||
? walletInfo.tokens.map((t: VectorToken) => `${t.quantity} ${t.name}`).join('\n')
|
||||
: 'No tokens found';
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `# Vector Wallet Information
|
||||
|
||||
Address: ${walletInfo.address}
|
||||
AP3X Balance: ${walletInfo.ada} AP3X
|
||||
UTXO Count: ${walletInfo.utxoCount}
|
||||
|
||||
${walletInfo.tokens.length > 0 ? `## Token Holdings (${walletInfo.tokens.length}):\n${tokenList}` : 'No token holdings found'}`,
|
||||
}],
|
||||
};
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `Failed to get wallet information: ${error.message}
|
||||
|
||||
**Troubleshooting Tips:**
|
||||
1. Make sure you have a valid 15 or 24-word BIP39 mnemonic
|
||||
2. Verify the Ogmios endpoint is reachable: ${VECTOR_OGMIOS_URL}
|
||||
3. Check the console logs for detailed error information`,
|
||||
}],
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// vector_get_utxos - List UTxOs for an address or the wallet
|
||||
// vector_get_utxos - List UTxOs for an address
|
||||
server.tool(
|
||||
"vector_get_utxos",
|
||||
"List unspent transaction outputs (UTxOs) for a Vector address or a wallet derived from a mnemonic",
|
||||
"List unspent transaction outputs (UTxOs) for a Vector address",
|
||||
{
|
||||
address: z.string().optional().describe("Vector address to query UTxOs for. If omitted, mnemonic is required."),
|
||||
mnemonic: z.string().optional().describe("15 or 24-word BIP39 mnemonic (required if address is omitted)"),
|
||||
address: z.string().describe("Vector address to query UTxOs for (addr1...)"),
|
||||
},
|
||||
async ({ address, mnemonic }) => {
|
||||
async ({ address }) => {
|
||||
const rateCheck = rateLimiter.check();
|
||||
if (!rateCheck.allowed) {
|
||||
return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] };
|
||||
}
|
||||
try {
|
||||
let utxos;
|
||||
let queryAddress: string;
|
||||
|
||||
if (address) {
|
||||
const provider = new OgmiosProvider({
|
||||
ogmiosUrl: VECTOR_OGMIOS_URL,
|
||||
submitUrl: VECTOR_SUBMIT_URL,
|
||||
koiosUrl: VECTOR_KOIOS_URL,
|
||||
});
|
||||
utxos = await provider.getUtxos(address);
|
||||
queryAddress = address;
|
||||
} else {
|
||||
if (!mnemonic) throw new Error('Provide either address or mnemonic');
|
||||
const lucid = await initLucid(mnemonic);
|
||||
queryAddress = await lucid.wallet().address();
|
||||
utxos = await lucid.utxosAt(queryAddress);
|
||||
}
|
||||
const utxos = await makeProvider().getUtxos(address);
|
||||
|
||||
if (utxos.length === 0) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `No UTxOs found for ${queryAddress}`,
|
||||
text: `No UTxOs found for ${address}`,
|
||||
}],
|
||||
};
|
||||
}
|
||||
@@ -312,7 +153,7 @@ ${walletInfo.tokens.length > 0 ? `## Token Holdings (${walletInfo.tokens.length}
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `# UTxOs for ${queryAddress}
|
||||
text: `# UTxOs for ${address}
|
||||
|
||||
Total: ${utxos.length} UTxO(s)
|
||||
|
||||
@@ -441,50 +282,6 @@ Non-custodial flow: 1) sign locally with vector_signer_sign { txCbor }; 2) broad
|
||||
}
|
||||
);
|
||||
|
||||
// vector_get_spend_limits - Check safety layer status
|
||||
server.tool(
|
||||
"vector_get_spend_limits",
|
||||
"Check current spend limits and remaining daily budget",
|
||||
{},
|
||||
async () => {
|
||||
const rateCheck = rateLimiter.check();
|
||||
if (!rateCheck.allowed) {
|
||||
return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] };
|
||||
}
|
||||
try {
|
||||
const status = safetyLayer.getSpendStatus();
|
||||
const log = safetyLayer.getAuditLog();
|
||||
|
||||
const recentTxs = log.slice(-5).reverse().map((entry) =>
|
||||
`- ${entry.timestamp}: ${lovelaceToAda(entry.amountLovelace)} AP3X → ${entry.recipient.substring(0, 20)}... (${entry.txHash.substring(0, 16)}...)`
|
||||
).join('\n');
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `# Vector Spend Limits
|
||||
|
||||
Per-Transaction Limit: ${lovelaceToAda(status.perTransactionLimit)} AP3X
|
||||
Daily Limit: ${lovelaceToAda(status.dailyLimit)} AP3X
|
||||
Daily Spent: ${lovelaceToAda(status.dailySpent)} AP3X
|
||||
Daily Remaining: ${lovelaceToAda(status.dailyRemaining)} AP3X
|
||||
Resets At: ${status.resetTime}
|
||||
|
||||
${log.length > 0 ? `## Recent Transactions (last ${Math.min(5, log.length)}):\n${recentTxs}` : 'No transactions recorded yet.'}`,
|
||||
}],
|
||||
};
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `Failed to get spend limits: ${error.message}`,
|
||||
}],
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// vector_build_transaction - build an UNSIGNED multi-output transaction (keyless)
|
||||
server.tool(
|
||||
"vector_build_transaction",
|
||||
@@ -522,7 +319,7 @@ ${log.length > 0 ? `## Recent Transactions (last ${Math.min(5, log.length)}):\n$
|
||||
${result.txCbor}
|
||||
\`\`\`
|
||||
|
||||
Use vector_dry_run with this CBOR to simulate it, sign it locally with vector_signer_sign, then broadcast with vector_submit_transaction.`,
|
||||
Use vector_dry_run with this CBOR to simulate it, sign it locally with vector_signer_sign, broadcast with vector_submit_transaction, then confirm with vector_await_transaction { txHash }.`,
|
||||
}],
|
||||
};
|
||||
} catch (err) {
|
||||
@@ -544,18 +341,18 @@ Use vector_dry_run with this CBOR to simulate it, sign it locally with vector_si
|
||||
// vector_dry_run - Simulate a transaction without submitting
|
||||
server.tool(
|
||||
"vector_dry_run",
|
||||
"Simulate a transaction without submitting - returns fee estimate and validation result",
|
||||
"Simulate a transaction without submitting - returns fee estimate and validation result. Accepts either built CBOR or outputs to build from (keyless).",
|
||||
{
|
||||
txCbor: z.string().optional().describe("Hex-encoded CBOR of a built transaction to evaluate"),
|
||||
outputs: z.array(z.object({
|
||||
address: z.string().describe("Recipient Vector address"),
|
||||
lovelace: z.number().describe("Amount in lovelace"),
|
||||
assets: z.record(z.string(), z.string()).optional(),
|
||||
})).optional().describe("If no txCbor provided, build a TX from these outputs and evaluate it"),
|
||||
mnemonic: z.string().optional().describe("15 or 24-word BIP39 mnemonic (required when outputs is provided)"),
|
||||
})).optional().describe("If no txCbor provided, build an unsigned TX from these outputs and evaluate it"),
|
||||
changeAddress: z.string().optional().describe("Your wallet address (required when building from outputs)"),
|
||||
metadata: z.string().optional().describe("Optional JSON metadata when building from outputs"),
|
||||
},
|
||||
async ({ txCbor, outputs, mnemonic, metadata }) => {
|
||||
async ({ txCbor, outputs, changeAddress, metadata }) => {
|
||||
const rateCheck = rateLimiter.check();
|
||||
if (!rateCheck.allowed) {
|
||||
return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] };
|
||||
@@ -565,42 +362,20 @@ Use vector_dry_run with this CBOR to simulate it, sign it locally with vector_si
|
||||
let feeFromBuild: string | null = null;
|
||||
|
||||
if (!cborToEvaluate && outputs && outputs.length > 0) {
|
||||
// Build the transaction first
|
||||
if (!mnemonic) throw new Error('mnemonic is required when building from outputs');
|
||||
const lucid = await initLucid(mnemonic);
|
||||
|
||||
let tx = lucid.newTx();
|
||||
for (const output of outputs) {
|
||||
const assets: Record<string, bigint> = { lovelace: BigInt(output.lovelace) };
|
||||
if (output.assets) {
|
||||
for (const [unit, qty] of Object.entries(output.assets)) {
|
||||
assets[unit] = BigInt(qty);
|
||||
}
|
||||
}
|
||||
tx = tx.pay.ToAddress(output.address, assets);
|
||||
}
|
||||
|
||||
if (metadata) {
|
||||
const parsedMetadata = typeof metadata === 'string' ? JSON.parse(metadata) : metadata;
|
||||
tx = tx.attachMetadata(674, parsedMetadata);
|
||||
}
|
||||
|
||||
const completedTx = await tx.complete();
|
||||
const txJson = (completedTx as any).toJSON?.() ?? {};
|
||||
feeFromBuild = String(txJson?.body?.fee ?? '0');
|
||||
const signedTx = await completedTx.sign.withWallet().complete();
|
||||
cborToEvaluate = signedTx.toCBOR();
|
||||
if (!changeAddress) throw new Error('changeAddress is required when building from outputs');
|
||||
const lucid = await lucidForAddress(makeProvider(), changeAddress);
|
||||
const built = await buildMultiOutput(lucid, { outputs, metadataJson: metadata });
|
||||
feeFromBuild = built.fee;
|
||||
// Ogmios evaluates scripts against the UNSIGNED transaction -
|
||||
// signatures are not required for evaluation.
|
||||
cborToEvaluate = built.txCbor;
|
||||
}
|
||||
|
||||
if (!cborToEvaluate) {
|
||||
throw new Error('Provide either txCbor or outputs to evaluate');
|
||||
}
|
||||
|
||||
const provider = new OgmiosProvider({
|
||||
ogmiosUrl: VECTOR_OGMIOS_URL,
|
||||
submitUrl: VECTOR_SUBMIT_URL,
|
||||
koiosUrl: VECTOR_KOIOS_URL,
|
||||
});
|
||||
const provider = makeProvider();
|
||||
|
||||
let evalResult: VectorDryRunResult;
|
||||
try {
|
||||
@@ -626,7 +401,6 @@ Use vector_dry_run with this CBOR to simulate it, sign it locally with vector_si
|
||||
executionUnits: (totalMemory > 0 || totalCpu > 0) ? { memory: totalMemory, cpu: totalCpu } : undefined,
|
||||
};
|
||||
} catch (evalErr) {
|
||||
// evaluateTransaction failed - still return fee if we built the tx
|
||||
if (feeFromBuild) {
|
||||
evalResult = {
|
||||
valid: true,
|
||||
@@ -768,37 +542,25 @@ Transaction Hash: ${txHash}
|
||||
"vector_get_transaction_history",
|
||||
"Get transaction history for a Vector address via Koios indexed queries",
|
||||
{
|
||||
address: z.string().optional().describe("Vector address to query. If omitted, mnemonic is required."),
|
||||
mnemonic: z.string().optional().describe("15 or 24-word BIP39 mnemonic (required if address is omitted)"),
|
||||
address: z.string().describe("Vector address to query (addr1...)"),
|
||||
limit: z.number().min(1).max(50).optional().describe("Number of transactions to return (default: 20, max: 50)"),
|
||||
offset: z.number().min(0).optional().describe("Offset for pagination (default: 0)"),
|
||||
},
|
||||
async ({ address, mnemonic, limit, offset }) => {
|
||||
async ({ address, limit, offset }) => {
|
||||
const rateCheck = rateLimiter.check();
|
||||
if (!rateCheck.allowed) {
|
||||
return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] };
|
||||
}
|
||||
try {
|
||||
let queryAddress = address;
|
||||
if (!queryAddress) {
|
||||
if (!mnemonic) throw new Error('Provide either address or mnemonic');
|
||||
const lucid = await initLucid(mnemonic);
|
||||
queryAddress = await lucid.wallet().address();
|
||||
}
|
||||
const provider = makeProvider();
|
||||
|
||||
const provider = new OgmiosProvider({
|
||||
ogmiosUrl: VECTOR_OGMIOS_URL,
|
||||
submitUrl: VECTOR_SUBMIT_URL,
|
||||
koiosUrl: VECTOR_KOIOS_URL,
|
||||
});
|
||||
|
||||
const txs = await provider.getTransactionHistory(queryAddress, offset || 0, limit || 20);
|
||||
const txs = await provider.getTransactionHistory(address, offset || 0, limit || 20);
|
||||
|
||||
if (txs.length === 0) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `No transactions found for ${queryAddress}`,
|
||||
text: `No transactions found for ${address}`,
|
||||
}],
|
||||
};
|
||||
}
|
||||
@@ -811,13 +573,13 @@ Transaction Hash: ${txHash}
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `# Transaction History for ${queryAddress}
|
||||
text: `# Transaction History for ${address}
|
||||
|
||||
Showing ${txs.length} transaction(s) (offset: ${offset || 0}):
|
||||
|
||||
${txList}
|
||||
|
||||
[View on Explorer](${VECTOR_EXPLORER_URL}/address/${queryAddress})`,
|
||||
[View on Explorer](${VECTOR_EXPLORER_URL}/address/${address})`,
|
||||
}],
|
||||
};
|
||||
} catch (err) {
|
||||
@@ -872,7 +634,7 @@ ${txList}
|
||||
${result.txCbor}
|
||||
\`\`\`
|
||||
|
||||
Sign locally with vector_signer_sign, broadcast with vector_submit_transaction, then use vector_build_interact_contract to interact with the deployed contract.`,
|
||||
Sign locally with vector_signer_sign, broadcast with vector_submit_transaction, then confirm with vector_await_transaction { txHash }. Once confirmed, use vector_build_interact_contract to interact with the deployed contract.`,
|
||||
}],
|
||||
};
|
||||
} catch (err) {
|
||||
@@ -936,7 +698,7 @@ Sign locally with vector_signer_sign, broadcast with vector_submit_transaction,
|
||||
${result.txCbor}
|
||||
\`\`\`
|
||||
|
||||
Sign locally with vector_signer_sign { txCbor }, then broadcast with vector_submit_transaction { signedTxCbor }.`,
|
||||
Sign locally with vector_signer_sign { txCbor }, then broadcast with vector_submit_transaction { signedTxCbor }, then confirm with vector_await_transaction { txHash }.`,
|
||||
}],
|
||||
};
|
||||
} catch (err) {
|
||||
|
||||
54
packages/builder/test/boundary.test.ts
Normal file
54
packages/builder/test/boundary.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { resolve, join, relative } from 'node:path';
|
||||
|
||||
/**
|
||||
* The builder-side custody guarantee, made mechanical (design-spec success
|
||||
* criterion 1: "grep-able: zero mnemonic params in the builder").
|
||||
*
|
||||
* Files still on the custodial path are explicitly allowlisted with the PR
|
||||
* that removes them. Shrinking this list is the definition of done for the
|
||||
* non-custodial split; growing it should be impossible to do accidentally.
|
||||
*/
|
||||
const SRC_ROOT = resolve(import.meta.dirname!, '../src');
|
||||
const CUSTODIAL_UNTIL_LATER_PR = new Set([
|
||||
'vector/agent-network.ts', // family 2 — goes keyless in spec PR 7
|
||||
'vector/self-improvement.ts', // family 3 — goes keyless in spec PR 8
|
||||
]);
|
||||
const FORBIDDEN = /\b(mnemonic|seed|bip39|fromSeed|walletFromSeed|selectWallet\.fromSeed)\b/i;
|
||||
|
||||
function sourceFiles(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) out.push(...sourceFiles(full));
|
||||
else if (/\.(ts|js|mjs|cjs|mts|cts)$/.test(entry)) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('builder custody boundary', () => {
|
||||
test('no key-material vocabulary outside the allowlisted custodial files', () => {
|
||||
const offenders: string[] = [];
|
||||
for (const file of sourceFiles(SRC_ROOT)) {
|
||||
const rel = relative(SRC_ROOT, file).replace(/\\/g, '/');
|
||||
if (CUSTODIAL_UNTIL_LATER_PR.has(rel)) continue;
|
||||
const lines = readFileSync(file, 'utf-8').split('\n');
|
||||
lines.forEach((line, i) => {
|
||||
if (FORBIDDEN.test(line)) offenders.push(`${rel}:${i + 1}: ${line.trim()}`);
|
||||
});
|
||||
}
|
||||
assert.deepEqual(offenders, [], `key-material vocabulary found in keyless builder source:\n${offenders.join('\n')}`);
|
||||
});
|
||||
|
||||
test('the allowlist matches reality (files removed from the custodial path must leave the list)', () => {
|
||||
for (const rel of CUSTODIAL_UNTIL_LATER_PR) {
|
||||
const content = readFileSync(join(SRC_ROOT, rel), 'utf-8');
|
||||
assert.match(
|
||||
content, FORBIDDEN,
|
||||
`${rel} is allowlisted as custodial but contains no key-material vocabulary - remove it from CUSTODIAL_UNTIL_LATER_PR`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -14,8 +14,14 @@ import type { RawServerHandle } from '../setup.ts';
|
||||
// .superpowers/sdd/pr5-integration-tests-report.md for the mutation-testing
|
||||
// evidence.
|
||||
//
|
||||
// Tool choice: vector_get_spend_limits (vector.ts) reads only in-process
|
||||
// SafetyLayer state - confirmed by reading safety.ts, no network/wallet.
|
||||
// Tool choice: vector_build_send_apex (vector.ts) with a deliberately invalid
|
||||
// changeAddress. lucidForAddress (packages/builder/src/vector/build.ts)
|
||||
// validates the address with getAddressDetails() and throws BEFORE any
|
||||
// provider call - proven by Task 2's booby-trapped-provider test. Handlers
|
||||
// return caught errors as normal content, so isError stays false and this
|
||||
// probe is just as hermetic as the vector_get_spend_limits tool it replaces
|
||||
// (which vector.ts no longer has - Task 5 deleted it, since SafetyLayer state
|
||||
// is no longer exposed to keyless callers).
|
||||
// vector_get_agent_profile (agent-network.ts) and
|
||||
// vector_self_improvement_analyze_metrics (self-improvement.ts) DO reach
|
||||
// Ogmios/Koios in their normal bodies, but only after their own
|
||||
@@ -78,11 +84,13 @@ describe('per-identity rate limiting across real registered tools', () => {
|
||||
});
|
||||
|
||||
test('alice exhausts her budget on a vector.ts tool; bob is unaffected (isolation via the real tools, not just limiterFor)', async () => {
|
||||
const first = await call(alice, 'vector_get_spend_limits');
|
||||
const PROBE_ARGS = { changeAddress: 'not-an-address', recipientAddress: 'not-an-address', amount: 1 };
|
||||
|
||||
const first = await call(alice, 'vector_build_send_apex', PROBE_ARGS);
|
||||
assert.equal(first.isError, false, 'first call must not be a tool error');
|
||||
assert.ok(!RATE_LIMIT_PATTERN.test(first.text), 'first call must not be rate-limited yet');
|
||||
|
||||
const second = await call(alice, 'vector_get_spend_limits');
|
||||
const second = await call(alice, 'vector_build_send_apex', PROBE_ARGS);
|
||||
assert.equal(second.isError, false, 'a rate-limit refusal is a normal, well-formed result - not a thrown tool error');
|
||||
assert.match(second.text, RATE_LIMIT_PATTERN, 'alice must now be rate-limited');
|
||||
|
||||
@@ -91,7 +99,7 @@ describe('per-identity rate limiting across real registered tools', () => {
|
||||
// inference from limiterFor() in isolation (rate-limiter.test.ts already
|
||||
// covers that in isolation; this covers the wiring rate-limiter.test.ts
|
||||
// cannot see).
|
||||
const bobFirst = await call(bob, 'vector_get_spend_limits');
|
||||
const bobFirst = await call(bob, 'vector_build_send_apex', PROBE_ARGS);
|
||||
assert.equal(bobFirst.isError, false);
|
||||
assert.ok(!RATE_LIMIT_PATTERN.test(bobFirst.text), "bob must be unaffected by alice's exhausted budget");
|
||||
});
|
||||
|
||||
@@ -5,13 +5,17 @@ import { resolve } from 'node:path';
|
||||
import { startServer, stopServer, ServerContext } from '../setup.ts';
|
||||
|
||||
/**
|
||||
* Snapshot of the pre-migration tool surface (23 tools), captured from a real
|
||||
* `listTools()` call against a built server on the `ci/harness-and-gitignore`
|
||||
* branch. This is the baseline for the non-custodial split series
|
||||
* (see docs/architecture/non-custodial-split.md) — PRs 2, 6, 7 and 8
|
||||
* deliberately restructure and rename tool families, and each of those PRs is
|
||||
* expected to update this list deliberately, not accidentally. No wallet,
|
||||
* no network beyond localhost — this never needs a mnemonic.
|
||||
* Snapshot of the current tool surface (23 tools) for the non-custodial split
|
||||
* series (see docs/architecture/non-custodial-split.md). PR 6 (Task 5) is the
|
||||
* PR that took this list from 25 to 23: it deleted `vector_get_address` and
|
||||
* `vector_get_spend_limits` outright (the safety-layer/wallet-info tools have
|
||||
* no keyless equivalent — spend limits are gone because the builder no longer
|
||||
* holds keys to limit) as part of making the remaining query tools
|
||||
* (`vector_get_utxos`, `vector_get_transaction_history`, `vector_dry_run`)
|
||||
* keyless. PRs 2, 6, 7 and 8 each deliberately restructure and rename tool
|
||||
* families, and each of those PRs is expected to update this list
|
||||
* deliberately, not accidentally. No wallet, no network beyond localhost —
|
||||
* this never needs a mnemonic.
|
||||
*/
|
||||
const EXPECTED_TOOLS = [
|
||||
'vector_await_transaction',
|
||||
@@ -23,10 +27,8 @@ const EXPECTED_TOOLS = [
|
||||
'vector_deregister_agent',
|
||||
'vector_discover_agents',
|
||||
'vector_dry_run',
|
||||
'vector_get_address',
|
||||
'vector_get_agent_profile',
|
||||
'vector_get_balance',
|
||||
'vector_get_spend_limits',
|
||||
'vector_get_transaction_history',
|
||||
'vector_get_utxos',
|
||||
'vector_message_agent',
|
||||
@@ -42,33 +44,40 @@ const EXPECTED_TOOLS = [
|
||||
];
|
||||
|
||||
/**
|
||||
* Full-schema snapshot: the exact `inputSchema` (JSON Schema) the live server
|
||||
* advertises for every tool, keyed by tool name. This is a change detector,
|
||||
* not a freeze — PRs 6, 7 and 8 deliberately restructure tool schemas as part
|
||||
* of the non-custodial split, and each of those PRs is expected to
|
||||
* regenerate this fixture to match its intentional changes (boot the built
|
||||
* server, call listTools(), and dump { [name]: inputSchema } sorted by
|
||||
* name — do not hand-edit the JSON).
|
||||
* Full-schema snapshot: the exact `description` and `inputSchema` (JSON
|
||||
* Schema) the live server advertises for every tool, keyed by tool name.
|
||||
* This is a change detector, not a freeze — PRs 6, 7 and 8 deliberately
|
||||
* restructure tool schemas as part of the non-custodial split, and each of
|
||||
* those PRs is expected to regenerate this fixture to match its intentional
|
||||
* changes: run `npm run build && node packages/builder/scripts/regen-tool-schemas.mjs`
|
||||
* (boots the built server, calls listTools(), and dumps
|
||||
* { [name]: { description, inputSchema } } sorted by name — do not hand-edit
|
||||
* the JSON).
|
||||
*
|
||||
* What this guards against is an *unintended* wire-format change slipping
|
||||
* through unnoticed: the name/count checks above only catch a tool being
|
||||
* added, removed, or renamed — they say nothing about a tool's argument
|
||||
* schema quietly changing shape. That happened once already: converting
|
||||
* vector.ts's Zod import from "zod" to "zod/v4" (for the MCP SDK's Zod 4
|
||||
* type inference) also switched which JSON Schema serializer the SDK uses
|
||||
* for that file's tools — Zod v3 schemas go through the vendored
|
||||
* zod-to-json-schema, Zod v4 schemas go through Zod's own native
|
||||
* What this guards against is an *unintended* wire-format or copy change
|
||||
* slipping through unnoticed: the name/count checks above only catch a tool
|
||||
* being added, removed, or renamed — they say nothing about a tool's argument
|
||||
* schema or description quietly changing shape. The inputSchema half happened
|
||||
* once already: converting vector.ts's Zod import from "zod" to "zod/v4"
|
||||
* (for the MCP SDK's Zod 4 type inference) also switched which JSON Schema
|
||||
* serializer the SDK uses for that file's tools — Zod v3 schemas go through
|
||||
* the vendored zod-to-json-schema, Zod v4 schemas go through Zod's own native
|
||||
* toJSONSchema — and the two disagree on whether object schemas get
|
||||
* `additionalProperties: false`. Nothing in the original name/count smoke
|
||||
* test could see that; this fixture exists so the next silent schema drift
|
||||
* fails loudly instead.
|
||||
* test could see that. `description` was folded into this same fixture in
|
||||
* PR 6 (Task 5) because several tool descriptions are the only place a
|
||||
* caller learns the non-custodial build → sign → submit → confirm flow
|
||||
* (vector_signer_sign / vector_submit_transaction / vector_await_transaction)
|
||||
* — a silently edited or dropped sentence there is a real regression this
|
||||
* fixture should catch too, not just a JSON Schema shape change.
|
||||
*/
|
||||
const SNAPSHOT_PATH = resolve(import.meta.dirname!, 'tool-schemas.snapshot.json');
|
||||
const schemaSnapshot: Record<string, unknown> = JSON.parse(readFileSync(SNAPSHOT_PATH, 'utf-8'));
|
||||
const schemaSnapshot: Record<string, { description?: string; inputSchema: unknown }> =
|
||||
JSON.parse(readFileSync(SNAPSHOT_PATH, 'utf-8'));
|
||||
|
||||
let ctx: ServerContext;
|
||||
let actualNames: string[];
|
||||
let actualToolsByName: Map<string, { name: string; inputSchema: unknown }>;
|
||||
let actualToolsByName: Map<string, { name: string; description?: string; inputSchema: unknown }>;
|
||||
|
||||
before(async () => {
|
||||
ctx = await startServer();
|
||||
@@ -82,11 +91,11 @@ after(async () => {
|
||||
});
|
||||
|
||||
describe('tool inventory smoke test', () => {
|
||||
test('exposes exactly 25 tools', () => {
|
||||
test('exposes exactly 23 tools', () => {
|
||||
assert.equal(
|
||||
actualNames.length,
|
||||
25,
|
||||
`expected 25 tools, got ${actualNames.length}: ${JSON.stringify(actualNames)}`
|
||||
23,
|
||||
`expected 23 tools, got ${actualNames.length}: ${JSON.stringify(actualNames)}`
|
||||
);
|
||||
});
|
||||
|
||||
@@ -117,7 +126,7 @@ describe('tool schema snapshot', () => {
|
||||
'test/smoke/tool-schemas.snapshot.json keys do not match the live tool set.\n' +
|
||||
` live tools missing from snapshot: ${missingFromSnapshot.length ? JSON.stringify(missingFromSnapshot) : '(none)'}\n` +
|
||||
` snapshot entries with no live tool: ${staleInSnapshot.length ? JSON.stringify(staleInSnapshot) : '(none)'}\n` +
|
||||
'Regenerate the snapshot from a live built server if this drift is intentional.'
|
||||
'Regenerate the snapshot from a live built server (packages/builder/scripts/regen-tool-schemas.mjs) if this drift is intentional.'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -126,18 +135,19 @@ describe('tool schema snapshot', () => {
|
||||
// diff of just that tool's schema, instead of "objects differ" over an
|
||||
// unreadable combined structure.
|
||||
for (const toolName of Object.keys(schemaSnapshot).sort()) {
|
||||
test(`inputSchema unchanged: ${toolName}`, () => {
|
||||
test(`schema+description unchanged: ${toolName}`, () => {
|
||||
const live = actualToolsByName.get(toolName);
|
||||
assert.ok(
|
||||
live,
|
||||
`Tool "${toolName}" is in the snapshot but was not returned by the live server's listTools().`
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
live.inputSchema,
|
||||
{ description: live.description, inputSchema: live.inputSchema },
|
||||
schemaSnapshot[toolName],
|
||||
`inputSchema for "${toolName}" differs from the checked-in snapshot in test/smoke/tool-schemas.snapshot.json.\n` +
|
||||
'If this is a deliberate schema change (e.g. PRs 6-8 restructuring tool families), regenerate the snapshot ' +
|
||||
'from a live built server. If not, this is the exact class of silent wire-format regression this test exists to catch.'
|
||||
`description/inputSchema for "${toolName}" differs from the checked-in snapshot in test/smoke/tool-schemas.snapshot.json.\n` +
|
||||
'If this is a deliberate schema or copy change (e.g. PRs 6-8 restructuring tool families), regenerate the snapshot ' +
|
||||
'from a live built server (packages/builder/scripts/regen-tool-schemas.mjs). If not, this is the exact class of silent ' +
|
||||
'wire-format/copy regression this test exists to catch.'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,43 +1,5 @@
|
||||
// Vector-specific type definitions
|
||||
|
||||
export interface VectorToken {
|
||||
unit: string;
|
||||
name: string;
|
||||
quantity: string;
|
||||
}
|
||||
|
||||
export interface VectorWalletInfo {
|
||||
address: string;
|
||||
utxoCount: number;
|
||||
ada: string;
|
||||
tokens: VectorToken[];
|
||||
}
|
||||
|
||||
export interface VectorAdaTransactionResult {
|
||||
txHash: string;
|
||||
senderAddress: string;
|
||||
recipientAddress: string;
|
||||
amount: number;
|
||||
links: {
|
||||
explorer: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface VectorTokenTransactionResult {
|
||||
txHash: string;
|
||||
senderAddress: string;
|
||||
recipientAddress: string;
|
||||
token: {
|
||||
policyId: string;
|
||||
name: string;
|
||||
amount: string;
|
||||
};
|
||||
ada: string;
|
||||
links: {
|
||||
explorer: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SpendLimits {
|
||||
perTransaction: number; // lovelace
|
||||
daily: number; // lovelace
|
||||
@@ -67,19 +29,6 @@ export interface TxOutput {
|
||||
assets?: Record<string, string>; // unit -> quantity
|
||||
}
|
||||
|
||||
export interface VectorBuildTransactionResult {
|
||||
txCbor: string;
|
||||
txHash: string;
|
||||
fee: string;
|
||||
feeAda: string;
|
||||
outputCount: number;
|
||||
totalAda: string;
|
||||
submitted: boolean;
|
||||
links?: {
|
||||
explorer: string;
|
||||
};
|
||||
}
|
||||
|
||||
// --- Dry Run ---
|
||||
|
||||
export interface VectorDryRunResult {
|
||||
@@ -108,29 +57,6 @@ export interface VectorTransactionHistoryResult {
|
||||
total: number;
|
||||
}
|
||||
|
||||
// --- Deploy Contract ---
|
||||
|
||||
export interface VectorDeployContractResult {
|
||||
txHash: string;
|
||||
scriptAddress: string;
|
||||
scriptHash: string;
|
||||
scriptType: string;
|
||||
links: {
|
||||
explorer: string;
|
||||
};
|
||||
}
|
||||
|
||||
// --- Interact Contract ---
|
||||
|
||||
export interface VectorInteractContractResult {
|
||||
txHash: string;
|
||||
scriptAddress: string;
|
||||
action: 'spend' | 'lock';
|
||||
links: {
|
||||
explorer: string;
|
||||
};
|
||||
}
|
||||
|
||||
// --- Keyless build results (non-custodial split, family 1) ---
|
||||
// Every build_* tool returns unsigned CBOR; signing happens on the local
|
||||
// signer, submission via vector_submit_transaction.
|
||||
|
||||
Reference in New Issue
Block a user