From 2d961c13fa354f61f9f47f37593f66c03c16cb04 Mon Sep 17 00:00:00 2001 From: FilipBl4gojevic <84703395+FilipBl4gojevic@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:40:35 +0200 Subject: [PATCH 01/14] test(builder): offline FixtureProvider with captured testnet protocol parameters --- .../test/fixtures/capture-protocol-params.mjs | 22 + .../test/fixtures/fixture-provider.test.ts | 40 ++ .../builder/test/fixtures/fixture-provider.ts | 87 +++ .../fixtures/protocol-params.fixture.json | 678 ++++++++++++++++++ 4 files changed, 827 insertions(+) create mode 100644 packages/builder/test/fixtures/capture-protocol-params.mjs create mode 100644 packages/builder/test/fixtures/fixture-provider.test.ts create mode 100644 packages/builder/test/fixtures/fixture-provider.ts create mode 100644 packages/builder/test/fixtures/protocol-params.fixture.json diff --git a/packages/builder/test/fixtures/capture-protocol-params.mjs b/packages/builder/test/fixtures/capture-protocol-params.mjs new file mode 100644 index 0000000..b41aa48 --- /dev/null +++ b/packages/builder/test/fixtures/capture-protocol-params.mjs @@ -0,0 +1,22 @@ +// packages/builder/test/fixtures/capture-protocol-params.mjs +// +// One-time capture: writes Lucid-format protocol parameters from the live +// Vector testnet Ogmios endpoint into protocol-params.fixture.json, using +// OgmiosProvider's own mapping so the fixture can never drift from the +// conversion logic. Run manually after `npm run build`: +// +// node packages/builder/test/fixtures/capture-protocol-params.mjs +// +// Never runs in CI — unit tests read the checked-in JSON. +import { writeFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { OgmiosProvider } from '@apexfusion/vector-mcp-shared/provider'; + +const ogmiosUrl = process.env.VECTOR_OGMIOS_URL || 'https://ogmios.vector.testnet.apexfusion.org'; +const provider = new OgmiosProvider({ ogmiosUrl, submitUrl: 'http://unused.invalid', koiosUrl: '' }); +const params = await provider.getProtocolParameters(); +const json = JSON.stringify(params, (_k, v) => (typeof v === 'bigint' ? { __bigint: v.toString() } : v), 2); +const out = resolve(dirname(fileURLToPath(import.meta.url)), 'protocol-params.fixture.json'); +writeFileSync(out, json + '\n'); +console.log(`wrote ${out}`); diff --git a/packages/builder/test/fixtures/fixture-provider.test.ts b/packages/builder/test/fixtures/fixture-provider.test.ts new file mode 100644 index 0000000..db3994a --- /dev/null +++ b/packages/builder/test/fixtures/fixture-provider.test.ts @@ -0,0 +1,40 @@ +// packages/builder/test/fixtures/fixture-provider.test.ts +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { Lucid } from '@lucid-evolution/lucid'; +import { FixtureProvider, fixtureProtocolParameters, FIXTURE_UTXOS, OWN_ADDRESS, FOREIGN_ADDRESS } from './fixture-provider.ts'; + +describe('FixtureProvider', () => { + test('serves the captured protocol parameters with bigints revived', () => { + const p = fixtureProtocolParameters(); + assert.equal(p.minFeeA, 45); + assert.equal(p.minFeeB, 156253); + assert.equal(typeof p.coinsPerUtxoByte, 'bigint'); + assert.ok(p.costModels.PlutusV2, 'PlutusV2 cost model missing from fixture'); + }); + + test('getUtxos filters by address and returns nothing for a foreign address', async () => { + const provider = new FixtureProvider(FIXTURE_UTXOS); + assert.equal((await provider.getUtxos(OWN_ADDRESS)).length, 3); + assert.equal((await provider.getUtxos(FOREIGN_ADDRESS)).length, 0); + }); + + test('network-only methods throw loudly instead of hanging', async () => { + const provider = new FixtureProvider(FIXTURE_UTXOS); + await assert.rejects(() => provider.submitTx('84'), /offline/); + await assert.rejects(() => provider.getDelegation('stake1xyz'), /offline/); + }); + + // The offline replication of the PR-4 spike: a REAL unsigned transaction, + // built with no key and no network. If this test passes, every later build + // test has a working foundation. + test('Lucid + FixtureProvider builds a real unsigned tx via fromAddress', async () => { + const lucid = await Lucid(new FixtureProvider(FIXTURE_UTXOS), 'Mainnet'); + const utxos = await lucid.utxosAt(OWN_ADDRESS); + lucid.selectWallet.fromAddress(OWN_ADDRESS, utxos); + const tx = await lucid.newTx().pay.ToAddress(FOREIGN_ADDRESS, { lovelace: 3_000_000n }).complete(); + const cbor = tx.toCBOR(); + assert.ok(cbor.length > 200, `unsigned tx CBOR suspiciously short: ${cbor.length} hex chars`); + assert.match(cbor, /^84/, 'Conway-era tx CBOR must start with array header 0x84'); + }); +}); diff --git a/packages/builder/test/fixtures/fixture-provider.ts b/packages/builder/test/fixtures/fixture-provider.ts new file mode 100644 index 0000000..e01a89c --- /dev/null +++ b/packages/builder/test/fixtures/fixture-provider.ts @@ -0,0 +1,87 @@ +// packages/builder/test/fixtures/fixture-provider.ts +// +// Offline stand-in for OgmiosProvider in unit tests. Serves the checked-in +// protocol-params fixture (captured from the live testnet by +// capture-protocol-params.mjs) and a caller-supplied UTxO set. Every method +// that would need the chain throws loudly so a test that would touch the +// network fails fast instead of hanging or silently passing. +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import type { + Provider, ProtocolParameters, UTxO, OutRef, Address, Credential, + Delegation, DatumHash, Datum, RewardAddress, Transaction, TxHash, Unit, +} from '@lucid-evolution/lucid'; + +export const OWN_ADDRESS = 'addr1qylasu4y34ccwt8hv55tkswa9fthtck9xtppvc4y03kwhaztd0jlxdzk72zj8mgy6x4269v9gytkgffh3k8d3l8987yqu5c35z'; +export const FOREIGN_ADDRESS = 'addr1wx434t2jc3m5uhdf7tq05xjdqu3q5z7a2lhrmn5mapsd43srh7ll8'; +export const FIXTURE_TXHASH = 'e17a2ebab8eae3850f959290041b3bef3f3597584f8cf4a728e14777dddb8c32'; +export const FIXTURE_TOKEN_UNIT = '1111111111111111111111111111111111111111111111111111111154657374546f6b656e'; + +export const FIXTURE_UTXOS: UTxO[] = [ + { txHash: FIXTURE_TXHASH, outputIndex: 0, address: OWN_ADDRESS, assets: { lovelace: 1_000_000_000n } }, + { txHash: FIXTURE_TXHASH, outputIndex: 1, address: OWN_ADDRESS, assets: { lovelace: 5_000_000n } }, + { txHash: FIXTURE_TXHASH, outputIndex: 2, address: OWN_ADDRESS, assets: { lovelace: 10_000_000n, [FIXTURE_TOKEN_UNIT]: 500n } }, +]; + +const PARAMS_PATH = resolve(import.meta.dirname!, 'protocol-params.fixture.json'); + +export function fixtureProtocolParameters(): ProtocolParameters { + return JSON.parse(readFileSync(PARAMS_PATH, 'utf-8'), (_k, v) => + v !== null && typeof v === 'object' && '__bigint' in v ? BigInt(v.__bigint) : v, + ) as ProtocolParameters; +} + +export class FixtureProvider implements Provider { + private utxos: UTxO[]; + + // Not a parameter-property constructor (`constructor(private utxos...)`): + // Node's native type-stripping (no ts-node/tsx loader in the plain `node + // --test` invocation this file is designed to run under) only erases + // types, it does not transform TS-only constructs like parameter + // properties, and rejects them with ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX. + // Same behavior as the parameter-property form, just spelled so `node` + // can strip-and-run it directly. Matches OgmiosProvider's own style in + // provider.ts, which never uses parameter properties either. + constructor(utxos: UTxO[]) { + this.utxos = utxos; + } + + async getProtocolParameters(): Promise { + return fixtureProtocolParameters(); + } + + async getUtxos(addressOrCredential: Address | Credential): Promise { + if (typeof addressOrCredential !== 'string') { + throw new Error('FixtureProvider: credential queries not supported offline'); + } + return this.utxos.filter((u) => u.address === addressOrCredential); + } + + async getUtxosByOutRef(outRefs: Array): Promise { + return this.utxos.filter((u) => + outRefs.some((r) => r.txHash === u.txHash && Number(r.outputIndex) === u.outputIndex), + ); + } + + async getUtxosWithUnit(_a: Address | Credential, _u: Unit): Promise { + throw new Error('FixtureProvider: offline — getUtxosWithUnit unavailable'); + } + async getUtxoByUnit(_u: Unit): Promise { + throw new Error('FixtureProvider: offline — getUtxoByUnit unavailable'); + } + async getDelegation(_r: RewardAddress): Promise { + throw new Error('FixtureProvider: offline — getDelegation unavailable'); + } + async getDatum(_d: DatumHash): Promise { + throw new Error('FixtureProvider: offline — getDatum unavailable'); + } + awaitTx(_tx: TxHash, _interval?: number): Promise { + throw new Error('FixtureProvider: offline — awaitTx unavailable'); + } + async submitTx(_tx: Transaction): Promise { + throw new Error('FixtureProvider: offline — submitTx unavailable'); + } + async evaluateTx(_tx: Transaction, _additional?: UTxO[]): Promise { + throw new Error('FixtureProvider: offline — evaluateTx unavailable'); + } +} diff --git a/packages/builder/test/fixtures/protocol-params.fixture.json b/packages/builder/test/fixtures/protocol-params.fixture.json new file mode 100644 index 0000000..f5c910e --- /dev/null +++ b/packages/builder/test/fixtures/protocol-params.fixture.json @@ -0,0 +1,678 @@ +{ + "minFeeA": 45, + "minFeeB": 156253, + "maxTxSize": 16384, + "maxValSize": 5000, + "keyDeposit": { + "__bigint": "500000000" + }, + "poolDeposit": { + "__bigint": "5000000000000" + }, + "priceMem": 0.0577, + "priceStep": 0.0000721, + "maxTxExMem": { + "__bigint": "16000000" + }, + "maxTxExSteps": { + "__bigint": "10000000000" + }, + "coinsPerUtxoByte": { + "__bigint": "4310" + }, + "collateralPercentage": 150, + "maxCollateralInputs": 3, + "costModels": { + "PlutusV1": [ + 197209, + 0, + 1, + 1, + 396231, + 621, + 0, + 1, + 150000, + 1000, + 0, + 1, + 150000, + 32, + 2477736, + 29175, + 4, + 29773, + 100, + 29773, + 100, + 29773, + 100, + 29773, + 100, + 29773, + 100, + 29773, + 100, + 100, + 100, + 29773, + 100, + 150000, + 32, + 150000, + 32, + 150000, + 32, + 150000, + 1000, + 0, + 1, + 150000, + 32, + 150000, + 1000, + 0, + 8, + 148000, + 425507, + 118, + 0, + 1, + 1, + 150000, + 1000, + 0, + 8, + 150000, + 112536, + 247, + 1, + 150000, + 10000, + 1, + 136542, + 1326, + 1, + 1000, + 150000, + 1000, + 1, + 150000, + 32, + 150000, + 32, + 150000, + 32, + 1, + 1, + 150000, + 1, + 150000, + 4, + 103599, + 248, + 1, + 103599, + 248, + 1, + 145276, + 1366, + 1, + 179690, + 497, + 1, + 150000, + 32, + 150000, + 32, + 150000, + 32, + 150000, + 32, + 150000, + 32, + 150000, + 32, + 148000, + 425507, + 118, + 0, + 1, + 1, + 61516, + 11218, + 0, + 1, + 150000, + 32, + 148000, + 425507, + 118, + 0, + 1, + 1, + 148000, + 425507, + 118, + 0, + 1, + 1, + 2477736, + 29175, + 4, + 0, + 82363, + 4, + 150000, + 5000, + 0, + 1, + 150000, + 32, + 197209, + 0, + 1, + 1, + 150000, + 32, + 150000, + 32, + 150000, + 32, + 150000, + 32, + 150000, + 32, + 150000, + 32, + 150000, + 32, + 3345831, + 1, + 1 + ], + "PlutusV2": [ + 205665, + 812, + 1, + 1, + 1000, + 571, + 0, + 1, + 1000, + 24177, + 4, + 1, + 1000, + 32, + 117366, + 10475, + 4, + 23000, + 100, + 23000, + 100, + 23000, + 100, + 23000, + 100, + 23000, + 100, + 23000, + 100, + 100, + 100, + 23000, + 100, + 19537, + 32, + 175354, + 32, + 46417, + 4, + 221973, + 511, + 0, + 1, + 89141, + 32, + 497525, + 14068, + 4, + 2, + 196500, + 453240, + 220, + 0, + 1, + 1, + 1000, + 28662, + 4, + 2, + 245000, + 216773, + 62, + 1, + 1060367, + 12586, + 1, + 208512, + 421, + 1, + 187000, + 1000, + 52998, + 1, + 80436, + 32, + 43249, + 32, + 1000, + 32, + 80556, + 1, + 57667, + 4, + 1000, + 10, + 197145, + 156, + 1, + 197145, + 156, + 1, + 204924, + 473, + 1, + 208896, + 511, + 1, + 52467, + 32, + 64832, + 32, + 65493, + 32, + 22558, + 32, + 16563, + 32, + 76511, + 32, + 196500, + 453240, + 220, + 0, + 1, + 1, + 69522, + 11687, + 0, + 1, + 60091, + 32, + 196500, + 453240, + 220, + 0, + 1, + 1, + 196500, + 453240, + 220, + 0, + 1, + 1, + 1159724, + 392670, + 0, + 2, + 806990, + 30482, + 4, + 1927926, + 82523, + 4, + 265318, + 0, + 4, + 0, + 85931, + 32, + 205665, + 812, + 1, + 1, + 41182, + 32, + 212342, + 32, + 31220, + 32, + 32696, + 32, + 43357, + 32, + 32247, + 32, + 38314, + 32, + 35892428, + 10, + 9462713, + 1021, + 10, + 38887044, + 32947, + 10 + ], + "PlutusV3": [ + 100788, + 420, + 1, + 1, + 1000, + 173, + 0, + 1, + 1000, + 59957, + 4, + 1, + 11183, + 32, + 201305, + 8356, + 4, + 16000, + 100, + 16000, + 100, + 16000, + 100, + 16000, + 100, + 16000, + 100, + 16000, + 100, + 100, + 100, + 16000, + 100, + 94375, + 32, + 132994, + 32, + 61462, + 4, + 72010, + 178, + 0, + 1, + 22151, + 32, + 91189, + 769, + 4, + 2, + 85848, + 123203, + 7305, + -900, + 1716, + 549, + 57, + 85848, + 0, + 1, + 1, + 1000, + 42921, + 4, + 2, + 24548, + 29498, + 38, + 1, + 898148, + 27279, + 1, + 51775, + 558, + 1, + 39184, + 1000, + 60594, + 1, + 141895, + 32, + 83150, + 32, + 15299, + 32, + 76049, + 1, + 13169, + 4, + 22100, + 10, + 28999, + 74, + 1, + 28999, + 74, + 1, + 43285, + 552, + 1, + 44749, + 541, + 1, + 33852, + 32, + 68246, + 32, + 72362, + 32, + 7243, + 32, + 7391, + 32, + 11546, + 32, + 85848, + 123203, + 7305, + -900, + 1716, + 549, + 57, + 85848, + 0, + 1, + 90434, + 519, + 0, + 1, + 74433, + 32, + 85848, + 123203, + 7305, + -900, + 1716, + 549, + 57, + 85848, + 0, + 1, + 1, + 85848, + 123203, + 7305, + -900, + 1716, + 549, + 57, + 85848, + 0, + 1, + 955506, + 213312, + 0, + 2, + 270652, + 22588, + 4, + 1457325, + 64566, + 4, + 20467, + 1, + 4, + 0, + 141992, + 32, + 100788, + 420, + 1, + 1, + 81663, + 32, + 59498, + 32, + 20142, + 32, + 24588, + 32, + 20744, + 32, + 25933, + 32, + 24623, + 32, + 43053543, + 10, + 53384111, + 14333, + 10, + 43574283, + 26308, + 10, + 16000, + 100, + 16000, + 100, + 962335, + 18, + 2780678, + 6, + 442008, + 1, + 52538055, + 3756, + 18, + 267929, + 18, + 76433006, + 8868, + 18, + 52948122, + 18, + 1995836, + 36, + 3227919, + 12, + 901022, + 1, + 166917843, + 4307, + 36, + 284546, + 36, + 158221314, + 26549, + 36, + 74698472, + 36, + 333849714, + 1, + 254006273, + 72, + 2174038, + 72, + 2261318, + 64571, + 4, + 207616, + 8310, + 4, + 1293828, + 28716, + 63, + 0, + 1, + 1006041, + 43623, + 251, + 0, + 1, + 100181, + 726, + 719, + 0, + 1, + 100181, + 726, + 719, + 0, + 1, + 100181, + 726, + 719, + 0, + 1, + 107878, + 680, + 0, + 1, + 95336, + 1, + 281145, + 18848, + 0, + 1, + 180194, + 159, + 1, + 1, + 158519, + 8942, + 0, + 1, + 159378, + 8813, + 0, + 1, + 107490, + 3298, + 1, + 106057, + 655, + 1, + 1964219, + 24520, + 3 + ] + }, + "minFeeRefScriptCostPerByte": 15, + "drepDeposit": { + "__bigint": "1000000000000" + }, + "govActionDeposit": { + "__bigint": "1000000000" + } +} From 3e7098e521bc1517b01f44e065d24f49bdcb828f Mon Sep 17 00:00:00 2001 From: FilipBl4gojevic <84703395+FilipBl4gojevic@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:03:29 +0200 Subject: [PATCH 02/14] test(builder): wire fixture tests into the unit gate, validate captures - Move fixture-provider.test.ts up to test/ (test:unit's glob is packages/*/test/*.test.ts, deliberately shallow, so a nested test/fixtures/ file was silently never run). The fixture module itself (fixture-provider.ts, the capture script, the JSON fixture) stays in test/fixtures/; only the *.test.ts file moved, importing it via ./fixtures/fixture-provider.ts. - Harden capture-protocol-params.mjs: refuse to write a fixture whose costModels/coinsPerUtxoByte/keyDeposit could have come from OgmiosProvider's own `??` fallback defaults instead of a genuine response. The brief's sanity numbers (minFeeA 45, minFeeB 156253, ...) are literally those same fallback literals, so matching them proves nothing; costModels has no populated fallback, so a well-formed, appropriately-sized cost model per Plutus version is the one signal a schema-mismatched response cannot fake. - packages/builder/tsconfig.json: include test/**/* so `npm run typecheck` actually checks builder's test files (it previously covered src/**/* only, unlike packages/shared). Needed allowImportingTsExtensions for the now-in-scope relative .ts-suffixed imports across every existing test file. Surfaced one real pre-existing gap: auth.test.ts calls loadAuthConfig with deliberately minimal env objects, but the exported signature demanded the full env.d.ts-augmented NodeJS.ProcessEnv (7 required custom fields) even though the function only ever reads MCP_AUTH_TOKENS. Narrowed the parameter to Record - the function's real contract - which is a type-only change with a single production call site (index.ts's parameterless loadAuthConfig()) that already relies on the default. Verified: test:unit 117/117 (was 113 - the 4 fixture tests were never counted before), typecheck 0 errors, test:smoke 28/28, build unaffected. Co-Authored-By: Claude Fable 5 --- packages/builder/src/auth.ts | 2 +- .../{fixtures => }/fixture-provider.test.ts | 4 +-- .../test/fixtures/capture-protocol-params.mjs | 33 +++++++++++++++++++ packages/builder/tsconfig.json | 5 +-- 4 files changed, 39 insertions(+), 5 deletions(-) rename packages/builder/test/{fixtures => }/fixture-provider.test.ts (94%) diff --git a/packages/builder/src/auth.ts b/packages/builder/src/auth.ts index 5805b8c..4f8743f 100644 --- a/packages/builder/src/auth.ts +++ b/packages/builder/src/auth.ts @@ -33,7 +33,7 @@ function labelForToken(token: string): string { // (hex / base64url / alphanumeric) to avoid this - there is no way for this parser // to detect the fragmentation after the fact, since the comma is already consumed // by the top-level split before any entry is inspected. -export function loadAuthConfig(env: NodeJS.ProcessEnv = process.env): AuthConfig { +export function loadAuthConfig(env: Record = process.env): AuthConfig { const raw = (env.MCP_AUTH_TOKENS ?? '').trim(); if (raw === '') return { enabled: false, identities: new Map() }; diff --git a/packages/builder/test/fixtures/fixture-provider.test.ts b/packages/builder/test/fixture-provider.test.ts similarity index 94% rename from packages/builder/test/fixtures/fixture-provider.test.ts rename to packages/builder/test/fixture-provider.test.ts index db3994a..95d1c61 100644 --- a/packages/builder/test/fixtures/fixture-provider.test.ts +++ b/packages/builder/test/fixture-provider.test.ts @@ -1,8 +1,8 @@ -// packages/builder/test/fixtures/fixture-provider.test.ts +// packages/builder/test/fixture-provider.test.ts import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; import { Lucid } from '@lucid-evolution/lucid'; -import { FixtureProvider, fixtureProtocolParameters, FIXTURE_UTXOS, OWN_ADDRESS, FOREIGN_ADDRESS } from './fixture-provider.ts'; +import { FixtureProvider, fixtureProtocolParameters, FIXTURE_UTXOS, OWN_ADDRESS, FOREIGN_ADDRESS } from './fixtures/fixture-provider.ts'; describe('FixtureProvider', () => { test('serves the captured protocol parameters with bigints revived', () => { diff --git a/packages/builder/test/fixtures/capture-protocol-params.mjs b/packages/builder/test/fixtures/capture-protocol-params.mjs index b41aa48..89ad1d3 100644 --- a/packages/builder/test/fixtures/capture-protocol-params.mjs +++ b/packages/builder/test/fixtures/capture-protocol-params.mjs @@ -16,6 +16,39 @@ import { OgmiosProvider } from '@apexfusion/vector-mcp-shared/provider'; const ogmiosUrl = process.env.VECTOR_OGMIOS_URL || 'https://ogmios.vector.testnet.apexfusion.org'; const provider = new OgmiosProvider({ ogmiosUrl, submitUrl: 'http://unused.invalid', koiosUrl: '' }); const params = await provider.getProtocolParameters(); + +// Refuse to write a fixture that could have come from OgmiosProvider's own +// `??` fallback defaults instead of a real response. Several numeric fields +// (minFeeA, minFeeB, coinsPerUtxoByte, keyDeposit, ...) fall back to +// hardcoded literals on a schema mismatch, so matching those literals proves +// nothing on its own — a broken RPC response is indistinguishable from a +// testnet that genuinely uses standard values. costModels has no such +// populated fallback (provider.ts's only fallback there is `{}`), so a +// well-formed, appropriately-sized cost model per Plutus version is the one +// signal that cannot be produced by falling through to a default. +function sizeOf(x) { + if (Array.isArray(x)) return x.length; + if (x && typeof x === 'object') return Object.keys(x).length; + return 0; +} + +const failures = []; +for (const version of ['PlutusV1', 'PlutusV2', 'PlutusV3']) { + const size = sizeOf(params.costModels?.[version]); + if (size < 100) { + failures.push(`costModels.${version} has ${size} entries, want >= 100`); + } +} +if (typeof params.coinsPerUtxoByte !== 'bigint') { + failures.push(`coinsPerUtxoByte is ${typeof params.coinsPerUtxoByte}, want bigint`); +} +if (typeof params.keyDeposit !== 'bigint') { + failures.push(`keyDeposit is ${typeof params.keyDeposit}, want bigint`); +} +if (failures.length > 0) { + throw new Error('capture looks like fallback defaults, refusing to write: ' + failures.join('; ')); +} + const json = JSON.stringify(params, (_k, v) => (typeof v === 'bigint' ? { __bigint: v.toString() } : v), 2); const out = resolve(dirname(fileURLToPath(import.meta.url)), 'protocol-params.fixture.json'); writeFileSync(out, json + '\n'); diff --git a/packages/builder/tsconfig.json b/packages/builder/tsconfig.json index 7d1320a..81d86a6 100644 --- a/packages/builder/tsconfig.json +++ b/packages/builder/tsconfig.json @@ -10,8 +10,9 @@ "noEmit": true, "allowJs": true, "checkJs": false, - "resolveJsonModule": true + "resolveJsonModule": true, + "allowImportingTsExtensions": true }, - "include": ["src/**/*"], + "include": ["src/**/*", "test/**/*"], "exclude": ["node_modules", "build"] } From 9a10e85df731f68bb7a862e76a51869688eac15c Mon Sep 17 00:00:00 2001 From: FilipBl4gojevic <84703395+FilipBl4gojevic@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:17:00 +0200 Subject: [PATCH 03/14] feat(builder): keyless build core - lucidForAddress, buildSendApex, buildSendTokens --- packages/builder/src/vector/build.ts | 96 ++++++++++++++ packages/builder/test/build.test.ts | 184 +++++++++++++++++++++++++++ packages/shared/src/types.ts | 19 +++ 3 files changed, 299 insertions(+) create mode 100644 packages/builder/src/vector/build.ts create mode 100644 packages/builder/test/build.test.ts diff --git a/packages/builder/src/vector/build.ts b/packages/builder/src/vector/build.ts new file mode 100644 index 0000000..9d1ee0b --- /dev/null +++ b/packages/builder/src/vector/build.ts @@ -0,0 +1,96 @@ +// packages/builder/src/vector/build.ts +// +// Keyless transaction building — the core of the non-custodial split's +// builder side. Every function here takes an already-initialized Lucid +// instance whose wallet was selected with selectWallet.fromAddress (no key +// material anywhere in this package) and returns UNSIGNED CBOR. Signing +// happens on the user's local signer; submission via vector_submit_transaction. +import { + Lucid, fromText, Data, applyDoubleCborEncoding, validatorToAddress, + validatorToScriptHash, getAddressDetails, +} from '@lucid-evolution/lucid'; +import type { LucidEvolution, Provider, SpendingValidator, TxSignBuilder } from '@lucid-evolution/lucid'; +import { lovelaceToAda } from '@apexfusion/vector-mcp-shared/tx'; +import type { + TxOutput, VectorUnsignedBuildResult, VectorBuildDeployResult, VectorBuildInteractResult, +} from '@apexfusion/vector-mcp-shared/types'; + +/** + * Initialize Lucid for keyless building: validate the change address, fetch + * its UTxOs, and select an address-only wallet (spike-proven: PR-4 spike (a)). + * Validation happens BEFORE any provider call so a bad address fails fast + * and cheap. + */ +export async function lucidForAddress(provider: Provider, changeAddress: string): Promise { + try { + getAddressDetails(changeAddress); + } catch (err) { + throw new Error(`Invalid change address: ${err instanceof Error ? err.message : String(err)}`); + } + const lucid = await Lucid(provider, 'Mainnet'); + const utxos = await lucid.utxosAt(changeAddress); + if (utxos.length === 0) { + throw new Error(`No UTxOs found at ${changeAddress} — the wallet is empty or the address is wrong`); + } + lucid.selectWallet.fromAddress(changeAddress, utxos); + return lucid; +} + +function toBuildResult(completed: TxSignBuilder): VectorUnsignedBuildResult { + const txJson = (completed as any).toJSON?.() ?? {}; + const fee = String(txJson?.body?.fee ?? '0'); + return { + txCbor: completed.toCBOR(), + txHash: completed.toHash(), + fee, + feeAda: lovelaceToAda(fee), + }; +} + +function requireValidRecipient(address: string): void { + try { + getAddressDetails(address); + } catch (err) { + throw new Error(`Invalid recipient address: ${err instanceof Error ? err.message : String(err)}`); + } +} + +export async function buildSendApex( + lucid: LucidEvolution, + p: { recipientAddress: string; amountApex: number; metadataJson?: string }, +): Promise { + requireValidRecipient(p.recipientAddress); + if (typeof p.amountApex !== 'number' || !Number.isFinite(p.amountApex) || p.amountApex <= 0) { + throw new Error('Amount must be a positive number'); + } + const lovelace = BigInt(Math.floor(p.amountApex * 1_000_000)); + let tx = lucid.newTx().pay.ToAddress(p.recipientAddress, { lovelace }); + if (p.metadataJson) { + tx = tx.attachMetadata(674, JSON.parse(p.metadataJson)); + } + return toBuildResult(await tx.complete()); +} + +export async function buildSendTokens( + lucid: LucidEvolution, + p: { recipientAddress: string; policyId: string; assetName: string; amount: string; apexAmount?: number }, +): Promise { + requireValidRecipient(p.recipientAddress); + if (!/^[0-9a-fA-F]{56}$/.test(p.policyId)) { + throw new Error('Invalid policy id: expected 56 hex characters'); + } + if (!p.amount || isNaN(Number(p.amount)) || Number(p.amount) <= 0) { + throw new Error('Amount must be a positive number'); + } + let assetNameHex = p.assetName; + if (p.assetName && !/^[0-9a-fA-F]+$/.test(p.assetName)) { + assetNameHex = fromText(p.assetName); + } + const unit = `${p.policyId}${assetNameHex}`; + const outputLovelace = p.apexAmount ? BigInt(Math.floor(p.apexAmount * 1_000_000)) : 2_000_000n; + const tx = lucid.newTx().pay.ToAddress(p.recipientAddress, { + lovelace: outputLovelace, + [unit]: BigInt(p.amount), + }); + return toBuildResult(await tx.complete()); +} diff --git a/packages/builder/test/build.test.ts b/packages/builder/test/build.test.ts new file mode 100644 index 0000000..82be9d3 --- /dev/null +++ b/packages/builder/test/build.test.ts @@ -0,0 +1,184 @@ +// packages/builder/test/build.test.ts +import { describe, test, before } from 'node:test'; +import assert from 'node:assert/strict'; +import { CML } from '@lucid-evolution/lucid'; +import type { LucidEvolution } from '@lucid-evolution/lucid'; +import { FixtureProvider, FIXTURE_UTXOS, OWN_ADDRESS, FOREIGN_ADDRESS, FIXTURE_TOKEN_UNIT } from './fixtures/fixture-provider.ts'; +import { lucidForAddress, buildSendApex, buildSendTokens } from '../src/vector/build.ts'; + +// Decode helpers — CML is re-exported by @lucid-evolution/lucid, no extra dep. +// If a CML method name below is off (from_cbor_hex / body / outputs / amount / +// multi_asset traversal), cross-check against the mutation-tested reference: +// `git show feat/signer-package:packages/signer/src/decode.ts` — fix toward +// that, do not guess at the wasm API. +function decodeOutputs(cborHex: string): Array<{ address: string; lovelace: bigint; units: Record }> { + const tx = CML.Transaction.from_cbor_hex(cborHex); + const outs = tx.body().outputs(); + const result = []; + for (let i = 0; i < outs.len(); i++) { + const o = outs.get(i); + const amount = o.amount(); + const units: Record = {}; + const ma = amount.multi_asset(); + if (ma) { + const policies = ma.keys(); + for (let p = 0; p < policies.len(); p++) { + const policy = policies.get(p); + const assets = ma.get_assets(policy); + if (!assets) continue; + const names = assets.keys(); + for (let n = 0; n < names.len(); n++) { + const name = names.get(n); + units[policy.to_hex() + name.to_hex()] = BigInt(assets.get(name)!.toString()); + } + } + } + result.push({ address: o.address().to_bech32(), lovelace: BigInt(amount.coin().toString()), units }); + } + return result; +} +function decodeFee(cborHex: string): bigint { + return BigInt(CML.Transaction.from_cbor_hex(cborHex).body().fee().toString()); +} +function decodeMetadataLabels(cborHex: string): string[] { + const aux = CML.Transaction.from_cbor_hex(cborHex).auxiliary_data(); + if (!aux) return []; + const metadata = aux.metadata(); + if (!metadata) return []; + const labels = metadata.labels(); + const out: string[] = []; + for (let i = 0; i < labels.len(); i++) out.push(labels.get(i).toString()); + return out; +} + +let lucid: LucidEvolution; +before(async () => { + lucid = await lucidForAddress(new FixtureProvider(FIXTURE_UTXOS), OWN_ADDRESS); +}); + +describe('lucidForAddress', () => { + test('rejects a malformed change address before any provider call', async () => { + // A provider whose every method throws 'network' — if validation is + // ordered correctly the error is about the address, not the network. + const boobyTrapped = new FixtureProvider([]); + (boobyTrapped as any).getProtocolParameters = () => { throw new Error('network hit'); }; + await assert.rejects( + () => lucidForAddress(boobyTrapped, 'not-an-address'), + /Invalid change address/, + ); + }); + + test('refuses an address with no UTxOs (nothing to build from)', async () => { + await assert.rejects( + () => lucidForAddress(new FixtureProvider(FIXTURE_UTXOS), FOREIGN_ADDRESS), + /No UTxOs found/, + ); + }); +}); + +describe('buildSendApex', () => { + test('builds an unsigned tx paying the recipient with change to the own address', async () => { + const r = await buildSendApex(lucid, { recipientAddress: FOREIGN_ADDRESS, amountApex: 3 }); + const outs = decodeOutputs(r.txCbor); + const toRecipient = outs.filter((o) => o.address === FOREIGN_ADDRESS); + assert.equal(toRecipient.length, 1); + assert.equal(toRecipient[0].lovelace, 3_000_000n); + const change = outs.filter((o) => o.address === OWN_ADDRESS); + assert.ok(change.length >= 1, 'no change output back to the own address'); + }); + + test('value is conserved: inputs = outputs + fee', async () => { + const r = await buildSendApex(lucid, { recipientAddress: FOREIGN_ADDRESS, amountApex: 3 }); + const outs = decodeOutputs(r.txCbor); + const totalOut = outs.reduce((s, o) => s + o.lovelace, 0n); + const fee = decodeFee(r.txCbor); + const tx = CML.Transaction.from_cbor_hex(r.txCbor); + const inputs = tx.body().inputs(); + let totalIn = 0n; + for (let i = 0; i < inputs.len(); i++) { + const input = inputs.get(i); + const match = FIXTURE_UTXOS.find( + (u) => u.txHash === input.transaction_id().to_hex() && u.outputIndex === Number(input.index()), + ); + assert.ok(match, 'tx consumed an input that is not in the fixture set'); + totalIn += BigInt(match.assets.lovelace); + } + assert.equal(totalIn, totalOut + fee, 'lovelace not conserved'); + assert.equal(r.fee, fee.toString(), 'reported fee differs from CBOR fee'); + }); + + test('the result is UNSIGNED and the hash matches the body', async () => { + const r = await buildSendApex(lucid, { recipientAddress: FOREIGN_ADDRESS, amountApex: 3 }); + const tx = CML.Transaction.from_cbor_hex(r.txCbor); + const vkeys = tx.witness_set().vkeywitnesses(); + assert.ok(vkeys === undefined || vkeys.len() === 0, 'built tx must carry no vkey witnesses'); + assert.equal(CML.hash_transaction(tx.body()).to_hex(), r.txHash); + }); + + test('attaches JSON metadata under label 674', async () => { + const r = await buildSendApex(lucid, { + recipientAddress: FOREIGN_ADDRESS, amountApex: 3, metadataJson: '{"msg":"hi"}', + }); + assert.deepEqual(decodeMetadataLabels(r.txCbor), ['674']); + }); + + test('rejects a malformed recipient before building', async () => { + await assert.rejects( + () => buildSendApex(lucid, { recipientAddress: 'addr1garbage', amountApex: 3 }), + /Invalid recipient address/, + ); + }); + + test('rejects a non-positive amount', async () => { + await assert.rejects(() => buildSendApex(lucid, { recipientAddress: FOREIGN_ADDRESS, amountApex: 0 }), /positive/); + await assert.rejects(() => buildSendApex(lucid, { recipientAddress: FOREIGN_ADDRESS, amountApex: -5 }), /positive/); + }); +}); + +describe('buildSendTokens', () => { + test('moves the token with the requested AP3X and returns change', async () => { + const r = await buildSendTokens(lucid, { + recipientAddress: FOREIGN_ADDRESS, + policyId: FIXTURE_TOKEN_UNIT.slice(0, 56), + assetName: FIXTURE_TOKEN_UNIT.slice(56), + amount: '200', + }); + const outs = decodeOutputs(r.txCbor); + const toRecipient = outs.find((o) => o.address === FOREIGN_ADDRESS); + assert.ok(toRecipient, 'no output to recipient'); + assert.equal(toRecipient.units[FIXTURE_TOKEN_UNIT], 200n); + assert.equal(toRecipient.lovelace, 2_000_000n, 'default min-ADA accompaniment expected'); + // the remaining 300 tokens must come back as change + const change = outs.filter((o) => o.address === OWN_ADDRESS); + const changeTokens = change.reduce((s, o) => s + (o.units[FIXTURE_TOKEN_UNIT] ?? 0n), 0n); + assert.equal(changeTokens, 300n, 'token change not conserved'); + }); + + test('converts a human-readable asset name to hex', async () => { + const r = await buildSendTokens(lucid, { + recipientAddress: FOREIGN_ADDRESS, + policyId: FIXTURE_TOKEN_UNIT.slice(0, 56), + assetName: 'TestToken', // text, not hex — must become 54657374546f6b656e + amount: '10', + }); + const outs = decodeOutputs(r.txCbor); + const toRecipient = outs.find((o) => o.address === FOREIGN_ADDRESS)!; + assert.equal(toRecipient.units[FIXTURE_TOKEN_UNIT], 10n); + }); + + test('rejects a malformed policy id (not 56 hex chars)', async () => { + await assert.rejects( + () => buildSendTokens(lucid, { recipientAddress: FOREIGN_ADDRESS, policyId: 'beef', assetName: '', amount: '1' }), + /policy id/i, + ); + }); + + test('rejects a non-positive token amount', async () => { + await assert.rejects( + () => buildSendTokens(lucid, { + recipientAddress: FOREIGN_ADDRESS, policyId: FIXTURE_TOKEN_UNIT.slice(0, 56), assetName: '', amount: '0', + }), + /positive/, + ); + }); +}); diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index a63baf5..35de9dc 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -131,6 +131,25 @@ export interface VectorInteractContractResult { }; } +// --- 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. + +export interface VectorUnsignedBuildResult { + txCbor: string; // hex CBOR of the UNSIGNED transaction + txHash: string; // body hash — stable across signing + fee: string; // lovelace + feeAda: string; +} + +export interface VectorBuildDeployResult extends VectorUnsignedBuildResult { + scriptAddress: string; scriptHash: string; scriptType: string; +} + +export interface VectorBuildInteractResult extends VectorUnsignedBuildResult { + scriptAddress: string; action: 'lock' | 'spend'; +} + // --- Agent Network --- export interface AgentProfile { From 59dd3cb57645979e623ad346789aec7a5773a048 Mon Sep 17 00:00:00 2001 From: FilipBl4gojevic <84703395+FilipBl4gojevic@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:34:36 +0200 Subject: [PATCH 04/14] feat(builder): keyless build core - multi-output, deploy, interact --- packages/builder/src/vector/build.ts | 91 +++++++++++++++ packages/builder/test/build-contracts.test.ts | 106 ++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 packages/builder/test/build-contracts.test.ts diff --git a/packages/builder/src/vector/build.ts b/packages/builder/src/vector/build.ts index 9d1ee0b..bba4799 100644 --- a/packages/builder/src/vector/build.ts +++ b/packages/builder/src/vector/build.ts @@ -94,3 +94,94 @@ export async function buildSendTokens( }); return toBuildResult(await tx.complete()); } + +export async function buildMultiOutput( + lucid: LucidEvolution, + p: { outputs: TxOutput[]; metadataJson?: string }, +): Promise { + if (!p.outputs || p.outputs.length === 0) { + throw new Error('At least one output is required'); + } + for (const o of p.outputs) requireValidRecipient(o.address); + let tx = lucid.newTx(); + for (const output of p.outputs) { + const assets: Record = { 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 (p.metadataJson) { + tx = tx.attachMetadata(674, JSON.parse(p.metadataJson)); + } + return toBuildResult(await tx.complete()); +} + +export async function buildDeployContract( + lucid: LucidEvolution, + p: { scriptCbor: string; scriptType: 'PlutusV1' | 'PlutusV2' | 'PlutusV3'; initialDatum?: string; lovelaceAmount?: number }, +): Promise { + const validator: SpendingValidator = { + type: p.scriptType, + script: applyDoubleCborEncoding(p.scriptCbor), + }; + const scriptAddress = validatorToAddress('Mainnet', validator); + const scriptHash = validatorToScriptHash(validator); + const datum = p.initialDatum || Data.void(); + const lovelace = BigInt(p.lovelaceAmount ?? 2_000_000); + const tx = lucid.newTx() + .pay.ToAddressWithData(scriptAddress, { kind: 'inline', value: datum }, { lovelace }); + return { ...toBuildResult(await tx.complete()), scriptAddress, scriptHash, scriptType: p.scriptType }; +} + +export async function buildInteractContract( + lucid: LucidEvolution, + p: { + scriptCbor: string; scriptType: 'PlutusV1' | 'PlutusV2' | 'PlutusV3'; action: 'lock' | 'spend'; + changeAddress: string; redeemer?: string; datum?: string; lovelaceAmount?: number; + utxoRef?: { txHash: string; outputIndex: number }; assets?: Record; + }, +): Promise { + const validator: SpendingValidator = { + type: p.scriptType, + script: applyDoubleCborEncoding(p.scriptCbor), + }; + const scriptAddress = validatorToAddress('Mainnet', validator); + + if (p.action === 'lock') { + const datumData = p.datum || Data.void(); + const outputAssets: Record = { lovelace: BigInt(p.lovelaceAmount ?? 2_000_000) }; + if (p.assets) { + for (const [unit, qty] of Object.entries(p.assets)) outputAssets[unit] = BigInt(qty); + } + const tx = lucid.newTx() + .pay.ToAddressWithData(scriptAddress, { kind: 'inline', value: datumData }, outputAssets); + return { ...toBuildResult(await tx.complete()), scriptAddress, action: 'lock' }; + } + + // SPEND: collect from the script back to the wallet + const scriptUtxos = p.utxoRef + ? await lucid.utxosByOutRef([p.utxoRef]) + : await lucid.utxosAt(scriptAddress); + if (!scriptUtxos || scriptUtxos.length === 0) { + throw new Error(`No UTxOs found at script address ${scriptAddress}`); + } + const redeemerData = p.redeemer || Data.void(); + let completed; + try { + completed = await lucid.newTx() + .collectFrom(scriptUtxos, redeemerData) + .attach.SpendingValidator(validator) + .addSigner(p.changeAddress) + .complete(); + } catch { + // Retry without the native UPLC evaluator — falls back to the provider's + // evaluateTx (network); mirrors the pre-split behaviour for chain quirks. + completed = await lucid.newTx() + .collectFrom(scriptUtxos, redeemerData) + .attach.SpendingValidator(validator) + .addSigner(p.changeAddress) + .complete({ localUPLCEval: false }); + } + return { ...toBuildResult(completed), scriptAddress, action: 'spend' }; +} diff --git a/packages/builder/test/build-contracts.test.ts b/packages/builder/test/build-contracts.test.ts new file mode 100644 index 0000000..8c53602 --- /dev/null +++ b/packages/builder/test/build-contracts.test.ts @@ -0,0 +1,106 @@ +// packages/builder/test/build-contracts.test.ts +import { describe, test, before } from 'node:test'; +import assert from 'node:assert/strict'; +import { CML, applyDoubleCborEncoding, validatorToAddress } from '@lucid-evolution/lucid'; +import type { LucidEvolution, SpendingValidator, UTxO } from '@lucid-evolution/lucid'; +import { FixtureProvider, FIXTURE_UTXOS, OWN_ADDRESS, FOREIGN_ADDRESS, FIXTURE_TXHASH } from './fixtures/fixture-provider.ts'; +import { lucidForAddress, buildMultiOutput, buildDeployContract, buildInteractContract } from '../src/vector/build.ts'; + +const ALWAYS_SUCCEEDS_V2 = '49480100002221200101'; +const validator: SpendingValidator = { type: 'PlutusV2', script: applyDoubleCborEncoding(ALWAYS_SUCCEEDS_V2) }; +const SCRIPT_ADDRESS = validatorToAddress('Mainnet', validator); + +function outputsOf(cborHex: string): Array<{ address: string; lovelace: bigint }> { + const outs = CML.Transaction.from_cbor_hex(cborHex).body().outputs(); + const result = []; + for (let i = 0; i < outs.len(); i++) { + const o = outs.get(i); + result.push({ address: o.address().to_bech32(), lovelace: BigInt(o.amount().coin().toString()) }); + } + return result; +} + +let lucid: LucidEvolution; +before(async () => { + lucid = await lucidForAddress(new FixtureProvider(FIXTURE_UTXOS), OWN_ADDRESS); +}); + +describe('buildMultiOutput', () => { + test('pays every output and conserves value', async () => { + const r = await buildMultiOutput(lucid, { + outputs: [ + { address: FOREIGN_ADDRESS, lovelace: 2_000_000 }, + { address: FOREIGN_ADDRESS, lovelace: 3_000_000 }, + ], + }); + const outs = outputsOf(r.txCbor); + const foreign = outs.filter((o) => o.address === FOREIGN_ADDRESS); + assert.equal(foreign.length, 2); + assert.deepEqual(foreign.map((o) => o.lovelace).sort(), [2_000_000n, 3_000_000n]); + }); + + test('rejects an empty output list', async () => { + await assert.rejects(() => buildMultiOutput(lucid, { outputs: [] }), /At least one output/); + }); +}); + +describe('buildDeployContract', () => { + test('locks funds at the derived script address with an inline datum', async () => { + const r = await buildDeployContract(lucid, { + scriptCbor: ALWAYS_SUCCEEDS_V2, scriptType: 'PlutusV2', lovelaceAmount: 2_000_000, + }); + assert.equal(r.scriptAddress, SCRIPT_ADDRESS); + const outs = outputsOf(r.txCbor); + const toScript = outs.find((o) => o.address === SCRIPT_ADDRESS); + assert.ok(toScript, 'no output to script address'); + assert.equal(toScript.lovelace, 2_000_000n); + // unsigned, as always + const vkeys = CML.Transaction.from_cbor_hex(r.txCbor).witness_set().vkeywitnesses(); + assert.ok(vkeys === undefined || vkeys.len() === 0); + }); +}); + +describe('buildInteractContract', () => { + test('lock: sends funds to the script address', async () => { + const r = await buildInteractContract(lucid, { + scriptCbor: ALWAYS_SUCCEEDS_V2, scriptType: 'PlutusV2', action: 'lock', + changeAddress: OWN_ADDRESS, lovelaceAmount: 2_000_000, + }); + assert.equal(r.action, 'lock'); + const toScript = outputsOf(r.txCbor).find((o) => o.address === SCRIPT_ADDRESS); + assert.ok(toScript && toScript.lovelace === 2_000_000n); + }); + + test('spend: collects a script UTxO back to the wallet (offline, native eval)', async () => { + const scriptUtxo: UTxO = { + txHash: FIXTURE_TXHASH, outputIndex: 7, address: SCRIPT_ADDRESS, + assets: { lovelace: 2_000_000n }, datum: 'd87980', + }; + const withScript = new FixtureProvider([...FIXTURE_UTXOS, scriptUtxo]); + const l = await lucidForAddress(withScript, OWN_ADDRESS); + const r = await buildInteractContract(l, { + scriptCbor: ALWAYS_SUCCEEDS_V2, scriptType: 'PlutusV2', action: 'spend', + changeAddress: OWN_ADDRESS, redeemer: 'd87980', + }); + assert.equal(r.action, 'spend'); + const tx = CML.Transaction.from_cbor_hex(r.txCbor); + // the script input must be among the consumed inputs + const inputs = tx.body().inputs(); + let consumedScriptInput = false; + for (let i = 0; i < inputs.len(); i++) { + const inp = inputs.get(i); + if (inp.transaction_id().to_hex() === FIXTURE_TXHASH && Number(inp.index()) === 7) consumedScriptInput = true; + } + assert.ok(consumedScriptInput, 'script UTxO was not consumed'); + }); + + test('spend: refuses when the script address holds nothing', async () => { + await assert.rejects( + () => buildInteractContract(lucid, { + scriptCbor: ALWAYS_SUCCEEDS_V2, scriptType: 'PlutusV2', action: 'spend', + changeAddress: OWN_ADDRESS, redeemer: 'd87980', + }), + /No UTxOs found at script address/, + ); + }); +}); From c8ce776ec1bc8b1763dd0ccd7491510fd83483cf Mon Sep 17 00:00:00 2001 From: FilipBl4gojevic <84703395+FilipBl4gojevic@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:06:33 +0200 Subject: [PATCH 05/14] fix(builder): harden keyless contract paths - action guard, utxoRef address check, ?? datums, eval-order and signer pins --- packages/builder/src/vector/build.ts | 65 +++++++++-------- packages/builder/test/build-contracts.test.ts | 71 ++++++++++++++++++- 2 files changed, 107 insertions(+), 29 deletions(-) diff --git a/packages/builder/src/vector/build.ts b/packages/builder/src/vector/build.ts index bba4799..0446096 100644 --- a/packages/builder/src/vector/build.ts +++ b/packages/builder/src/vector/build.ts @@ -127,7 +127,7 @@ export async function buildDeployContract( }; const scriptAddress = validatorToAddress('Mainnet', validator); const scriptHash = validatorToScriptHash(validator); - const datum = p.initialDatum || Data.void(); + const datum = p.initialDatum ?? Data.void(); const lovelace = BigInt(p.lovelaceAmount ?? 2_000_000); const tx = lucid.newTx() .pay.ToAddressWithData(scriptAddress, { kind: 'inline', value: datum }, { lovelace }); @@ -149,7 +149,8 @@ export async function buildInteractContract( const scriptAddress = validatorToAddress('Mainnet', validator); if (p.action === 'lock') { - const datumData = p.datum || Data.void(); + // '' must NOT silently become void - ?? lets a caller bug fail loudly downstream + const datumData = p.datum ?? Data.void(); const outputAssets: Record = { lovelace: BigInt(p.lovelaceAmount ?? 2_000_000) }; if (p.assets) { for (const [unit, qty] of Object.entries(p.assets)) outputAssets[unit] = BigInt(qty); @@ -157,31 +158,39 @@ export async function buildInteractContract( const tx = lucid.newTx() .pay.ToAddressWithData(scriptAddress, { kind: 'inline', value: datumData }, outputAssets); return { ...toBuildResult(await tx.complete()), scriptAddress, action: 'lock' }; + } else if (p.action === 'spend') { + // SPEND: collect from the script back to the wallet. + // no utxoRef: sweep ALL UTxOs at the script address - old tool's documented + // semantic ("otherwise spends all UTxOs at script address"), preserved here + // for behavior parity; this is not a gap, do not "fix" it. + const scriptUtxos = p.utxoRef + ? await lucid.utxosByOutRef([p.utxoRef]) + : await lucid.utxosAt(scriptAddress); + if (!scriptUtxos || scriptUtxos.length === 0) { + throw new Error(`No UTxOs found at script address ${scriptAddress}`); + } + if (p.utxoRef && scriptUtxos.some((u) => u.address !== scriptAddress)) { + throw new Error(`UTxO ${p.utxoRef.txHash}#${p.utxoRef.outputIndex} is not at script address ${scriptAddress}`); + } + const redeemerData = p.redeemer ?? Data.void(); + let completed; + try { + completed = await lucid.newTx() + .collectFrom(scriptUtxos, redeemerData) + .attach.SpendingValidator(validator) + .addSigner(p.changeAddress) + .complete(); + } catch { + // Retry without the native UPLC evaluator — falls back to the provider's + // evaluateTx (network); mirrors the pre-split behaviour for chain quirks. + completed = await lucid.newTx() + .collectFrom(scriptUtxos, redeemerData) + .attach.SpendingValidator(validator) + .addSigner(p.changeAddress) + .complete({ localUPLCEval: false }); + } + return { ...toBuildResult(completed), scriptAddress, action: 'spend' }; + } else { + throw new Error(`Invalid action: ${String(p.action)} - expected 'lock' or 'spend'`); } - - // SPEND: collect from the script back to the wallet - const scriptUtxos = p.utxoRef - ? await lucid.utxosByOutRef([p.utxoRef]) - : await lucid.utxosAt(scriptAddress); - if (!scriptUtxos || scriptUtxos.length === 0) { - throw new Error(`No UTxOs found at script address ${scriptAddress}`); - } - const redeemerData = p.redeemer || Data.void(); - let completed; - try { - completed = await lucid.newTx() - .collectFrom(scriptUtxos, redeemerData) - .attach.SpendingValidator(validator) - .addSigner(p.changeAddress) - .complete(); - } catch { - // Retry without the native UPLC evaluator — falls back to the provider's - // evaluateTx (network); mirrors the pre-split behaviour for chain quirks. - completed = await lucid.newTx() - .collectFrom(scriptUtxos, redeemerData) - .attach.SpendingValidator(validator) - .addSigner(p.changeAddress) - .complete({ localUPLCEval: false }); - } - return { ...toBuildResult(completed), scriptAddress, action: 'spend' }; } diff --git a/packages/builder/test/build-contracts.test.ts b/packages/builder/test/build-contracts.test.ts index 8c53602..e41a579 100644 --- a/packages/builder/test/build-contracts.test.ts +++ b/packages/builder/test/build-contracts.test.ts @@ -1,7 +1,7 @@ // packages/builder/test/build-contracts.test.ts import { describe, test, before } from 'node:test'; import assert from 'node:assert/strict'; -import { CML, applyDoubleCborEncoding, validatorToAddress } from '@lucid-evolution/lucid'; +import { CML, applyDoubleCborEncoding, validatorToAddress, validatorToScriptHash, getAddressDetails } from '@lucid-evolution/lucid'; import type { LucidEvolution, SpendingValidator, UTxO } from '@lucid-evolution/lucid'; import { FixtureProvider, FIXTURE_UTXOS, OWN_ADDRESS, FOREIGN_ADDRESS, FIXTURE_TXHASH } from './fixtures/fixture-provider.ts'; import { lucidForAddress, buildMultiOutput, buildDeployContract, buildInteractContract } from '../src/vector/build.ts'; @@ -42,6 +42,13 @@ describe('buildMultiOutput', () => { test('rejects an empty output list', async () => { await assert.rejects(() => buildMultiOutput(lucid, { outputs: [] }), /At least one output/); }); + + test('rejects an invalid recipient address among the outputs', async () => { + await assert.rejects( + () => buildMultiOutput(lucid, { outputs: [{ address: 'addr1garbage', lovelace: 2_000_000 }] }), + /Invalid recipient address/, + ); + }); }); describe('buildDeployContract', () => { @@ -50,6 +57,7 @@ describe('buildDeployContract', () => { scriptCbor: ALWAYS_SUCCEEDS_V2, scriptType: 'PlutusV2', lovelaceAmount: 2_000_000, }); assert.equal(r.scriptAddress, SCRIPT_ADDRESS); + assert.equal(r.scriptHash, validatorToScriptHash(validator)); const outs = outputsOf(r.txCbor); const toScript = outs.find((o) => o.address === SCRIPT_ADDRESS); assert.ok(toScript, 'no output to script address'); @@ -71,6 +79,26 @@ describe('buildInteractContract', () => { assert.ok(toScript && toScript.lovelace === 2_000_000n); }); + test('lock: rejects an empty-string datum instead of silently treating it as void', async () => { + // '' is falsy but caller-supplied; ?? must let it flow through and fail + // loudly downstream rather than defaulting to Data.void() (locking funds + // under a datum the caller never asked for). + await assert.rejects(() => buildInteractContract(lucid, { + scriptCbor: ALWAYS_SUCCEEDS_V2, scriptType: 'PlutusV2', action: 'lock', + changeAddress: OWN_ADDRESS, lovelaceAmount: 2_000_000, datum: '', + })); + }); + + test('rejects an invalid action', async () => { + await assert.rejects( + () => buildInteractContract(lucid, { + scriptCbor: ALWAYS_SUCCEEDS_V2, scriptType: 'PlutusV2', action: 'steal' as any, + changeAddress: OWN_ADDRESS, lovelaceAmount: 2_000_000, + }), + /Invalid action/, + ); + }); + test('spend: collects a script UTxO back to the wallet (offline, native eval)', async () => { const scriptUtxo: UTxO = { txHash: FIXTURE_TXHASH, outputIndex: 7, address: SCRIPT_ADDRESS, @@ -92,6 +120,36 @@ describe('buildInteractContract', () => { if (inp.transaction_id().to_hex() === FIXTURE_TXHASH && Number(inp.index()) === 7) consumedScriptInput = true; } assert.ok(consumedScriptInput, 'script UTxO was not consumed'); + // addSigner(changeAddress) must land in the tx body as a required signer, + // keyed by the wallet's payment key hash (not the script, not some other key). + const requiredSigners = tx.body().required_signers(); + assert.ok(requiredSigners && requiredSigners.len() > 0, 'no required_signers on the built tx'); + const signerHashes: string[] = []; + for (let i = 0; i < requiredSigners!.len(); i++) signerHashes.push(requiredSigners!.get(i).to_hex()); + const expectedHash = getAddressDetails(OWN_ADDRESS).paymentCredential?.hash; + assert.ok(expectedHash, 'OWN_ADDRESS has no payment credential to compare against'); + assert.deepEqual(signerHashes, [expectedHash]); + }); + + test('spend: succeeds via native UPLC eval without ever calling the provider fallback', async () => { + const scriptUtxo: UTxO = { + txHash: FIXTURE_TXHASH, outputIndex: 7, address: SCRIPT_ADDRESS, + assets: { lovelace: 2_000_000n }, datum: 'd87980', + }; + const withScript = new FixtureProvider([...FIXTURE_UTXOS, scriptUtxo]); + let evalCalls = 0; + (withScript as any).evaluateTx = async () => { + evalCalls++; + throw new Error('provider evaluateTx should not be reached for a natively-evaluable script'); + }; + const l = await lucidForAddress(withScript, OWN_ADDRESS); + await buildInteractContract(l, { + scriptCbor: ALWAYS_SUCCEEDS_V2, scriptType: 'PlutusV2', action: 'spend', + changeAddress: OWN_ADDRESS, redeemer: 'd87980', + }); + // pins the ordering: native eval must succeed on its own; the catch-block + // fallback to the provider's evaluateTx must stay unreached here. + assert.equal(evalCalls, 0, 'native UPLC evaluator should have succeeded without a provider fallback'); }); test('spend: refuses when the script address holds nothing', async () => { @@ -103,4 +161,15 @@ describe('buildInteractContract', () => { /No UTxOs found at script address/, ); }); + + test('spend: refuses a utxoRef that does not point at the script address', async () => { + await assert.rejects( + () => buildInteractContract(lucid, { + scriptCbor: ALWAYS_SUCCEEDS_V2, scriptType: 'PlutusV2', action: 'spend', + changeAddress: OWN_ADDRESS, redeemer: 'd87980', + utxoRef: { txHash: FIXTURE_TXHASH, outputIndex: 0 }, // a real wallet UTxO, not at SCRIPT_ADDRESS + }), + /not at script address/, + ); + }); }); From 6884fcc2446dec8051d6266370bc0d5bd115e8a7 Mon Sep 17 00:00:00 2001 From: FilipBl4gojevic <84703395+FilipBl4gojevic@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:26:28 +0200 Subject: [PATCH 06/14] feat(builder)!: replace custodial signing tools with keyless build_* tools (wallet/tx family) --- packages/builder/src/vector/vector.ts | 669 ++++-------------- .../builder/test/smoke/tool-inventory.test.ts | 8 +- .../test/smoke/tool-schemas.snapshot.json | 378 +++++----- 3 files changed, 309 insertions(+), 746 deletions(-) diff --git a/packages/builder/src/vector/vector.ts b/packages/builder/src/vector/vector.ts index 9cceda7..64f54da 100644 --- a/packages/builder/src/vector/vector.ts +++ b/packages/builder/src/vector/vector.ts @@ -1,7 +1,6 @@ import { z } from "zod/v4"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { Lucid, fromText, Data, applyDoubleCborEncoding, validatorToAddress, validatorToScriptHash, getAddressDetails } from '@lucid-evolution/lucid'; -import type { SpendingValidator } from '@lucid-evolution/lucid'; +import { Lucid } from '@lucid-evolution/lucid'; import * as dotenv from 'dotenv'; import { resolve } from 'path'; @@ -18,10 +17,12 @@ import { pollUntilConfirmed, buildConfirmationCheck } from './poll.js'; import { registerAgentNetworkTools } from './agent-network.js'; import { registerSelfImprovementTools } from './self-improvement.js'; import type { - VectorToken, VectorWalletInfo, VectorAdaTransactionResult, VectorTokenTransactionResult, - TxOutput, VectorBuildTransactionResult, VectorDryRunResult, VectorDeployContractResult, - VectorInteractContractResult, + VectorToken, VectorWalletInfo, VectorDryRunResult, } from '@apexfusion/vector-mcp-shared/types'; +import { + lucidForAddress, buildSendApex, buildSendTokens, buildMultiOutput, + buildDeployContract, buildInteractContract, +} from './build.js'; // Direct .env loading const __filename = fileURLToPath(import.meta.url); @@ -127,350 +128,13 @@ export async function getWalletInfo(mnemonic: string, accountIndex: number = 0): }; } -// Send AP3X transaction -export async function sendAda( - recipientAddress: string, - amountAda: number, - mnemonic: string, - metadata: any = null -): Promise { - if (!recipientAddress) { - throw new Error('Recipient address is required'); - } - - if (typeof amountAda !== 'number' || amountAda <= 0) { - throw new Error('Amount must be a positive number'); - } - - const lovelaceAmount = Math.floor(amountAda * 1_000_000); - - // Safety check - const safetyCheck = safetyLayer.checkTransaction(lovelaceAmount); - if (!safetyCheck.allowed) { - throw new Error(`Safety limit exceeded: ${safetyCheck.reason}`); - } - - const lucid = await initLucid(mnemonic); - const senderAddress = await lucid.wallet().address(); - - // Validate recipient address - try { - getAddressDetails(recipientAddress); - } catch (error) { - throw new Error(`Invalid recipient address: ${error instanceof Error ? error.message : String(error)}`); - } - - // Build transaction - let tx = lucid.newTx() - .pay.ToAddress(recipientAddress, { lovelace: BigInt(lovelaceAmount) }); - - if (metadata) { - const parsedMetadata = typeof metadata === 'string' ? JSON.parse(metadata) : metadata; - tx = tx.attachMetadata(674, parsedMetadata); - } - - const signBuilder = await tx.complete(); - const signedTx = await signBuilder.sign.withWallet().complete(); - const txHash = await signedTx.submit(); - - // Record in safety layer - safetyLayer.recordTransaction(txHash, lovelaceAmount, recipientAddress); - - return { - txHash, - senderAddress, - recipientAddress, - amount: amountAda, - links: { - explorer: explorerTxLink(txHash), - }, - }; -} - -// Send tokens transaction -export async function sendTokens( - recipientAddress: string, - policyId: string, - assetName: string, - amount: string, - mnemonic: string, - adaAmount: number | null = null -): Promise { - if (!recipientAddress) throw new Error('Recipient address is required'); - if (!policyId) throw new Error('Policy ID is required'); - if (!amount || isNaN(Number(amount)) || Number(amount) <= 0) { - throw new Error('Amount must be a positive number'); - } - - // Safety check on AP3X portion - const adaLovelace = adaAmount ? Math.floor(adaAmount * 1_000_000) : 2_000_000; // min ~2 AP3X - const safetyCheck = safetyLayer.checkTransaction(adaLovelace); - if (!safetyCheck.allowed) { - throw new Error(`Safety limit exceeded: ${safetyCheck.reason}`); - } - - const lucid = await initLucid(mnemonic); - const senderAddress = await lucid.wallet().address(); - - try { - getAddressDetails(recipientAddress); - } catch (error) { - throw new Error(`Invalid recipient address: ${error instanceof Error ? error.message : String(error)}`); - } - - let assetNameHex = assetName; - if (assetName && !/^[0-9a-fA-F]+$/.test(assetName)) { - assetNameHex = fromText(assetName); - } - - const unit = `${policyId}${assetNameHex}`; - const assets = { [unit]: BigInt(amount) }; - - const outputLovelace = adaAmount - ? BigInt(Math.floor(adaAmount * 1_000_000)) - : BigInt(2_000_000); // Default min AP3X - - let tx = lucid.newTx() - .pay.ToAddress(recipientAddress, { - lovelace: outputLovelace, - ...assets, - }); - - const signBuilder = await tx.complete(); - const signedTx = await signBuilder.sign.withWallet().complete(); - const txHash = await signedTx.submit(); - - safetyLayer.recordTransaction(txHash, Number(outputLovelace), recipientAddress); - - const displayAssetName = assetName ? formatAssetName(assetNameHex) : ''; - - return { - txHash, - senderAddress, - recipientAddress, - token: { - policyId, - name: displayAssetName, - amount, - }, - ada: lovelaceToAda(outputLovelace), - links: { - explorer: explorerTxLink(txHash), - }, - }; -} - -// Build a complex multi-output transaction -export async function buildTransaction( - outputs: TxOutput[], - mnemonic: string, - metadata: any = null, - submit: boolean = false, -): Promise { - if (!outputs || outputs.length === 0) { - throw new Error('At least one output is required'); - } - - // Calculate total AP3X across all outputs for safety check - const totalLovelace = outputs.reduce((sum, o) => sum + o.lovelace, 0); - const safetyCheck = safetyLayer.checkTransaction(totalLovelace); - if (!safetyCheck.allowed) { - throw new Error(`Safety limit exceeded: ${safetyCheck.reason}`); - } - - const lucid = await initLucid(mnemonic); - - let tx = lucid.newTx(); - - for (const output of outputs) { - const assets: Record = { - 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?.() ?? {}; - const fee = txJson?.body?.fee ?? '0'; - const txHash = completedTx.toHash(); - const txCbor = completedTx.toCBOR(); - - if (submit) { - const signedTx = await completedTx.sign.withWallet().complete(); - const submittedHash = await signedTx.submit(); - - safetyLayer.recordTransaction(submittedHash, totalLovelace, outputs.map(o => o.address).join(', ')); - - return { - txCbor: '', - txHash: submittedHash, - fee: String(fee), - feeAda: lovelaceToAda(fee), - outputCount: outputs.length, - totalAda: lovelaceToAda(totalLovelace), - submitted: true, - links: { explorer: explorerTxLink(submittedHash) }, - }; - } - - return { - txCbor, - txHash, - fee: String(fee), - feeAda: lovelaceToAda(fee), - outputCount: outputs.length, - totalAda: lovelaceToAda(totalLovelace), - submitted: false, - }; -} - -// Deploy a smart contract by locking funds at the script address -export async function deployContract( - scriptCbor: string, - scriptType: string, - mnemonic: string, - initialDatum: string | null = null, - lovelaceAmount: number = 2_000_000, -): Promise { - const safetyCheck = safetyLayer.checkTransaction(lovelaceAmount); - if (!safetyCheck.allowed) { - throw new Error(`Safety limit exceeded: ${safetyCheck.reason}`); - } - - const lucid = await initLucid(mnemonic); - - const validator: SpendingValidator = { - type: scriptType as any, - script: applyDoubleCborEncoding(scriptCbor), - }; - - const scriptAddress = validatorToAddress('Mainnet', validator); - const scriptHash = validatorToScriptHash(validator); - - const datum = initialDatum || Data.void(); - - let tx = lucid.newTx() - .pay.ToAddressWithData(scriptAddress, { kind: "inline", value: datum }, { lovelace: BigInt(lovelaceAmount) }); - - const signBuilder = await tx.complete(); - const signedTx = await signBuilder.sign.withWallet().complete(); - const txHash = await signedTx.submit(); - - safetyLayer.recordTransaction(txHash, lovelaceAmount, scriptAddress); - - return { - txHash, - scriptAddress, - scriptHash, - scriptType, - links: { explorer: explorerTxLink(txHash) }, - }; -} - -// Interact with a deployed smart contract (lock or spend) -export async function interactWithContract( - scriptCbor: string, - scriptType: string, - action: 'spend' | 'lock', - mnemonic: string, - redeemer: string | null = null, - datum: string | null = null, - lovelaceAmount: number = 2_000_000, - utxoRef: { txHash: string; outputIndex: number } | null = null, - assets: Record | null = null, -): Promise { - const lucid = await initLucid(mnemonic); - const walletAddress = await lucid.wallet().address(); - - const validator: SpendingValidator = { - type: scriptType as any, - script: applyDoubleCborEncoding(scriptCbor), - }; - - const scriptAddress = validatorToAddress('Mainnet', validator); - - if (action === 'lock') { - const safetyCheck = safetyLayer.checkTransaction(lovelaceAmount); - if (!safetyCheck.allowed) { - throw new Error(`Safety limit exceeded: ${safetyCheck.reason}`); - } - - const datumData = datum || Data.void(); - const outputAssets: Record = { lovelace: BigInt(lovelaceAmount) }; - if (assets) { - for (const [unit, qty] of Object.entries(assets)) { - outputAssets[unit] = BigInt(qty); - } - } - - let tx = lucid.newTx() - .pay.ToAddressWithData(scriptAddress, { kind: "inline", value: datumData }, outputAssets); - - const signBuilder = await tx.complete(); - const signedTx = await signBuilder.sign.withWallet().complete(); - const txHash = await signedTx.submit(); - - safetyLayer.recordTransaction(txHash, lovelaceAmount, scriptAddress); - - return { - txHash, - scriptAddress, - action: 'lock', - links: { explorer: explorerTxLink(txHash) }, - }; - } else { - // SPEND: collect from script - let scriptUtxos; - if (utxoRef) { - scriptUtxos = await lucid.utxosByOutRef([utxoRef]); - } else { - scriptUtxos = await lucid.utxosAt(scriptAddress); - } - - if (!scriptUtxos || scriptUtxos.length === 0) { - throw new Error(`No UTxOs found at script address ${scriptAddress}`); - } - - const redeemerData = redeemer || Data.void(); - - let completedTx; - try { - completedTx = await lucid.newTx() - .collectFrom(scriptUtxos, redeemerData) - .attach.SpendingValidator(validator) - .addSigner(walletAddress) - .complete(); - } catch (err) { - // Retry without native UPLC evaluator if it fails - completedTx = await lucid.newTx() - .collectFrom(scriptUtxos, redeemerData) - .attach.SpendingValidator(validator) - .addSigner(walletAddress) - .complete({ localUPLCEval: false }); - } - - const signedTx = await completedTx.sign.withWallet().complete(); - const txHash = await signedTx.submit(); - - // No spend recording for collecting - funds are coming back to wallet - - return { - txHash, - scriptAddress, - action: 'spend', - links: { explorer: explorerTxLink(txHash) }, - }; - } +// Shared provider for the keyless build_* tools (Task 5 reuses this). +function makeProvider(): OgmiosProvider { + return new OgmiosProvider({ + ogmiosUrl: VECTOR_OGMIOS_URL, + submitUrl: VECTOR_SUBMIT_URL, + koiosUrl: VECTOR_KOIOS_URL, + }); } // Register all Vector MCP tools @@ -667,66 +331,40 @@ ${utxoList}`, } ); - // vector_send_apex - Send APEX with safety limits (or craft unsigned TX) + // vector_build_send_apex - build an UNSIGNED AP3X payment (keyless) server.tool( - "vector_send_apex", - "Send AP3X from a wallet to a recipient address. Set unsigned_only=true to return unsigned CBOR without submitting (transaction-crafter mode).", + "vector_build_send_apex", + "Build an UNSIGNED transaction sending AP3X to a recipient. Takes no key material - sign the returned CBOR with your local signer (vector_signer_sign), then broadcast with vector_submit_transaction.", { + changeAddress: z.string().describe("Your wallet address (source of funds; receives change). Get it from your local signer's vector_signer_get_address."), recipientAddress: z.string().describe("Recipient Vector address (addr1...)"), amount: z.number().min(1).describe("Amount of AP3X to send"), - mnemonic: z.string().describe("15 or 24-word BIP39 mnemonic for the sending wallet"), metadata: z.string().optional().describe("Optional transaction metadata in JSON format"), - unsigned_only: z.boolean().optional().default(false).describe("If true, return unsigned TX CBOR without signing or submitting"), }, - async ({ recipientAddress, amount, mnemonic, metadata, unsigned_only }) => { + async ({ changeAddress, recipientAddress, amount, metadata }) => { const rateCheck = rateLimiter.check(); if (!rateCheck.allowed) { return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] }; } try { - if (unsigned_only) { - const lovelaceAmount = BigInt(Math.floor(amount * 1_000_000)); - const lucid = await initLucid(mnemonic); - let txBuilder = lucid.newTx().pay.ToAddress(recipientAddress, { lovelace: lovelaceAmount }); - if (metadata) { - const parsedMetadata = typeof metadata === 'string' ? JSON.parse(metadata) : metadata; - txBuilder = txBuilder.attachMetadata(674, parsedMetadata); - } - const tx = await txBuilder.complete(); - const txJson = (tx as any).toJSON?.() ?? {}; - const fee = txJson?.body?.fee ?? '0'; - const txCbor = tx.toCBOR(); - return { - content: [{ - type: "text", - text: `# Unsigned Transaction (Transaction-Crafter Mode) - -**Amount:** ${amount} AP3X (${lovelaceAmount} lovelace) -**To:** ${recipientAddress} -**Estimated Fee:** ${lovelaceToAda(fee)} AP3X - -**Unsigned TX CBOR:** -\`\`\` -${txCbor} -\`\`\` - -This transaction has NOT been submitted. Sign and submit it separately.`, - }], - }; - } - - const result = await sendAda(recipientAddress, amount, mnemonic, metadata); + const lucid = await lucidForAddress(makeProvider(), changeAddress); + const result = await buildSendApex(lucid, { recipientAddress, amountApex: amount, metadataJson: metadata }); return { content: [{ type: "text", - text: `# AP3X Transaction Successful + text: `# Unsigned Transaction Built -Transaction Hash: ${result.txHash} -From: ${result.senderAddress} -To: ${result.recipientAddress} -Amount: ${result.amount} AP3X +**Amount:** ${amount} AP3X +**To:** ${recipientAddress} +**Estimated Fee:** ${result.feeAda} AP3X +**Tx Hash:** ${result.txHash} -[View on Explorer](${result.links.explorer})`, +**Unsigned TX CBOR:** +\`\`\` +${result.txCbor} +\`\`\` + +Non-custodial flow: 1) sign locally with vector_signer_sign { txCbor } — your keys never leave your machine; 2) broadcast with vector_submit_transaction { signedTxCbor }; 3) confirm with vector_await_transaction { txHash }.`, }], }; } catch (err) { @@ -734,93 +372,56 @@ Amount: ${result.amount} AP3X return { content: [{ type: "text", - text: `Failed to send AP3X: ${error.message} + text: `Failed to build transaction: ${error.message} **Troubleshooting Tips:** -1. Check that your wallet has sufficient balance +1. Verify the change address is your funded wallet address (addr1...) 2. Verify the recipient address is correct (addr1...) -3. Check spend limits with vector_get_spend_limits -4. Verify the Ogmios endpoint is reachable`, +3. Ensure the wallet has enough AP3X for the amount plus fees`, }], }; } } ); - // vector_send_tokens - Send native tokens with safety limits (or craft unsigned TX) + // vector_build_send_tokens - build an UNSIGNED native-token transfer (keyless) server.tool( - "vector_send_tokens", - "Send Vector native tokens from a wallet to a recipient address. Set unsigned_only=true to return unsigned CBOR without submitting.", + "vector_build_send_tokens", + "Build an UNSIGNED transaction sending Vector native tokens. Takes no key material - sign the returned CBOR with your local signer (vector_signer_sign), then broadcast with vector_submit_transaction.", { + changeAddress: z.string().describe("Your wallet address (source of funds; receives change). Get it from your local signer's vector_signer_get_address."), recipientAddress: z.string().describe("Recipient Vector address (addr1...)"), - policyId: z.string().describe("Token policy ID"), - assetName: z.string().describe("Asset name (can be empty for policy-only tokens)"), + policyId: z.string().describe("Token policy ID (56 hex characters)"), + assetName: z.string().describe("Asset name (text or hex; can be empty for policy-only tokens)"), amount: z.string().describe("Amount of tokens to send"), - mnemonic: z.string().describe("15 or 24-word BIP39 mnemonic for the sending wallet"), - adaAmount: z.number().optional().describe("Optional AP3X to include (uses minimum required if not specified)"), - unsigned_only: z.boolean().optional().default(false).describe("If true, return unsigned TX CBOR without signing or submitting"), + adaAmount: z.number().optional().describe("Optional AP3X to include with the tokens (defaults to the 2 AP3X minimum)"), }, - async ({ recipientAddress, policyId, assetName, amount, mnemonic, adaAmount, unsigned_only }) => { + async ({ changeAddress, recipientAddress, policyId, assetName, amount, adaAmount }) => { const rateCheck = rateLimiter.check(); if (!rateCheck.allowed) { return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] }; } try { - if (unsigned_only) { - const lucid = await initLucid(mnemonic); - let assetNameHex = assetName; - if (assetName && !/^[0-9a-fA-F]+$/.test(assetName)) { - assetNameHex = fromText(assetName); - } - const unit = `${policyId}${assetNameHex}`; - const outputLovelace = adaAmount - ? BigInt(Math.floor(adaAmount * 1_000_000)) - : BigInt(2_000_000); - const tx = await lucid.newTx() - .pay.ToAddress(recipientAddress, { lovelace: outputLovelace, [unit]: BigInt(amount) }) - .complete(); - const txJson = (tx as any).toJSON?.() ?? {}; - const fee = txJson?.body?.fee ?? '0'; - const txCbor = tx.toCBOR(); - return { - content: [{ - type: "text", - text: `# Unsigned Token Transaction (Transaction-Crafter Mode) - -**Token:** ${formatAssetName(assetNameHex) || policyId.substring(0,8) + '...'} -**Amount:** ${amount} -**To:** ${recipientAddress} -**Included AP3X:** ${lovelaceToAda(outputLovelace)} AP3X -**Estimated Fee:** ${lovelaceToAda(fee)} AP3X - -**Unsigned TX CBOR:** -\`\`\` -${txCbor} -\`\`\` - -This transaction has NOT been submitted. Sign and submit it separately.`, - }], - }; - } - - const result = await sendTokens(recipientAddress, policyId, assetName, amount, mnemonic, adaAmount); + const lucid = await lucidForAddress(makeProvider(), changeAddress); + const result = await buildSendTokens(lucid, { recipientAddress, policyId, assetName, amount, apexAmount: adaAmount }); + const displayName = assetName && !/^[0-9a-fA-F]+$/.test(assetName) ? assetName : (formatAssetName(assetName) || `${policyId.substring(0, 8)}...`); return { content: [{ type: "text", - text: `# Token Transaction Successful + text: `# Unsigned Token Transaction Built -Transaction Hash: ${result.txHash} -From: ${result.senderAddress} -To: ${result.recipientAddress} +**Token:** ${displayName} +**Amount:** ${amount} +**To:** ${recipientAddress} +**Estimated Fee:** ${result.feeAda} AP3X +**Tx Hash:** ${result.txHash} -Token Details: -- Policy ID: ${result.token.policyId} -- Asset Name: ${result.token.name || '(none)'} -- Amount: ${result.token.amount} +**Unsigned TX CBOR:** +\`\`\` +${result.txCbor} +\`\`\` -Included AP3X: ${result.ada} AP3X - -[View on Explorer](${result.links.explorer})`, +Non-custodial flow: 1) sign locally with vector_signer_sign { txCbor }; 2) broadcast with vector_submit_transaction { signedTxCbor }; 3) confirm with vector_await_transaction { txHash }.`, }], }; } catch (err) { @@ -828,13 +429,12 @@ Included AP3X: ${result.ada} AP3X return { content: [{ type: "text", - text: `Failed to send tokens: ${error.message} + text: `Failed to build token transaction: ${error.message} **Troubleshooting Tips:** -1. Check that your wallet has sufficient AP3X and token balance -2. Verify the policy ID and asset name are correct -3. Verify the recipient address is correct (addr1...) -4. Check spend limits with vector_get_spend_limits`, +1. Check that the wallet at changeAddress holds the token and enough AP3X +2. Verify the policy ID (56 hex chars) and asset name are correct +3. Verify the recipient address is correct (addr1...)`, }], }; } @@ -885,57 +485,44 @@ ${log.length > 0 ? `## Recent Transactions (last ${Math.min(5, log.length)}):\n$ } ); - // vector_build_transaction - Build a complex multi-output transaction + // vector_build_transaction - build an UNSIGNED multi-output transaction (keyless) server.tool( "vector_build_transaction", - "Build a complex multi-output transaction with metadata. Set submit=true to sign and submit, or false to return unsigned CBOR for review.", + "Build an UNSIGNED multi-output transaction with optional metadata. Takes no key material and never submits - sign the returned CBOR with your local signer (vector_signer_sign), then broadcast with vector_submit_transaction.", { + changeAddress: z.string().describe("Your wallet address (source of funds; receives change). Get it from your local signer's vector_signer_get_address."), outputs: z.array(z.object({ address: z.string().describe("Recipient Vector address"), lovelace: z.number().describe("Amount in lovelace (1 AP3X = 1,000,000 lovelace)"), assets: z.record(z.string(), z.string()).optional().describe("Optional native assets: { 'policyId+assetNameHex': 'quantity' }"), })).min(1).describe("Transaction outputs"), - mnemonic: z.string().describe("15 or 24-word BIP39 mnemonic for the signing wallet"), metadata: z.string().optional().describe("Optional JSON metadata (attached under label 674)"), - submit: z.boolean().optional().describe("If true, sign and submit the transaction. If false/omitted, return unsigned CBOR for review."), }, - async ({ outputs, mnemonic, metadata, submit }) => { + async ({ changeAddress, outputs, metadata }) => { const rateCheck = rateLimiter.check(); if (!rateCheck.allowed) { return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] }; } try { - const result = await buildTransaction(outputs, mnemonic, metadata, submit); - - if (result.submitted) { - return { - content: [{ - type: "text", - text: `# Transaction Submitted - -Transaction Hash: ${result.txHash} -Fee: ${result.feeAda} AP3X -Outputs: ${result.outputCount} -Total AP3X Sent: ${result.totalAda} AP3X - -[View on Explorer](${result.links?.explorer})`, - }], - }; - } - + const lucid = await lucidForAddress(makeProvider(), changeAddress); + const result = await buildMultiOutput(lucid, { outputs, metadataJson: metadata }); + const totalLovelace = outputs.reduce((sum, o) => sum + o.lovelace, 0); return { content: [{ type: "text", - text: `# Transaction Built (Not Submitted) + text: `# Unsigned Transaction Built (Not Submitted) -Transaction Hash: ${result.txHash} -Fee: ${result.feeAda} AP3X -Outputs: ${result.outputCount} -Total AP3X: ${result.totalAda} AP3X +**Tx Hash:** ${result.txHash} +**Fee:** ${result.feeAda} AP3X +**Outputs:** ${outputs.length} +**Total AP3X:** ${lovelaceToAda(totalLovelace)} AP3X -CBOR (hex): ${result.txCbor.substring(0, 200)}${result.txCbor.length > 200 ? '...' : ''} +**Unsigned TX CBOR:** +\`\`\` +${result.txCbor} +\`\`\` -Use vector_dry_run with this CBOR to simulate, or call vector_build_transaction again with submit=true to submit.`, +Use vector_dry_run with this CBOR to simulate it, sign it locally with vector_signer_sign, then broadcast with vector_submit_transaction.`, }], }; } catch (err) { @@ -947,8 +534,7 @@ Use vector_dry_run with this CBOR to simulate, or call vector_build_transaction **Troubleshooting Tips:** 1. Verify all recipient addresses are valid (addr1...) -2. Ensure wallet has enough AP3X for outputs + fees -3. Check spend limits with vector_get_spend_limits`, +2. Ensure the wallet at changeAddress has enough AP3X for outputs + fees`, }], }; } @@ -1251,44 +837,42 @@ ${txList} } ); - // vector_deploy_contract - Deploy a Plutus/Aiken smart contract + // vector_build_deploy_contract - build an UNSIGNED contract deployment (keyless) server.tool( - "vector_deploy_contract", - "Deploy a Plutus/Aiken smart contract to Vector by sending funds to its script address", + "vector_build_deploy_contract", + "Build an UNSIGNED transaction deploying a Plutus/Aiken smart contract (locks funds at its script address). Takes no key material - sign with your local signer, then broadcast with vector_submit_transaction.", { + changeAddress: z.string().describe("Your wallet address (source of funds; receives change). Get it from your local signer's vector_signer_get_address."), scriptCbor: z.string().describe("Compiled Plutus/Aiken script in CBOR hex format"), scriptType: z.enum(["PlutusV1", "PlutusV2", "PlutusV3"]).describe("Script version"), - mnemonic: z.string().describe("15 or 24-word BIP39 mnemonic for the deploying wallet"), initialDatum: z.string().optional().describe("Initial datum as CBOR hex. Use 'd87980' for void/unit datum. Defaults to void if omitted."), lovelaceAmount: z.number().optional().describe("AP3X to lock at the script address in lovelace (default: 2,000,000 = 2 AP3X)"), }, - async ({ scriptCbor, scriptType, mnemonic, initialDatum, lovelaceAmount }) => { + async ({ changeAddress, scriptCbor, scriptType, initialDatum, lovelaceAmount }) => { const rateCheck = rateLimiter.check(); if (!rateCheck.allowed) { return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] }; } try { - const result = await deployContract( - scriptCbor, - scriptType, - mnemonic, - initialDatum || null, - lovelaceAmount || 2_000_000, - ); - + const lucid = await lucidForAddress(makeProvider(), changeAddress); + const result = await buildDeployContract(lucid, { scriptCbor, scriptType, initialDatum, lovelaceAmount }); return { content: [{ type: "text", - text: `# Smart Contract Deployed + text: `# Unsigned Contract Deployment Built -Transaction Hash: ${result.txHash} -Script Address: ${result.scriptAddress} -Script Hash: ${result.scriptHash} -Script Type: ${result.scriptType} +**Script Address:** ${result.scriptAddress} +**Script Hash:** ${result.scriptHash} +**Script Type:** ${result.scriptType} +**Estimated Fee:** ${result.feeAda} AP3X +**Tx Hash:** ${result.txHash} -Funds locked at the script address. Use vector_interact_contract to interact with this contract. +**Unsigned TX CBOR:** +\`\`\` +${result.txCbor} +\`\`\` -[View on Explorer](${result.links.explorer})`, +Sign locally with vector_signer_sign, broadcast with vector_submit_transaction, then use vector_build_interact_contract to interact with the deployed contract.`, }], }; } catch (err) { @@ -1296,28 +880,27 @@ Funds locked at the script address. Use vector_interact_contract to interact wit return { content: [{ type: "text", - text: `Failed to deploy contract: ${error.message} + text: `Failed to build contract deployment: ${error.message} **Troubleshooting Tips:** 1. Verify the script CBOR is valid hex (compiled Aiken or Plutus output) -2. Ensure wallet has sufficient AP3X for the locked amount + fees -3. Check that the script type matches the compiled version (PlutusV1/V2/V3) -4. Check spend limits with vector_get_spend_limits`, +2. Ensure the wallet at changeAddress has sufficient AP3X for the locked amount + fees +3. Check that the script type matches the compiled version (PlutusV1/V2/V3)`, }], }; } } ); - // vector_interact_contract - Interact with a deployed smart contract + // vector_build_interact_contract - build an UNSIGNED contract interaction (keyless) server.tool( - "vector_interact_contract", - "Interact with a deployed Plutus/Aiken smart contract - lock AP3X or spend from it", + "vector_build_interact_contract", + "Build an UNSIGNED transaction interacting with a deployed Plutus/Aiken contract - lock AP3X at it or spend from it. Takes no key material - sign with your local signer, then broadcast with vector_submit_transaction.", { + changeAddress: z.string().describe("Your wallet address (source of funds, change, and the required signer for spends). Get it from your local signer's vector_signer_get_address."), scriptCbor: z.string().describe("Compiled Plutus/Aiken script in CBOR hex"), scriptType: z.enum(["PlutusV1", "PlutusV2", "PlutusV3"]).describe("Script version"), action: z.enum(["spend", "lock"]).describe("'spend' to collect UTxOs from the script, 'lock' to send funds to it"), - mnemonic: z.string().describe("15 or 24-word BIP39 mnemonic for the wallet"), redeemer: z.string().optional().describe("Redeemer as CBOR hex (required for spend, use 'd87980' for void)"), datum: z.string().optional().describe("Datum as CBOR hex (required for lock, use 'd87980' for void)"), lovelaceAmount: z.number().optional().describe("Lovelace to lock (for lock action, default: 2,000,000 = 2 AP3X)"), @@ -1327,38 +910,33 @@ Funds locked at the script address. Use vector_interact_contract to interact wit }).optional().describe("Specific UTxO to spend from (optional, otherwise spends all UTxOs at script address)"), assets: z.record(z.string(), z.string()).optional().describe("Additional native assets for lock action: { 'policyId+assetNameHex': 'quantity' }"), }, - async ({ scriptCbor, scriptType, action, mnemonic, redeemer, datum, lovelaceAmount, utxoRef, assets }) => { + async ({ changeAddress, scriptCbor, scriptType, action, redeemer, datum, lovelaceAmount, utxoRef, assets }) => { const rateCheck = rateLimiter.check(); if (!rateCheck.allowed) { return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] }; } try { - const result = await interactWithContract( - scriptCbor, - scriptType, - action, - mnemonic, - redeemer || null, - datum || null, - lovelaceAmount || 2_000_000, - utxoRef || null, - assets || null, - ); - - const actionVerb = result.action === 'spend' ? 'collected from' : 'locked at'; - + const lucid = await lucidForAddress(makeProvider(), changeAddress); + const result = await buildInteractContract(lucid, { + scriptCbor, scriptType, action, changeAddress, redeemer, datum, lovelaceAmount, utxoRef, assets, + }); + const actionVerb = result.action === 'spend' ? 'collects from' : 'locks funds at'; return { content: [{ type: "text", - text: `# Contract Interaction Successful + text: `# Unsigned Contract Interaction Built -Transaction Hash: ${result.txHash} -Action: ${result.action} -Script Address: ${result.scriptAddress} +**Action:** ${result.action} (${actionVerb} the script address) +**Script Address:** ${result.scriptAddress} +**Estimated Fee:** ${result.feeAda} AP3X +**Tx Hash:** ${result.txHash} -Funds ${actionVerb} the script address. +**Unsigned TX CBOR:** +\`\`\` +${result.txCbor} +\`\`\` -[View on Explorer](${result.links.explorer})`, +Sign locally with vector_signer_sign { txCbor }, then broadcast with vector_submit_transaction { signedTxCbor }.`, }], }; } catch (err) { @@ -1366,14 +944,13 @@ Funds ${actionVerb} the script address. return { content: [{ type: "text", - text: `Failed to interact with contract: ${error.message} + text: `Failed to build contract interaction: ${error.message} **Troubleshooting Tips:** 1. For 'spend': ensure the script address has UTxOs and the redeemer satisfies the validator -2. For 'lock': ensure wallet has sufficient AP3X and the datum matches the script's expectations -3. Spending requires collateral - ensure wallet has a pure-AP3X UTxO (no native tokens) -4. Verify the script CBOR matches the deployed script exactly -5. Check spend limits with vector_get_spend_limits`, +2. For 'lock': ensure the wallet at changeAddress has sufficient AP3X and the datum matches the script's expectations +3. Spending requires collateral - ensure the wallet has a pure-AP3X UTxO (no native tokens) +4. Verify the script CBOR matches the deployed script exactly`, }], }; } diff --git a/packages/builder/test/smoke/tool-inventory.test.ts b/packages/builder/test/smoke/tool-inventory.test.ts index ffcf808..4dfe1e9 100644 --- a/packages/builder/test/smoke/tool-inventory.test.ts +++ b/packages/builder/test/smoke/tool-inventory.test.ts @@ -15,8 +15,11 @@ import { startServer, stopServer, ServerContext } from '../setup.ts'; */ const EXPECTED_TOOLS = [ 'vector_await_transaction', + 'vector_build_deploy_contract', + 'vector_build_interact_contract', + 'vector_build_send_apex', + 'vector_build_send_tokens', 'vector_build_transaction', - 'vector_deploy_contract', 'vector_deregister_agent', 'vector_discover_agents', 'vector_dry_run', @@ -26,7 +29,6 @@ const EXPECTED_TOOLS = [ 'vector_get_spend_limits', 'vector_get_transaction_history', 'vector_get_utxos', - 'vector_interact_contract', 'vector_message_agent', 'vector_register_agent', 'vector_self_improvement_analyze_metrics', @@ -34,8 +36,6 @@ const EXPECTED_TOOLS = [ 'vector_self_improvement_critique', 'vector_self_improvement_endorse', 'vector_self_improvement_submit_proposal', - 'vector_send_apex', - 'vector_send_tokens', 'vector_submit_transaction', 'vector_transfer_agent', 'vector_update_agent', diff --git a/packages/builder/test/smoke/tool-schemas.snapshot.json b/packages/builder/test/smoke/tool-schemas.snapshot.json index e6de772..1dda661 100644 --- a/packages/builder/test/smoke/tool-schemas.snapshot.json +++ b/packages/builder/test/smoke/tool-schemas.snapshot.json @@ -18,9 +18,189 @@ ], "$schema": "http://json-schema.org/draft-07/schema#" }, + "vector_build_deploy_contract": { + "type": "object", + "properties": { + "changeAddress": { + "description": "Your wallet address (source of funds; receives change). Get it from your local signer's vector_signer_get_address.", + "type": "string" + }, + "scriptCbor": { + "description": "Compiled Plutus/Aiken script in CBOR hex format", + "type": "string" + }, + "scriptType": { + "description": "Script version", + "type": "string", + "enum": [ + "PlutusV1", + "PlutusV2", + "PlutusV3" + ] + }, + "initialDatum": { + "description": "Initial datum as CBOR hex. Use 'd87980' for void/unit datum. Defaults to void if omitted.", + "type": "string" + }, + "lovelaceAmount": { + "description": "AP3X to lock at the script address in lovelace (default: 2,000,000 = 2 AP3X)", + "type": "number" + } + }, + "required": [ + "changeAddress", + "scriptCbor", + "scriptType" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "vector_build_interact_contract": { + "type": "object", + "properties": { + "changeAddress": { + "description": "Your wallet address (source of funds, change, and the required signer for spends). Get it from your local signer's vector_signer_get_address.", + "type": "string" + }, + "scriptCbor": { + "description": "Compiled Plutus/Aiken script in CBOR hex", + "type": "string" + }, + "scriptType": { + "description": "Script version", + "type": "string", + "enum": [ + "PlutusV1", + "PlutusV2", + "PlutusV3" + ] + }, + "action": { + "description": "'spend' to collect UTxOs from the script, 'lock' to send funds to it", + "type": "string", + "enum": [ + "spend", + "lock" + ] + }, + "redeemer": { + "description": "Redeemer as CBOR hex (required for spend, use 'd87980' for void)", + "type": "string" + }, + "datum": { + "description": "Datum as CBOR hex (required for lock, use 'd87980' for void)", + "type": "string" + }, + "lovelaceAmount": { + "description": "Lovelace to lock (for lock action, default: 2,000,000 = 2 AP3X)", + "type": "number" + }, + "utxoRef": { + "description": "Specific UTxO to spend from (optional, otherwise spends all UTxOs at script address)", + "type": "object", + "properties": { + "txHash": { + "type": "string" + }, + "outputIndex": { + "type": "number" + } + }, + "required": [ + "txHash", + "outputIndex" + ] + }, + "assets": { + "description": "Additional native assets for lock action: { 'policyId+assetNameHex': 'quantity' }", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "changeAddress", + "scriptCbor", + "scriptType", + "action" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "vector_build_send_apex": { + "type": "object", + "properties": { + "changeAddress": { + "description": "Your wallet address (source of funds; receives change). Get it from your local signer's vector_signer_get_address.", + "type": "string" + }, + "recipientAddress": { + "description": "Recipient Vector address (addr1...)", + "type": "string" + }, + "amount": { + "description": "Amount of AP3X to send", + "type": "number", + "minimum": 1 + }, + "metadata": { + "description": "Optional transaction metadata in JSON format", + "type": "string" + } + }, + "required": [ + "changeAddress", + "recipientAddress", + "amount" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "vector_build_send_tokens": { + "type": "object", + "properties": { + "changeAddress": { + "description": "Your wallet address (source of funds; receives change). Get it from your local signer's vector_signer_get_address.", + "type": "string" + }, + "recipientAddress": { + "description": "Recipient Vector address (addr1...)", + "type": "string" + }, + "policyId": { + "description": "Token policy ID (56 hex characters)", + "type": "string" + }, + "assetName": { + "description": "Asset name (text or hex; can be empty for policy-only tokens)", + "type": "string" + }, + "amount": { + "description": "Amount of tokens to send", + "type": "string" + }, + "adaAmount": { + "description": "Optional AP3X to include with the tokens (defaults to the 2 AP3X minimum)", + "type": "number" + } + }, + "required": [ + "changeAddress", + "recipientAddress", + "policyId", + "assetName", + "amount" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, "vector_build_transaction": { "type": "object", "properties": { + "changeAddress": { + "description": "Your wallet address (source of funds; receives change). Get it from your local signer's vector_signer_get_address.", + "type": "string" + }, "outputs": { "description": "Transaction outputs", "minItems": 1, @@ -53,58 +233,14 @@ ] } }, - "mnemonic": { - "description": "15 or 24-word BIP39 mnemonic for the signing wallet", - "type": "string" - }, "metadata": { "description": "Optional JSON metadata (attached under label 674)", "type": "string" - }, - "submit": { - "description": "If true, sign and submit the transaction. If false/omitted, return unsigned CBOR for review.", - "type": "boolean" } }, "required": [ - "outputs", - "mnemonic" - ], - "$schema": "http://json-schema.org/draft-07/schema#" - }, - "vector_deploy_contract": { - "type": "object", - "properties": { - "scriptCbor": { - "description": "Compiled Plutus/Aiken script in CBOR hex format", - "type": "string" - }, - "scriptType": { - "description": "Script version", - "type": "string", - "enum": [ - "PlutusV1", - "PlutusV2", - "PlutusV3" - ] - }, - "mnemonic": { - "description": "15 or 24-word BIP39 mnemonic for the deploying wallet", - "type": "string" - }, - "initialDatum": { - "description": "Initial datum as CBOR hex. Use 'd87980' for void/unit datum. Defaults to void if omitted.", - "type": "string" - }, - "lovelaceAmount": { - "description": "AP3X to lock at the script address in lovelace (default: 2,000,000 = 2 AP3X)", - "type": "number" - } - }, - "required": [ - "scriptCbor", - "scriptType", - "mnemonic" + "changeAddress", + "outputs" ], "$schema": "http://json-schema.org/draft-07/schema#" }, @@ -276,81 +412,6 @@ }, "$schema": "http://json-schema.org/draft-07/schema#" }, - "vector_interact_contract": { - "type": "object", - "properties": { - "scriptCbor": { - "description": "Compiled Plutus/Aiken script in CBOR hex", - "type": "string" - }, - "scriptType": { - "description": "Script version", - "type": "string", - "enum": [ - "PlutusV1", - "PlutusV2", - "PlutusV3" - ] - }, - "action": { - "description": "'spend' to collect UTxOs from the script, 'lock' to send funds to it", - "type": "string", - "enum": [ - "spend", - "lock" - ] - }, - "mnemonic": { - "description": "15 or 24-word BIP39 mnemonic for the wallet", - "type": "string" - }, - "redeemer": { - "description": "Redeemer as CBOR hex (required for spend, use 'd87980' for void)", - "type": "string" - }, - "datum": { - "description": "Datum as CBOR hex (required for lock, use 'd87980' for void)", - "type": "string" - }, - "lovelaceAmount": { - "description": "Lovelace to lock (for lock action, default: 2,000,000 = 2 AP3X)", - "type": "number" - }, - "utxoRef": { - "description": "Specific UTxO to spend from (optional, otherwise spends all UTxOs at script address)", - "type": "object", - "properties": { - "txHash": { - "type": "string" - }, - "outputIndex": { - "type": "number" - } - }, - "required": [ - "txHash", - "outputIndex" - ] - }, - "assets": { - "description": "Additional native assets for lock action: { 'policyId+assetNameHex': 'quantity' }", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - } - } - }, - "required": [ - "scriptCbor", - "scriptType", - "action", - "mnemonic" - ], - "$schema": "http://json-schema.org/draft-07/schema#" - }, "vector_message_agent": { "type": "object", "properties": { @@ -655,81 +716,6 @@ ], "$schema": "http://json-schema.org/draft-07/schema#" }, - "vector_send_apex": { - "type": "object", - "properties": { - "recipientAddress": { - "description": "Recipient Vector address (addr1...)", - "type": "string" - }, - "amount": { - "description": "Amount of AP3X to send", - "type": "number", - "minimum": 1 - }, - "mnemonic": { - "description": "15 or 24-word BIP39 mnemonic for the sending wallet", - "type": "string" - }, - "metadata": { - "description": "Optional transaction metadata in JSON format", - "type": "string" - }, - "unsigned_only": { - "description": "If true, return unsigned TX CBOR without signing or submitting", - "default": false, - "type": "boolean" - } - }, - "required": [ - "recipientAddress", - "amount", - "mnemonic" - ], - "$schema": "http://json-schema.org/draft-07/schema#" - }, - "vector_send_tokens": { - "type": "object", - "properties": { - "recipientAddress": { - "description": "Recipient Vector address (addr1...)", - "type": "string" - }, - "policyId": { - "description": "Token policy ID", - "type": "string" - }, - "assetName": { - "description": "Asset name (can be empty for policy-only tokens)", - "type": "string" - }, - "amount": { - "description": "Amount of tokens to send", - "type": "string" - }, - "mnemonic": { - "description": "15 or 24-word BIP39 mnemonic for the sending wallet", - "type": "string" - }, - "adaAmount": { - "description": "Optional AP3X to include (uses minimum required if not specified)", - "type": "number" - }, - "unsigned_only": { - "description": "If true, return unsigned TX CBOR without signing or submitting", - "default": false, - "type": "boolean" - } - }, - "required": [ - "recipientAddress", - "policyId", - "assetName", - "amount", - "mnemonic" - ], - "$schema": "http://json-schema.org/draft-07/schema#" - }, "vector_submit_transaction": { "type": "object", "properties": { From 6bbeb6ca7cd7f16d9627b376f2760b79c3e491c2 Mon Sep 17 00:00:00 2001 From: FilipBl4gojevic <84703395+FilipBl4gojevic@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:54:40 +0200 Subject: [PATCH 07/14] 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 --- .../builder/scripts/regen-tool-schemas.mjs | 64 + packages/builder/src/vector/index.ts | 7 - packages/builder/src/vector/vector.ts | 298 +--- packages/builder/test/boundary.test.ts | 54 + .../integration/rate-limit-gating.test.ts | 18 +- .../builder/test/smoke/tool-inventory.test.ts | 84 +- .../test/smoke/tool-schemas.snapshot.json | 1553 +++++++++-------- packages/shared/src/types.ts | 74 - 8 files changed, 1009 insertions(+), 1143 deletions(-) create mode 100644 packages/builder/scripts/regen-tool-schemas.mjs create mode 100644 packages/builder/test/boundary.test.ts diff --git a/packages/builder/scripts/regen-tool-schemas.mjs b/packages/builder/scripts/regen-tool-schemas.mjs new file mode 100644 index 0000000..09582cb --- /dev/null +++ b/packages/builder/scripts/regen-tool-schemas.mjs @@ -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(); +} diff --git a/packages/builder/src/vector/index.ts b/packages/builder/src/vector/index.ts index 6e4f96f..a7238c0 100644 --- a/packages/builder/src/vector/index.ts +++ b/packages/builder/src/vector/index.ts @@ -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'; diff --git a/packages/builder/src/vector/vector.ts b/packages/builder/src/vector/vector.ts index 64f54da..86a8adf 100644 --- a/packages/builder/src/vector/vector.ts +++ b/packages/builder/src/vector/vector.ts @@ -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 { - 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 = {}; - 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 = { 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) { diff --git a/packages/builder/test/boundary.test.ts b/packages/builder/test/boundary.test.ts new file mode 100644 index 0000000..4cd1a4a --- /dev/null +++ b/packages/builder/test/boundary.test.ts @@ -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`, + ); + } + }); +}); diff --git a/packages/builder/test/integration/rate-limit-gating.test.ts b/packages/builder/test/integration/rate-limit-gating.test.ts index fec0196..c0d71a7 100644 --- a/packages/builder/test/integration/rate-limit-gating.test.ts +++ b/packages/builder/test/integration/rate-limit-gating.test.ts @@ -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"); }); diff --git a/packages/builder/test/smoke/tool-inventory.test.ts b/packages/builder/test/smoke/tool-inventory.test.ts index 4dfe1e9..ac4ebf7 100644 --- a/packages/builder/test/smoke/tool-inventory.test.ts +++ b/packages/builder/test/smoke/tool-inventory.test.ts @@ -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 = JSON.parse(readFileSync(SNAPSHOT_PATH, 'utf-8')); +const schemaSnapshot: Record = + JSON.parse(readFileSync(SNAPSHOT_PATH, 'utf-8')); let ctx: ServerContext; let actualNames: string[]; -let actualToolsByName: Map; +let actualToolsByName: Map; 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.' ); }); } diff --git a/packages/builder/test/smoke/tool-schemas.snapshot.json b/packages/builder/test/smoke/tool-schemas.snapshot.json index 1dda661..d73f855 100644 --- a/packages/builder/test/smoke/tool-schemas.snapshot.json +++ b/packages/builder/test/smoke/tool-schemas.snapshot.json @@ -1,799 +1,848 @@ { "vector_await_transaction": { - "type": "object", - "properties": { - "txHash": { - "description": "Transaction hash returned by vector_submit_transaction", - "type": "string" + "description": "Wait for a submitted transaction to be confirmed on Vector. Polls until it appears on-chain or the timeout elapses.", + "inputSchema": { + "type": "object", + "properties": { + "txHash": { + "description": "Transaction hash returned by vector_submit_transaction", + "type": "string" + }, + "timeoutSeconds": { + "description": "How long to wait before giving up (default: 120)", + "type": "number", + "minimum": 1, + "maximum": 300 + } }, - "timeoutSeconds": { - "description": "How long to wait before giving up (default: 120)", - "type": "number", - "minimum": 1, - "maximum": 300 - } - }, - "required": [ - "txHash" - ], - "$schema": "http://json-schema.org/draft-07/schema#" + "required": [ + "txHash" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + } }, "vector_build_deploy_contract": { - "type": "object", - "properties": { - "changeAddress": { - "description": "Your wallet address (source of funds; receives change). Get it from your local signer's vector_signer_get_address.", - "type": "string" + "description": "Build an UNSIGNED transaction deploying a Plutus/Aiken smart contract (locks funds at its script address). Takes no key material - sign with your local signer, then broadcast with vector_submit_transaction.", + "inputSchema": { + "type": "object", + "properties": { + "changeAddress": { + "description": "Your wallet address (source of funds; receives change). Get it from your local signer's vector_signer_get_address.", + "type": "string" + }, + "scriptCbor": { + "description": "Compiled Plutus/Aiken script in CBOR hex format", + "type": "string" + }, + "scriptType": { + "description": "Script version", + "type": "string", + "enum": [ + "PlutusV1", + "PlutusV2", + "PlutusV3" + ] + }, + "initialDatum": { + "description": "Initial datum as CBOR hex. Use 'd87980' for void/unit datum. Defaults to void if omitted.", + "type": "string" + }, + "lovelaceAmount": { + "description": "AP3X to lock at the script address in lovelace (default: 2,000,000 = 2 AP3X)", + "type": "number" + } }, - "scriptCbor": { - "description": "Compiled Plutus/Aiken script in CBOR hex format", - "type": "string" - }, - "scriptType": { - "description": "Script version", - "type": "string", - "enum": [ - "PlutusV1", - "PlutusV2", - "PlutusV3" - ] - }, - "initialDatum": { - "description": "Initial datum as CBOR hex. Use 'd87980' for void/unit datum. Defaults to void if omitted.", - "type": "string" - }, - "lovelaceAmount": { - "description": "AP3X to lock at the script address in lovelace (default: 2,000,000 = 2 AP3X)", - "type": "number" - } - }, - "required": [ - "changeAddress", - "scriptCbor", - "scriptType" - ], - "$schema": "http://json-schema.org/draft-07/schema#" + "required": [ + "changeAddress", + "scriptCbor", + "scriptType" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + } }, "vector_build_interact_contract": { - "type": "object", - "properties": { - "changeAddress": { - "description": "Your wallet address (source of funds, change, and the required signer for spends). Get it from your local signer's vector_signer_get_address.", - "type": "string" - }, - "scriptCbor": { - "description": "Compiled Plutus/Aiken script in CBOR hex", - "type": "string" - }, - "scriptType": { - "description": "Script version", - "type": "string", - "enum": [ - "PlutusV1", - "PlutusV2", - "PlutusV3" - ] - }, - "action": { - "description": "'spend' to collect UTxOs from the script, 'lock' to send funds to it", - "type": "string", - "enum": [ - "spend", - "lock" - ] - }, - "redeemer": { - "description": "Redeemer as CBOR hex (required for spend, use 'd87980' for void)", - "type": "string" - }, - "datum": { - "description": "Datum as CBOR hex (required for lock, use 'd87980' for void)", - "type": "string" - }, - "lovelaceAmount": { - "description": "Lovelace to lock (for lock action, default: 2,000,000 = 2 AP3X)", - "type": "number" - }, - "utxoRef": { - "description": "Specific UTxO to spend from (optional, otherwise spends all UTxOs at script address)", - "type": "object", - "properties": { - "txHash": { + "description": "Build an UNSIGNED transaction interacting with a deployed Plutus/Aiken contract - lock AP3X at it or spend from it. Takes no key material - sign with your local signer, then broadcast with vector_submit_transaction.", + "inputSchema": { + "type": "object", + "properties": { + "changeAddress": { + "description": "Your wallet address (source of funds, change, and the required signer for spends). Get it from your local signer's vector_signer_get_address.", + "type": "string" + }, + "scriptCbor": { + "description": "Compiled Plutus/Aiken script in CBOR hex", + "type": "string" + }, + "scriptType": { + "description": "Script version", + "type": "string", + "enum": [ + "PlutusV1", + "PlutusV2", + "PlutusV3" + ] + }, + "action": { + "description": "'spend' to collect UTxOs from the script, 'lock' to send funds to it", + "type": "string", + "enum": [ + "spend", + "lock" + ] + }, + "redeemer": { + "description": "Redeemer as CBOR hex (required for spend, use 'd87980' for void)", + "type": "string" + }, + "datum": { + "description": "Datum as CBOR hex (required for lock, use 'd87980' for void)", + "type": "string" + }, + "lovelaceAmount": { + "description": "Lovelace to lock (for lock action, default: 2,000,000 = 2 AP3X)", + "type": "number" + }, + "utxoRef": { + "description": "Specific UTxO to spend from (optional, otherwise spends all UTxOs at script address)", + "type": "object", + "properties": { + "txHash": { + "type": "string" + }, + "outputIndex": { + "type": "number" + } + }, + "required": [ + "txHash", + "outputIndex" + ] + }, + "assets": { + "description": "Additional native assets for lock action: { 'policyId+assetNameHex': 'quantity' }", + "type": "object", + "propertyNames": { "type": "string" }, - "outputIndex": { - "type": "number" + "additionalProperties": { + "type": "string" } - }, - "required": [ - "txHash", - "outputIndex" - ] - }, - "assets": { - "description": "Additional native assets for lock action: { 'policyId+assetNameHex': 'quantity' }", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" } - } - }, - "required": [ - "changeAddress", - "scriptCbor", - "scriptType", - "action" - ], - "$schema": "http://json-schema.org/draft-07/schema#" + }, + "required": [ + "changeAddress", + "scriptCbor", + "scriptType", + "action" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + } }, "vector_build_send_apex": { - "type": "object", - "properties": { - "changeAddress": { - "description": "Your wallet address (source of funds; receives change). Get it from your local signer's vector_signer_get_address.", - "type": "string" + "description": "Build an UNSIGNED transaction sending AP3X to a recipient. Takes no key material - sign the returned CBOR with your local signer (vector_signer_sign), then broadcast with vector_submit_transaction.", + "inputSchema": { + "type": "object", + "properties": { + "changeAddress": { + "description": "Your wallet address (source of funds; receives change). Get it from your local signer's vector_signer_get_address.", + "type": "string" + }, + "recipientAddress": { + "description": "Recipient Vector address (addr1...)", + "type": "string" + }, + "amount": { + "description": "Amount of AP3X to send", + "type": "number", + "minimum": 1 + }, + "metadata": { + "description": "Optional transaction metadata in JSON format", + "type": "string" + } }, - "recipientAddress": { - "description": "Recipient Vector address (addr1...)", - "type": "string" - }, - "amount": { - "description": "Amount of AP3X to send", - "type": "number", - "minimum": 1 - }, - "metadata": { - "description": "Optional transaction metadata in JSON format", - "type": "string" - } - }, - "required": [ - "changeAddress", - "recipientAddress", - "amount" - ], - "$schema": "http://json-schema.org/draft-07/schema#" + "required": [ + "changeAddress", + "recipientAddress", + "amount" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + } }, "vector_build_send_tokens": { - "type": "object", - "properties": { - "changeAddress": { - "description": "Your wallet address (source of funds; receives change). Get it from your local signer's vector_signer_get_address.", - "type": "string" + "description": "Build an UNSIGNED transaction sending Vector native tokens. Takes no key material - sign the returned CBOR with your local signer (vector_signer_sign), then broadcast with vector_submit_transaction.", + "inputSchema": { + "type": "object", + "properties": { + "changeAddress": { + "description": "Your wallet address (source of funds; receives change). Get it from your local signer's vector_signer_get_address.", + "type": "string" + }, + "recipientAddress": { + "description": "Recipient Vector address (addr1...)", + "type": "string" + }, + "policyId": { + "description": "Token policy ID (56 hex characters)", + "type": "string" + }, + "assetName": { + "description": "Asset name (text or hex; can be empty for policy-only tokens)", + "type": "string" + }, + "amount": { + "description": "Amount of tokens to send", + "type": "string" + }, + "adaAmount": { + "description": "Optional AP3X to include with the tokens (defaults to the 2 AP3X minimum)", + "type": "number" + } }, - "recipientAddress": { - "description": "Recipient Vector address (addr1...)", - "type": "string" - }, - "policyId": { - "description": "Token policy ID (56 hex characters)", - "type": "string" - }, - "assetName": { - "description": "Asset name (text or hex; can be empty for policy-only tokens)", - "type": "string" - }, - "amount": { - "description": "Amount of tokens to send", - "type": "string" - }, - "adaAmount": { - "description": "Optional AP3X to include with the tokens (defaults to the 2 AP3X minimum)", - "type": "number" - } - }, - "required": [ - "changeAddress", - "recipientAddress", - "policyId", - "assetName", - "amount" - ], - "$schema": "http://json-schema.org/draft-07/schema#" + "required": [ + "changeAddress", + "recipientAddress", + "policyId", + "assetName", + "amount" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + } }, "vector_build_transaction": { - "type": "object", - "properties": { - "changeAddress": { - "description": "Your wallet address (source of funds; receives change). Get it from your local signer's vector_signer_get_address.", - "type": "string" - }, - "outputs": { - "description": "Transaction outputs", - "minItems": 1, - "type": "array", - "items": { - "type": "object", - "properties": { - "address": { - "description": "Recipient Vector address", - "type": "string" - }, - "lovelace": { - "description": "Amount in lovelace (1 AP3X = 1,000,000 lovelace)", - "type": "number" - }, - "assets": { - "description": "Optional native assets: { 'policyId+assetNameHex': 'quantity' }", - "type": "object", - "propertyNames": { + "description": "Build an UNSIGNED multi-output transaction with optional metadata. Takes no key material and never submits - sign the returned CBOR with your local signer (vector_signer_sign), then broadcast with vector_submit_transaction.", + "inputSchema": { + "type": "object", + "properties": { + "changeAddress": { + "description": "Your wallet address (source of funds; receives change). Get it from your local signer's vector_signer_get_address.", + "type": "string" + }, + "outputs": { + "description": "Transaction outputs", + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "address": { + "description": "Recipient Vector address", "type": "string" }, - "additionalProperties": { - "type": "string" + "lovelace": { + "description": "Amount in lovelace (1 AP3X = 1,000,000 lovelace)", + "type": "number" + }, + "assets": { + "description": "Optional native assets: { 'policyId+assetNameHex': 'quantity' }", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } } - } - }, - "required": [ - "address", - "lovelace" - ] + }, + "required": [ + "address", + "lovelace" + ] + } + }, + "metadata": { + "description": "Optional JSON metadata (attached under label 674)", + "type": "string" } }, - "metadata": { - "description": "Optional JSON metadata (attached under label 674)", - "type": "string" - } - }, - "required": [ - "changeAddress", - "outputs" - ], - "$schema": "http://json-schema.org/draft-07/schema#" + "required": [ + "changeAddress", + "outputs" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + } }, "vector_deregister_agent": { - "type": "object", - "properties": { - "mnemonic": { - "description": "15 or 24-word BIP39 mnemonic for the agent's owner wallet", - "type": "string" + "description": "Deregister an agent from the Vector registry. Burns the identity NFT and returns the 10 AP3X deposit to your wallet.", + "inputSchema": { + "type": "object", + "properties": { + "mnemonic": { + "description": "15 or 24-word BIP39 mnemonic for the agent's owner wallet", + "type": "string" + }, + "agent_id": { + "description": "Agent DID to deregister: did:vector:agent:{policyId}:{nftAssetName}", + "type": "string" + } }, - "agent_id": { - "description": "Agent DID to deregister: did:vector:agent:{policyId}:{nftAssetName}", - "type": "string" - } - }, - "required": [ - "mnemonic", - "agent_id" - ], - "$schema": "http://json-schema.org/draft-07/schema#" + "required": [ + "mnemonic", + "agent_id" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + } }, "vector_discover_agents": { - "type": "object", - "properties": { - "capability": { - "description": "Filter by capability tag (e.g. 'investing', 'research')", - "type": "string" + "description": "Discover registered agents in the Vector on-chain registry, optionally filtered by capability or framework", + "inputSchema": { + "type": "object", + "properties": { + "capability": { + "description": "Filter by capability tag (e.g. 'investing', 'research')", + "type": "string" + }, + "framework": { + "description": "Filter by framework (e.g. 'OpenClaw', 'LangChain', 'CrewAI')", + "type": "string" + }, + "limit": { + "description": "Maximum number of agents to return (default: 20)", + "default": 20, + "type": "number" + } }, - "framework": { - "description": "Filter by framework (e.g. 'OpenClaw', 'LangChain', 'CrewAI')", - "type": "string" - }, - "limit": { - "description": "Maximum number of agents to return (default: 20)", - "default": 20, - "type": "number" - } - }, - "$schema": "http://json-schema.org/draft-07/schema#" + "$schema": "http://json-schema.org/draft-07/schema#" + } }, "vector_dry_run": { - "type": "object", - "properties": { - "txCbor": { - "description": "Hex-encoded CBOR of a built transaction to evaluate", - "type": "string" - }, - "outputs": { - "description": "If no txCbor provided, build a TX from these outputs and evaluate it", - "type": "array", - "items": { - "type": "object", - "properties": { - "address": { - "description": "Recipient Vector address", - "type": "string" - }, - "lovelace": { - "description": "Amount in lovelace", - "type": "number" - }, - "assets": { - "type": "object", - "propertyNames": { + "description": "Simulate a transaction without submitting - returns fee estimate and validation result. Accepts either built CBOR or outputs to build from (keyless).", + "inputSchema": { + "type": "object", + "properties": { + "txCbor": { + "description": "Hex-encoded CBOR of a built transaction to evaluate", + "type": "string" + }, + "outputs": { + "description": "If no txCbor provided, build an unsigned TX from these outputs and evaluate it", + "type": "array", + "items": { + "type": "object", + "properties": { + "address": { + "description": "Recipient Vector address", "type": "string" }, - "additionalProperties": { - "type": "string" + "lovelace": { + "description": "Amount in lovelace", + "type": "number" + }, + "assets": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } } - } - }, - "required": [ - "address", - "lovelace" + }, + "required": [ + "address", + "lovelace" + ] + } + }, + "changeAddress": { + "description": "Your wallet address (required when building from outputs)", + "type": "string" + }, + "metadata": { + "description": "Optional JSON metadata when building from outputs", + "type": "string" + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + "vector_get_agent_profile": { + "description": "Get a registered agent's profile from the on-chain registry by DID (did:vector:agent:...)", + "inputSchema": { + "type": "object", + "properties": { + "agent_id": { + "description": "Agent DID: did:vector:agent:{policyId}:{nftAssetName}", + "type": "string" + } + }, + "required": [ + "agent_id" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + "vector_get_balance": { + "description": "Get AP3X and token balances for a Vector address", + "inputSchema": { + "type": "object", + "properties": { + "address": { + "description": "Vector address to check (addr1...)", + "type": "string" + } + }, + "required": [ + "address" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + "vector_get_transaction_history": { + "description": "Get transaction history for a Vector address via Koios indexed queries", + "inputSchema": { + "type": "object", + "properties": { + "address": { + "description": "Vector address to query (addr1...)", + "type": "string" + }, + "limit": { + "description": "Number of transactions to return (default: 20, max: 50)", + "type": "number", + "minimum": 1, + "maximum": 50 + }, + "offset": { + "description": "Offset for pagination (default: 0)", + "type": "number", + "minimum": 0 + } + }, + "required": [ + "address" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + "vector_get_utxos": { + "description": "List unspent transaction outputs (UTxOs) for a Vector address", + "inputSchema": { + "type": "object", + "properties": { + "address": { + "description": "Vector address to query UTxOs for (addr1...)", + "type": "string" + } + }, + "required": [ + "address" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + "vector_message_agent": { + "description": "Send an on-chain message to a registered Vector agent via TX metadata (label 674). Delivers 2 AP3X to the agent's owner address.", + "inputSchema": { + "type": "object", + "properties": { + "agent_id": { + "description": "Recipient agent DID: did:vector:agent:{policyId}:{nftAssetName}", + "type": "string" + }, + "message_type": { + "description": "Type of message", + "type": "string", + "enum": [ + "inquiry", + "proposal", + "result" + ] + }, + "payload": { + "description": "Message payload (string, max 512 chars)", + "type": "string", + "maxLength": 512 + }, + "mnemonic": { + "description": "15 or 24-word BIP39 mnemonic for the sending wallet", + "type": "string" + } + }, + "required": [ + "agent_id", + "message_type", + "payload", + "mnemonic" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + "vector_register_agent": { + "description": "Register an agent in the Vector on-chain agent registry. Mints a soulbound identity NFT and locks a 10 AP3X deposit.", + "inputSchema": { + "type": "object", + "properties": { + "mnemonic": { + "description": "15 or 24-word BIP39 mnemonic for the registering wallet", + "type": "string" + }, + "name": { + "description": "Agent name (e.g. 'TradingBot', 'ResearchAgent')", + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "description": { + "description": "Short description of the agent's purpose", + "type": "string", + "maxLength": 256 + }, + "capabilities": { + "description": "List of capability tags (e.g. ['investing', 'research', 'environmental'])", + "type": "array", + "items": { + "type": "string" + } + }, + "framework": { + "description": "Framework used (e.g. 'OpenClaw', 'LangChain', 'CrewAI', 'custom')", + "type": "string" + }, + "endpoint": { + "description": "A2A communication endpoint URL (or empty string if not applicable)", + "type": "string" + } + }, + "required": [ + "mnemonic", + "name", + "description", + "capabilities", + "framework", + "endpoint" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + "vector_self_improvement_analyze_metrics": { + "description": "Analyze proposal metrics: proposal activity, adoption rate, treasury health, and engagement statistics. Read-only - no mnemonic needed.", + "inputSchema": { + "type": "object", + "properties": { + "focus": { + "description": "Analysis focus area", + "default": "overview", + "type": "string", + "enum": [ + "overview", + "adoption", + "treasury", + "activity" ] } }, - "mnemonic": { - "description": "15 or 24-word BIP39 mnemonic (required when outputs is provided)", - "type": "string" - }, - "metadata": { - "description": "Optional JSON metadata when building from outputs", - "type": "string" - } - }, - "$schema": "http://json-schema.org/draft-07/schema#" - }, - "vector_get_address": { - "type": "object", - "properties": { - "mnemonic": { - "description": "15 or 24-word BIP39 mnemonic for the wallet", - "type": "string" - } - }, - "required": [ - "mnemonic" - ], - "$schema": "http://json-schema.org/draft-07/schema#" - }, - "vector_get_agent_profile": { - "type": "object", - "properties": { - "agent_id": { - "description": "Agent DID: did:vector:agent:{policyId}:{nftAssetName}", - "type": "string" - } - }, - "required": [ - "agent_id" - ], - "$schema": "http://json-schema.org/draft-07/schema#" - }, - "vector_get_balance": { - "type": "object", - "properties": { - "address": { - "description": "Vector address to check (addr1...)", - "type": "string" - } - }, - "required": [ - "address" - ], - "$schema": "http://json-schema.org/draft-07/schema#" - }, - "vector_get_spend_limits": { - "type": "object", - "properties": {}, - "$schema": "http://json-schema.org/draft-07/schema#" - }, - "vector_get_transaction_history": { - "type": "object", - "properties": { - "address": { - "description": "Vector address to query. If omitted, mnemonic is required.", - "type": "string" - }, - "mnemonic": { - "description": "15 or 24-word BIP39 mnemonic (required if address is omitted)", - "type": "string" - }, - "limit": { - "description": "Number of transactions to return (default: 20, max: 50)", - "type": "number", - "minimum": 1, - "maximum": 50 - }, - "offset": { - "description": "Offset for pagination (default: 0)", - "type": "number", - "minimum": 0 - } - }, - "$schema": "http://json-schema.org/draft-07/schema#" - }, - "vector_get_utxos": { - "type": "object", - "properties": { - "address": { - "description": "Vector address to query UTxOs for. If omitted, mnemonic is required.", - "type": "string" - }, - "mnemonic": { - "description": "15 or 24-word BIP39 mnemonic (required if address is omitted)", - "type": "string" - } - }, - "$schema": "http://json-schema.org/draft-07/schema#" - }, - "vector_message_agent": { - "type": "object", - "properties": { - "agent_id": { - "description": "Recipient agent DID: did:vector:agent:{policyId}:{nftAssetName}", - "type": "string" - }, - "message_type": { - "description": "Type of message", - "type": "string", - "enum": [ - "inquiry", - "proposal", - "result" - ] - }, - "payload": { - "description": "Message payload (string, max 512 chars)", - "type": "string", - "maxLength": 512 - }, - "mnemonic": { - "description": "15 or 24-word BIP39 mnemonic for the sending wallet", - "type": "string" - } - }, - "required": [ - "agent_id", - "message_type", - "payload", - "mnemonic" - ], - "$schema": "http://json-schema.org/draft-07/schema#" - }, - "vector_register_agent": { - "type": "object", - "properties": { - "mnemonic": { - "description": "15 or 24-word BIP39 mnemonic for the registering wallet", - "type": "string" - }, - "name": { - "description": "Agent name (e.g. 'TradingBot', 'ResearchAgent')", - "type": "string", - "minLength": 1, - "maxLength": 64 - }, - "description": { - "description": "Short description of the agent's purpose", - "type": "string", - "maxLength": 256 - }, - "capabilities": { - "description": "List of capability tags (e.g. ['investing', 'research', 'environmental'])", - "type": "array", - "items": { - "type": "string" - } - }, - "framework": { - "description": "Framework used (e.g. 'OpenClaw', 'LangChain', 'CrewAI', 'custom')", - "type": "string" - }, - "endpoint": { - "description": "A2A communication endpoint URL (or empty string if not applicable)", - "type": "string" - } - }, - "required": [ - "mnemonic", - "name", - "description", - "capabilities", - "framework", - "endpoint" - ], - "$schema": "http://json-schema.org/draft-07/schema#" - }, - "vector_self_improvement_analyze_metrics": { - "type": "object", - "properties": { - "focus": { - "description": "Analysis focus area", - "default": "overview", - "type": "string", - "enum": [ - "overview", - "adoption", - "treasury", - "activity" - ] - } - }, - "$schema": "http://json-schema.org/draft-07/schema#" + "$schema": "http://json-schema.org/draft-07/schema#" + } }, "vector_self_improvement_browse": { - "type": "object", - "properties": { - "entity": { - "description": "What to browse", - "type": "string", - "enum": [ - "proposals", - "critiques", - "endorsements", - "treasury" - ] - }, - "state": { - "description": "Filter proposals by state: Open, Amended, Adopted, Rejected, Expired, Withdrawn", - "type": "string" - }, - "proposalType": { - "description": "Filter proposals by type: ParameterChange, TreasurySpend, ProtocolUpgrade, GameActivation, GeneralSuggestion", - "type": "string" - }, - "proposerDid": { - "description": "Filter by proposer DID (hex)", - "type": "string" - }, - "proposalTxHash": { - "description": "Filter critiques/endorsements by the proposal they reference", - "type": "string" - } - }, - "required": [ - "entity" - ], - "$schema": "http://json-schema.org/draft-07/schema#" - }, - "vector_self_improvement_critique": { - "type": "object", - "properties": { - "mnemonic": { - "description": "15 or 24-word BIP39 mnemonic for the wallet", - "type": "string" - }, - "agentDid": { - "description": "Agent DID (hex)", - "type": "string" - }, - "proposalTxHash": { - "description": "TX hash of the proposal UTxO to critique", - "type": "string" - }, - "proposalOutputIndex": { - "description": "Output index of the proposal UTxO", - "default": 0, - "type": "number" - }, - "critiqueDocument": { - "description": "Full critique document as JSON string. Uploaded to IPFS via Filebase; hash and CID computed automatically.", - "type": "string" - }, - "critiqueHash": { - "description": "blake2b_256 hash of critique document (64 hex chars). Required if critiqueDocument is not provided.", - "type": "string" - }, - "critiqueType": { - "description": "Type of critique", - "type": "string", - "enum": [ - "Supportive", - "Opposing", - "Amendment" - ] - }, - "storageUri": { - "description": "Off-chain storage URI for the critique document. Required if critiqueDocument is not provided.", - "type": "string" - }, - "stakeApex": { - "description": "AP3X to stake (minimum 10)", - "type": "number", - "minimum": 10 - } - }, - "required": [ - "mnemonic", - "agentDid", - "proposalTxHash", - "critiqueType", - "stakeApex" - ], - "$schema": "http://json-schema.org/draft-07/schema#" - }, - "vector_self_improvement_endorse": { - "type": "object", - "properties": { - "mnemonic": { - "description": "15 or 24-word BIP39 mnemonic for the wallet", - "type": "string" - }, - "agentDid": { - "description": "Agent DID (hex)", - "type": "string" - }, - "proposalTxHash": { - "description": "TX hash of the proposal UTxO to endorse", - "type": "string" - }, - "proposalOutputIndex": { - "description": "Output index of the proposal UTxO", - "default": 0, - "type": "number" - }, - "stakeApex": { - "description": "AP3X to stake as endorsement (minimum 5)", - "type": "number", - "minimum": 5 - } - }, - "required": [ - "mnemonic", - "agentDid", - "proposalTxHash", - "stakeApex" - ], - "$schema": "http://json-schema.org/draft-07/schema#" - }, - "vector_self_improvement_submit_proposal": { - "type": "object", - "properties": { - "mnemonic": { - "description": "15 or 24-word BIP39 mnemonic for the wallet", - "type": "string" - }, - "agentDid": { - "description": "Agent DID (hex) - the asset name from Agent Registry NFT", - "type": "string" - }, - "proposalDocument": { - "description": "Full proposal document as JSON string. Uploaded to IPFS via Filebase; hash and CID computed automatically. If provided, proposalHash and storageUri are ignored.", - "type": "string" - }, - "proposalHash": { - "description": "blake2b_256 hash of proposal document (64 hex chars). Required if proposalDocument is not provided.", - "type": "string" - }, - "proposalType": { - "description": "Category of the proposal", - "type": "string", - "enum": [ - "ParameterChange", - "TreasurySpend", - "ProtocolUpgrade", - "GameActivation", - "GeneralSuggestion" - ] - }, - "storageUri": { - "description": "Off-chain storage URI for the full proposal (IPFS CID or OriginTrail UAL). Required if proposalDocument is not provided.", - "type": "string" - }, - "stakeApex": { - "description": "AP3X to stake (minimum 25)", - "type": "number", - "minimum": 25 - }, - "typeParams": { - "description": "Type-specific parameters (required for ParameterChange and TreasurySpend)", - "type": "object", - "properties": { - "paramName": { - "type": "string" - }, - "currentValue": { - "type": "number" - }, - "proposedValue": { - "type": "number" - }, - "amount": { - "type": "number" - }, - "recipientDescription": { - "type": "string" - }, - "upgradeHash": { - "type": "string" - }, - "gameId": { - "type": "number" - } - } - }, - "priority": { - "description": "Priority level (Emergency requires higher stake and reputation)", - "default": "Standard", - "type": "string", - "enum": [ - "Standard", - "Emergency" - ] - } - }, - "required": [ - "mnemonic", - "agentDid", - "proposalType", - "stakeApex" - ], - "$schema": "http://json-schema.org/draft-07/schema#" - }, - "vector_submit_transaction": { - "type": "object", - "properties": { - "signedTxCbor": { - "description": "Hex-encoded CBOR of a fully signed transaction", - "type": "string" - } - }, - "required": [ - "signedTxCbor" - ], - "$schema": "http://json-schema.org/draft-07/schema#" - }, - "vector_transfer_agent": { - "type": "object", - "properties": { - "mnemonic": { - "description": "15 or 24-word BIP39 mnemonic for the current owner's wallet", - "type": "string" - }, - "agent_id": { - "description": "Agent DID to transfer: did:vector:agent:{policyId}:{nftAssetName}", - "type": "string" - }, - "new_owner_address": { - "description": "Bech32 address of the new owner (must be a verification key address, not a script address)", - "type": "string" - } - }, - "required": [ - "mnemonic", - "agent_id", - "new_owner_address" - ], - "$schema": "http://json-schema.org/draft-07/schema#" - }, - "vector_update_agent": { - "type": "object", - "properties": { - "mnemonic": { - "description": "15 or 24-word BIP39 mnemonic for the agent's owner wallet", - "type": "string" - }, - "agent_id": { - "description": "Agent DID to update: did:vector:agent:{policyId}:{nftAssetName}", - "type": "string" - }, - "name": { - "description": "New agent name", - "type": "string", - "minLength": 1, - "maxLength": 64 - }, - "description": { - "description": "New description", - "type": "string", - "maxLength": 256 - }, - "capabilities": { - "description": "New capability tags (replaces existing list)", - "type": "array", - "items": { + "description": "Browse improvement proposals, critiques, and endorsements. Query on-chain UTxOs at the module's script addresses and decode datums into human-readable format.", + "inputSchema": { + "type": "object", + "properties": { + "entity": { + "description": "What to browse", + "type": "string", + "enum": [ + "proposals", + "critiques", + "endorsements", + "treasury" + ] + }, + "state": { + "description": "Filter proposals by state: Open, Amended, Adopted, Rejected, Expired, Withdrawn", + "type": "string" + }, + "proposalType": { + "description": "Filter proposals by type: ParameterChange, TreasurySpend, ProtocolUpgrade, GameActivation, GeneralSuggestion", + "type": "string" + }, + "proposerDid": { + "description": "Filter by proposer DID (hex)", + "type": "string" + }, + "proposalTxHash": { + "description": "Filter critiques/endorsements by the proposal they reference", "type": "string" } }, - "framework": { - "description": "New framework identifier", - "type": "string" + "required": [ + "entity" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + "vector_self_improvement_critique": { + "description": "Submit a critique on an improvement proposal. Critiques can support, oppose, or propose amendments. Requires staking AP3X. Provide critiqueDocument (JSON string) for automatic IPFS upload, or critiqueHash + storageUri manually.", + "inputSchema": { + "type": "object", + "properties": { + "mnemonic": { + "description": "15 or 24-word BIP39 mnemonic for the wallet", + "type": "string" + }, + "agentDid": { + "description": "Agent DID (hex)", + "type": "string" + }, + "proposalTxHash": { + "description": "TX hash of the proposal UTxO to critique", + "type": "string" + }, + "proposalOutputIndex": { + "description": "Output index of the proposal UTxO", + "default": 0, + "type": "number" + }, + "critiqueDocument": { + "description": "Full critique document as JSON string. Uploaded to IPFS via Filebase; hash and CID computed automatically.", + "type": "string" + }, + "critiqueHash": { + "description": "blake2b_256 hash of critique document (64 hex chars). Required if critiqueDocument is not provided.", + "type": "string" + }, + "critiqueType": { + "description": "Type of critique", + "type": "string", + "enum": [ + "Supportive", + "Opposing", + "Amendment" + ] + }, + "storageUri": { + "description": "Off-chain storage URI for the critique document. Required if critiqueDocument is not provided.", + "type": "string" + }, + "stakeApex": { + "description": "AP3X to stake (minimum 10)", + "type": "number", + "minimum": 10 + } }, - "endpoint": { - "description": "New A2A endpoint URL (or empty string to clear)", - "type": "string" - } - }, - "required": [ - "mnemonic", - "agent_id" - ], - "$schema": "http://json-schema.org/draft-07/schema#" + "required": [ + "mnemonic", + "agentDid", + "proposalTxHash", + "critiqueType", + "stakeApex" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + "vector_self_improvement_endorse": { + "description": "Endorse an improvement proposal by staking AP3X. Endorsements signal support to the Foundation Council and are weighted by stake amount.", + "inputSchema": { + "type": "object", + "properties": { + "mnemonic": { + "description": "15 or 24-word BIP39 mnemonic for the wallet", + "type": "string" + }, + "agentDid": { + "description": "Agent DID (hex)", + "type": "string" + }, + "proposalTxHash": { + "description": "TX hash of the proposal UTxO to endorse", + "type": "string" + }, + "proposalOutputIndex": { + "description": "Output index of the proposal UTxO", + "default": 0, + "type": "number" + }, + "stakeApex": { + "description": "AP3X to stake as endorsement (minimum 5)", + "type": "number", + "minimum": 5 + } + }, + "required": [ + "mnemonic", + "agentDid", + "proposalTxHash", + "stakeApex" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + "vector_self_improvement_submit_proposal": { + "description": "Submit an improvement proposal to the Vector Self-Improvement Module. Requires staking AP3X. Provide proposalDocument (JSON string) for automatic IPFS upload via Filebase and blake2b_256 hashing, OR provide proposalHash and storageUri manually.", + "inputSchema": { + "type": "object", + "properties": { + "mnemonic": { + "description": "15 or 24-word BIP39 mnemonic for the wallet", + "type": "string" + }, + "agentDid": { + "description": "Agent DID (hex) - the asset name from Agent Registry NFT", + "type": "string" + }, + "proposalDocument": { + "description": "Full proposal document as JSON string. Uploaded to IPFS via Filebase; hash and CID computed automatically. If provided, proposalHash and storageUri are ignored.", + "type": "string" + }, + "proposalHash": { + "description": "blake2b_256 hash of proposal document (64 hex chars). Required if proposalDocument is not provided.", + "type": "string" + }, + "proposalType": { + "description": "Category of the proposal", + "type": "string", + "enum": [ + "ParameterChange", + "TreasurySpend", + "ProtocolUpgrade", + "GameActivation", + "GeneralSuggestion" + ] + }, + "storageUri": { + "description": "Off-chain storage URI for the full proposal (IPFS CID or OriginTrail UAL). Required if proposalDocument is not provided.", + "type": "string" + }, + "stakeApex": { + "description": "AP3X to stake (minimum 25)", + "type": "number", + "minimum": 25 + }, + "typeParams": { + "description": "Type-specific parameters (required for ParameterChange and TreasurySpend)", + "type": "object", + "properties": { + "paramName": { + "type": "string" + }, + "currentValue": { + "type": "number" + }, + "proposedValue": { + "type": "number" + }, + "amount": { + "type": "number" + }, + "recipientDescription": { + "type": "string" + }, + "upgradeHash": { + "type": "string" + }, + "gameId": { + "type": "number" + } + } + }, + "priority": { + "description": "Priority level (Emergency requires higher stake and reputation)", + "default": "Standard", + "type": "string", + "enum": [ + "Standard", + "Emergency" + ] + } + }, + "required": [ + "mnemonic", + "agentDid", + "proposalType", + "stakeApex" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + "vector_submit_transaction": { + "description": "Submit an already-signed transaction to Vector. Use this to broadcast CBOR signed elsewhere (for example by a local signer). Does not sign anything.", + "inputSchema": { + "type": "object", + "properties": { + "signedTxCbor": { + "description": "Hex-encoded CBOR of a fully signed transaction", + "type": "string" + } + }, + "required": [ + "signedTxCbor" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + "vector_transfer_agent": { + "description": "Transfer agent ownership to a new address. The new owner must have a verification key credential (not a script address).", + "inputSchema": { + "type": "object", + "properties": { + "mnemonic": { + "description": "15 or 24-word BIP39 mnemonic for the current owner's wallet", + "type": "string" + }, + "agent_id": { + "description": "Agent DID to transfer: did:vector:agent:{policyId}:{nftAssetName}", + "type": "string" + }, + "new_owner_address": { + "description": "Bech32 address of the new owner (must be a verification key address, not a script address)", + "type": "string" + } + }, + "required": [ + "mnemonic", + "agent_id", + "new_owner_address" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + "vector_update_agent": { + "description": "Update a registered agent's profile fields (name, description, capabilities, framework, endpoint). Only the specified fields are changed; others are preserved.", + "inputSchema": { + "type": "object", + "properties": { + "mnemonic": { + "description": "15 or 24-word BIP39 mnemonic for the agent's owner wallet", + "type": "string" + }, + "agent_id": { + "description": "Agent DID to update: did:vector:agent:{policyId}:{nftAssetName}", + "type": "string" + }, + "name": { + "description": "New agent name", + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "description": { + "description": "New description", + "type": "string", + "maxLength": 256 + }, + "capabilities": { + "description": "New capability tags (replaces existing list)", + "type": "array", + "items": { + "type": "string" + } + }, + "framework": { + "description": "New framework identifier", + "type": "string" + }, + "endpoint": { + "description": "New A2A endpoint URL (or empty string to clear)", + "type": "string" + } + }, + "required": [ + "mnemonic", + "agent_id" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + } } } diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 35de9dc..08cd075 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -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; // 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. From a9d356fd268656e910b03c61ad56886e638af496 Mon Sep 17 00:00:00 2001 From: FilipBl4gojevic <84703395+FilipBl4gojevic@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:13:16 +0200 Subject: [PATCH 08/14] fix(builder): close boundary-test gaps, drop dangling spend-limits copy, guard tool descriptions end-to-end Addresses opus reviewer findings on the Task 5 commit (33c4b52). Important: - agent-network.ts (3 sites) and self-improvement.ts (2 sites): response copy no longer tells a caller to run vector_get_spend_limits (deleted in Task 5) after a safety-limit error. Replaced with "Server spend limits for this tool are set via VECTOR_SPEND_LIMIT_PER_TX / VECTOR_SPEND_LIMIT_DAILY" (matching each site's existing phrasing). Nothing else in either file touched - both stay custodial until PRs 7/8. Also reworded the two comments (of my own, from Task 5) that named the deleted tool - tool-inventory.test.ts's header docblock and rate-limit-gating.test.ts's probe-choice comment - so a final `grep -rn vector_get_spend_limits packages/` is clean except run.test.ts (Task 6's, left untouched as instructed). - boundary.test.ts: added a third test pinning CUSTODIAL_UNTIL_LATER_PR.size === 2, so PR 7/8 shrinking it is a visible, reviewable number change rather than a silent list edit. - boundary.test.ts: FORBIDDEN is no longer word-bounded (`/(mnemonic|seed|bip39|fromSeed|walletFromSeed|selectWallet\.fromSeed)/i`, \b dropped) so it catches `seedPhrase`, `WALLET_SEED`, `bip39x`-shaped vocabulary, not just whole-word matches. Verified empirically (not just by inspection) that this introduces zero new hits outside the two allowlisted files - grepped `seed`/`bip39`/`mnemonic` case-insensitively across all of packages/builder/src first, confirmed every hit was already confined to agent-network.ts/self-improvement.ts, then reran the full boundary suite to prove it: no benign collisions, nothing to report back before committing. Added a fourth test pinning every FORBIDDEN alternative against a fixture corpus (mnemonic/MNEMONIC/seedPhrase/WALLET_SEED/bip39/ fromSeed/walletFromSeed/selectWallet.fromSeed/seed all match; "address-only wallet"/"unsigned transaction"/"changeAddress" don't) so deleting an alternative from the regex fails a named test instead of silently narrowing the guard. Cheap folds: - vector.ts: the five build_* tool DESCRIPTIONS (not response copy - that already taught the confirm step as of Task 5) now end with "...then broadcast with vector_submit_transaction and confirm with vector_await_transaction." Regenerated the snapshot; verified structurally that the delta is description-field-only on exactly those five entries (inputSchema untouched, all 23 keys unchanged) - this is also what makes the Task 5 addition-A docblock's "descriptions teach the full flow" claim actually true end-to-end. - tool-inventory.test.ts: each per-tool test now also asserts 'description' in schemaSnapshot[toolName] before the deepStrictEqual, so a regen that silently drops a description (e.g. JSON.stringify eating an undefined value) fails by naming the tool, not as an opaque diff. - regen-tool-schemas.mjs: server entry point and snapshot output path are now resolved from import.meta.dirname instead of hardcoded repo-root-relative strings, so the script runs from any cwd. Verified by actually invoking it from packages/builder (not the repo root) and confirming it wrote to the one correct packages/builder/test/smoke/tool-schemas.snapshot.json. Full gate re-run after all of the above: typecheck 0 errors, test:unit 144/144 (140 baseline + 4 boundary tests, up from 2), test:smoke 26/26, test:integration:auth 12/12. Co-Authored-By: Claude Fable 5 --- .../builder/scripts/regen-tool-schemas.mjs | 16 ++++++-- packages/builder/src/vector/agent-network.ts | 6 +-- .../builder/src/vector/self-improvement.ts | 4 +- packages/builder/src/vector/vector.ts | 10 ++--- packages/builder/test/boundary.test.ts | 37 ++++++++++++++++++- .../integration/rate-limit-gating.test.ts | 4 +- .../builder/test/smoke/tool-inventory.test.ts | 15 ++++++-- .../test/smoke/tool-schemas.snapshot.json | 10 ++--- 8 files changed, 78 insertions(+), 24 deletions(-) diff --git a/packages/builder/scripts/regen-tool-schemas.mjs b/packages/builder/scripts/regen-tool-schemas.mjs index 09582cb..5820fea 100644 --- a/packages/builder/scripts/regen-tool-schemas.mjs +++ b/packages/builder/scripts/regen-tool-schemas.mjs @@ -1,6 +1,9 @@ // 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 +// sorted by name. Run: npm run build && node packages/builder/scripts/regen-tool-schemas.mjs +// (npm run build must still run from the repo root - or via `npm run build +// --workspace=...` - since it's an npm script; but this script itself now +// runs from any cwd, since its paths are resolved from its own location). // // Deviation from the original brief version: the brief used a flat 2000ms // sleep before connecting. On this machine that raced the server's actual @@ -18,11 +21,18 @@ // packages/builder/test/smoke/tool-inventory.test.ts for the full rationale. import { spawn } from 'node:child_process'; import { writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; +// Resolved from this file's own location, not from process.cwd(), so the +// script behaves identically regardless of where it's invoked from (repo +// root, packages/builder, or anywhere else). +const SERVER_ENTRY = resolve(import.meta.dirname, '../build/index.js'); +const SNAPSHOT_PATH = resolve(import.meta.dirname, '../test/smoke/tool-schemas.snapshot.json'); + const PORT = 3939; -const server = spawn('node', ['packages/builder/build/index.js'], { +const server = spawn('node', [SERVER_ENTRY], { env: { ...process.env, PORT: String(PORT) }, stdio: ['ignore', 'ignore', 'pipe'], }); @@ -54,7 +64,7 @@ try { .sort(([a], [b]) => a.localeCompare(b)), ); writeFileSync( - 'packages/builder/test/smoke/tool-schemas.snapshot.json', + SNAPSHOT_PATH, JSON.stringify(snapshot, null, 2) + '\n', ); console.log(`wrote ${tools.length} tool schemas`); diff --git a/packages/builder/src/vector/agent-network.ts b/packages/builder/src/vector/agent-network.ts index 4157e59..18f8713 100644 --- a/packages/builder/src/vector/agent-network.ts +++ b/packages/builder/src/vector/agent-network.ts @@ -357,7 +357,7 @@ Save your Agent DID - you'll need it to update, deregister, or let other agents **Troubleshooting Tips:** 1. Ensure wallet has at least 12 AP3X (10 deposit + fees) -2. Check spend limits with vector_get_spend_limits +2. Server spend limits for this tool are set via VECTOR_SPEND_LIMIT_PER_TX / VECTOR_SPEND_LIMIT_DAILY 3. Each wallet can register multiple agents (different seed UTxOs) 4. Verify Ogmios endpoint is reachable at ${VECTOR_OGMIOS_URL}`, }], @@ -451,7 +451,7 @@ The agent's profile has been updated on-chain. The identity NFT and deposit are 1. Verify the agent DID is correct 2. Your wallet must be the agent's current owner 3. Ensure wallet has enough AP3X for transaction fees -4. Check spend limits with vector_get_spend_limits`, +4. Server spend limits for this tool are set via VECTOR_SPEND_LIMIT_PER_TX / VECTOR_SPEND_LIMIT_DAILY`, }], }; } @@ -672,7 +672,7 @@ The message is recorded on-chain in TX metadata label 674.`, 1. Verify the agent DID is correct: did:vector:agent:{policyId}:{nftAssetName} 2. Ensure wallet has at least 3 AP3X (2 AP3X delivery + fees) 3. Use vector_get_agent_profile to verify the agent exists first -4. Check spend limits with vector_get_spend_limits`, +4. Server spend limits for this tool are set via VECTOR_SPEND_LIMIT_PER_TX / VECTOR_SPEND_LIMIT_DAILY`, }], }; } diff --git a/packages/builder/src/vector/self-improvement.ts b/packages/builder/src/vector/self-improvement.ts index 9bb9b9a..91afc19 100644 --- a/packages/builder/src/vector/self-improvement.ts +++ b/packages/builder/src/vector/self-improvement.ts @@ -465,7 +465,7 @@ Each batch UTxO holds ~30 AP3X for adoption rewards.`, const stakeLovelace = stakeApex * 1_000_000; const safetyCheck = safetyLayer.checkTransaction(stakeLovelace + 4_000_000); if (!safetyCheck.allowed) { - return { content: [{ type: "text", text: `Safety limit exceeded: ${safetyCheck.reason}. Check limits with vector_get_spend_limits.` }] }; + return { content: [{ type: "text", text: `Safety limit exceeded: ${safetyCheck.reason}. Server spend limits for this tool are set via VECTOR_SPEND_LIMIT_PER_TX / VECTOR_SPEND_LIMIT_DAILY.` }] }; } try { @@ -695,7 +695,7 @@ activity tracking token (\`pact_\`) minted. Visible on the Foundation dashboard. 1. Ensure wallet has at least ${stakeApex + 5} AP3X (stake + fees) 2. For Emergency proposals, your agent needs Established reputation (100+ AP3X staked in Game 3) 3. proposalHash must be 64 hex characters (blake2b_256 of your proposal document) -4. Check spend limits with vector_get_spend_limits`, +4. Server spend limits for this tool are set via VECTOR_SPEND_LIMIT_PER_TX / VECTOR_SPEND_LIMIT_DAILY`, }], }; } diff --git a/packages/builder/src/vector/vector.ts b/packages/builder/src/vector/vector.ts index 86a8adf..12ba578 100644 --- a/packages/builder/src/vector/vector.ts +++ b/packages/builder/src/vector/vector.ts @@ -175,7 +175,7 @@ ${utxoList}`, // vector_build_send_apex - build an UNSIGNED AP3X payment (keyless) server.tool( "vector_build_send_apex", - "Build an UNSIGNED transaction sending AP3X to a recipient. Takes no key material - sign the returned CBOR with your local signer (vector_signer_sign), then broadcast with vector_submit_transaction.", + "Build an UNSIGNED transaction sending AP3X to a recipient. Takes no key material - sign the returned CBOR with your local signer (vector_signer_sign), then broadcast with vector_submit_transaction and confirm with vector_await_transaction.", { changeAddress: z.string().describe("Your wallet address (source of funds; receives change). Get it from your local signer's vector_signer_get_address."), recipientAddress: z.string().describe("Recipient Vector address (addr1...)"), @@ -228,7 +228,7 @@ Non-custodial flow: 1) sign locally with vector_signer_sign { txCbor } — your // vector_build_send_tokens - build an UNSIGNED native-token transfer (keyless) server.tool( "vector_build_send_tokens", - "Build an UNSIGNED transaction sending Vector native tokens. Takes no key material - sign the returned CBOR with your local signer (vector_signer_sign), then broadcast with vector_submit_transaction.", + "Build an UNSIGNED transaction sending Vector native tokens. Takes no key material - sign the returned CBOR with your local signer (vector_signer_sign), then broadcast with vector_submit_transaction and confirm with vector_await_transaction.", { changeAddress: z.string().describe("Your wallet address (source of funds; receives change). Get it from your local signer's vector_signer_get_address."), recipientAddress: z.string().describe("Recipient Vector address (addr1...)"), @@ -285,7 +285,7 @@ Non-custodial flow: 1) sign locally with vector_signer_sign { txCbor }; 2) broad // vector_build_transaction - build an UNSIGNED multi-output transaction (keyless) server.tool( "vector_build_transaction", - "Build an UNSIGNED multi-output transaction with optional metadata. Takes no key material and never submits - sign the returned CBOR with your local signer (vector_signer_sign), then broadcast with vector_submit_transaction.", + "Build an UNSIGNED multi-output transaction with optional metadata. Takes no key material and never submits - sign the returned CBOR with your local signer (vector_signer_sign), then broadcast with vector_submit_transaction and confirm with vector_await_transaction.", { changeAddress: z.string().describe("Your wallet address (source of funds; receives change). Get it from your local signer's vector_signer_get_address."), outputs: z.array(z.object({ @@ -602,7 +602,7 @@ ${txList} // vector_build_deploy_contract - build an UNSIGNED contract deployment (keyless) server.tool( "vector_build_deploy_contract", - "Build an UNSIGNED transaction deploying a Plutus/Aiken smart contract (locks funds at its script address). Takes no key material - sign with your local signer, then broadcast with vector_submit_transaction.", + "Build an UNSIGNED transaction deploying a Plutus/Aiken smart contract (locks funds at its script address). Takes no key material - sign with your local signer, then broadcast with vector_submit_transaction and confirm with vector_await_transaction.", { changeAddress: z.string().describe("Your wallet address (source of funds; receives change). Get it from your local signer's vector_signer_get_address."), scriptCbor: z.string().describe("Compiled Plutus/Aiken script in CBOR hex format"), @@ -657,7 +657,7 @@ Sign locally with vector_signer_sign, broadcast with vector_submit_transaction, // vector_build_interact_contract - build an UNSIGNED contract interaction (keyless) server.tool( "vector_build_interact_contract", - "Build an UNSIGNED transaction interacting with a deployed Plutus/Aiken contract - lock AP3X at it or spend from it. Takes no key material - sign with your local signer, then broadcast with vector_submit_transaction.", + "Build an UNSIGNED transaction interacting with a deployed Plutus/Aiken contract - lock AP3X at it or spend from it. Takes no key material - sign with your local signer, then broadcast with vector_submit_transaction and confirm with vector_await_transaction.", { changeAddress: z.string().describe("Your wallet address (source of funds, change, and the required signer for spends). Get it from your local signer's vector_signer_get_address."), scriptCbor: z.string().describe("Compiled Plutus/Aiken script in CBOR hex"), diff --git a/packages/builder/test/boundary.test.ts b/packages/builder/test/boundary.test.ts index 4cd1a4a..d8d30de 100644 --- a/packages/builder/test/boundary.test.ts +++ b/packages/builder/test/boundary.test.ts @@ -16,7 +16,14 @@ 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; +// No \b boundaries: this is deliberately a substring match, not a whole-word +// one. Word-bounding let `seedPhrase`, `WALLET_SEED` or `bip39x` slip past +// undetected — those are real key-material vocabulary wearing a different +// word shape, not false positives. `fromSeed`/`walletFromSeed` are already +// covered as substrings of `seed`, but stay in the alternation anyway for +// self-documentation (they cost nothing and name the exact API shape this +// guard exists to catch). +const FORBIDDEN = /(mnemonic|seed|bip39|fromSeed|walletFromSeed|selectWallet\.fromSeed)/i; function sourceFiles(dir: string): string[] { const out: string[] = []; @@ -51,4 +58,32 @@ describe('builder custody boundary', () => { ); } }); + + // The allowlist may only shrink (PR 7 drops agent-network.ts to reach 1, PR 8 + // drops self-improvement.ts to reach 0 - see the "goes keyless in spec PR N" + // comments above). Pinning the size means any widening is a deliberate, + // reviewable number change instead of a silent `.add(...)` slipping through. + test('the allowlist may only shrink', () => { + assert.equal(CUSTODIAL_UNTIL_LATER_PR.size, 2, 'the custodial allowlist may only shrink - PR 7 takes it to 1 (drop agent-network), PR 8 to 0'); + }); + + // Pins every FORBIDDEN alternative individually against a fixture corpus, so + // deleting one alternative from the regex (e.g. "we don't need bip39 + // anymore") fails a test by name instead of silently narrowing what this + // guard catches. Also pins representative keyless vocabulary that must NOT + // trip the guard, so widening FORBIDDEN too aggressively is equally visible. + test('every FORBIDDEN alternative is individually pinned against a fixture corpus', () => { + const mustMatch = [ + 'mnemonic', 'MNEMONIC', 'seedPhrase', 'WALLET_SEED', 'bip39', + 'fromSeed', 'walletFromSeed', 'selectWallet.fromSeed', 'seed', + ]; + for (const s of mustMatch) { + assert.match(s, FORBIDDEN, `FORBIDDEN should match "${s}" - deleting the alternative that catches this would silently narrow the guard`); + } + + const mustNotMatch = ['address-only wallet', 'unsigned transaction', 'changeAddress']; + for (const s of mustNotMatch) { + assert.doesNotMatch(s, FORBIDDEN, `FORBIDDEN should NOT match "${s}" - this is legitimate keyless vocabulary`); + } + }); }); diff --git a/packages/builder/test/integration/rate-limit-gating.test.ts b/packages/builder/test/integration/rate-limit-gating.test.ts index c0d71a7..35e0d74 100644 --- a/packages/builder/test/integration/rate-limit-gating.test.ts +++ b/packages/builder/test/integration/rate-limit-gating.test.ts @@ -19,8 +19,8 @@ import type { RawServerHandle } from '../setup.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 +// probe is just as hermetic as the spend-limit-status tool it replaces +// (vector.ts no longer has one - 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 diff --git a/packages/builder/test/smoke/tool-inventory.test.ts b/packages/builder/test/smoke/tool-inventory.test.ts index ac4ebf7..78e5099 100644 --- a/packages/builder/test/smoke/tool-inventory.test.ts +++ b/packages/builder/test/smoke/tool-inventory.test.ts @@ -7,9 +7,10 @@ import { startServer, stopServer, ServerContext } from '../setup.ts'; /** * 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 + * PR that took this list from 25 to 23: it deleted `vector_get_address` + * outright, along with the tool that reported per-transaction/daily + * SafetyLayer spend status (the safety-layer/wallet-info tools have no + * keyless equivalent — spend status is 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 @@ -141,6 +142,14 @@ describe('tool schema snapshot', () => { live, `Tool "${toolName}" is in the snapshot but was not returned by the live server's listTools().` ); + // Named separately from the deepStrictEqual below: a regen that + // silently drops descriptions (e.g. JSON.stringify dropping an + // `undefined` description key) should fail by naming exactly that, + // not just show up as an opaque diff. + assert.ok( + 'description' in (schemaSnapshot[toolName] as any), + `Snapshot entry for "${toolName}" has no "description" key in test/smoke/tool-schemas.snapshot.json.` + ); assert.deepStrictEqual( { description: live.description, inputSchema: live.inputSchema }, schemaSnapshot[toolName], diff --git a/packages/builder/test/smoke/tool-schemas.snapshot.json b/packages/builder/test/smoke/tool-schemas.snapshot.json index d73f855..fa1871c 100644 --- a/packages/builder/test/smoke/tool-schemas.snapshot.json +++ b/packages/builder/test/smoke/tool-schemas.snapshot.json @@ -22,7 +22,7 @@ } }, "vector_build_deploy_contract": { - "description": "Build an UNSIGNED transaction deploying a Plutus/Aiken smart contract (locks funds at its script address). Takes no key material - sign with your local signer, then broadcast with vector_submit_transaction.", + "description": "Build an UNSIGNED transaction deploying a Plutus/Aiken smart contract (locks funds at its script address). Takes no key material - sign with your local signer, then broadcast with vector_submit_transaction and confirm with vector_await_transaction.", "inputSchema": { "type": "object", "properties": { @@ -61,7 +61,7 @@ } }, "vector_build_interact_contract": { - "description": "Build an UNSIGNED transaction interacting with a deployed Plutus/Aiken contract - lock AP3X at it or spend from it. Takes no key material - sign with your local signer, then broadcast with vector_submit_transaction.", + "description": "Build an UNSIGNED transaction interacting with a deployed Plutus/Aiken contract - lock AP3X at it or spend from it. Takes no key material - sign with your local signer, then broadcast with vector_submit_transaction and confirm with vector_await_transaction.", "inputSchema": { "type": "object", "properties": { @@ -139,7 +139,7 @@ } }, "vector_build_send_apex": { - "description": "Build an UNSIGNED transaction sending AP3X to a recipient. Takes no key material - sign the returned CBOR with your local signer (vector_signer_sign), then broadcast with vector_submit_transaction.", + "description": "Build an UNSIGNED transaction sending AP3X to a recipient. Takes no key material - sign the returned CBOR with your local signer (vector_signer_sign), then broadcast with vector_submit_transaction and confirm with vector_await_transaction.", "inputSchema": { "type": "object", "properties": { @@ -170,7 +170,7 @@ } }, "vector_build_send_tokens": { - "description": "Build an UNSIGNED transaction sending Vector native tokens. Takes no key material - sign the returned CBOR with your local signer (vector_signer_sign), then broadcast with vector_submit_transaction.", + "description": "Build an UNSIGNED transaction sending Vector native tokens. Takes no key material - sign the returned CBOR with your local signer (vector_signer_sign), then broadcast with vector_submit_transaction and confirm with vector_await_transaction.", "inputSchema": { "type": "object", "properties": { @@ -210,7 +210,7 @@ } }, "vector_build_transaction": { - "description": "Build an UNSIGNED multi-output transaction with optional metadata. Takes no key material and never submits - sign the returned CBOR with your local signer (vector_signer_sign), then broadcast with vector_submit_transaction.", + "description": "Build an UNSIGNED multi-output transaction with optional metadata. Takes no key material and never submits - sign the returned CBOR with your local signer (vector_signer_sign), then broadcast with vector_submit_transaction and confirm with vector_await_transaction.", "inputSchema": { "type": "object", "properties": { From 1cdcdbf522abfd09cae74e7ed40ec7e59e1d51d4 Mon Sep 17 00:00:00 2001 From: FilipBl4gojevic <84703395+FilipBl4gojevic@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:40:13 +0200 Subject: [PATCH 09/14] fix(builder): reword agent-network minting-vocab comment to dodge PR-7 boundary guard "different seed UTxOs" is one-shot minting-policy vocabulary, not key material, but the boundary test's FORBIDDEN regex matches "seed" as a substring anywhere in scanned source. agent-network.ts is allowlisted as custodial today so this line is invisible to the guard, but PR 7 takes it keyless and drops the allowlist entry - at that point this comment would false-red the guard for no reason. Reworded to "one-shot UTxOs", meaning unchanged. --- packages/builder/src/vector/agent-network.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/builder/src/vector/agent-network.ts b/packages/builder/src/vector/agent-network.ts index 18f8713..ede7b89 100644 --- a/packages/builder/src/vector/agent-network.ts +++ b/packages/builder/src/vector/agent-network.ts @@ -358,7 +358,7 @@ Save your Agent DID - you'll need it to update, deregister, or let other agents **Troubleshooting Tips:** 1. Ensure wallet has at least 12 AP3X (10 deposit + fees) 2. Server spend limits for this tool are set via VECTOR_SPEND_LIMIT_PER_TX / VECTOR_SPEND_LIMIT_DAILY -3. Each wallet can register multiple agents (different seed UTxOs) +3. Each wallet can register multiple agents (different one-shot UTxOs) 4. Verify Ogmios endpoint is reachable at ${VECTOR_OGMIOS_URL}`, }], }; From 9799cbd8adf8b2cf8b60ab407573e25438e308ae Mon Sep 17 00:00:00 2001 From: FilipBl4gojevic <84703395+FilipBl4gojevic@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:40:21 +0200 Subject: [PATCH 10/14] test(builder): keyless-build and gated E2E integration suites; legacy suite follows the new family --- .../test/integration/keyless-build.test.ts | 133 +++++++++++++++ .../test/integration/keyless-e2e.test.ts | 64 ++++++++ packages/builder/test/integration/run.test.ts | 151 +++++++----------- 3 files changed, 251 insertions(+), 97 deletions(-) create mode 100644 packages/builder/test/integration/keyless-build.test.ts create mode 100644 packages/builder/test/integration/keyless-e2e.test.ts diff --git a/packages/builder/test/integration/keyless-build.test.ts b/packages/builder/test/integration/keyless-build.test.ts new file mode 100644 index 0000000..76c1951 --- /dev/null +++ b/packages/builder/test/integration/keyless-build.test.ts @@ -0,0 +1,133 @@ +// packages/builder/test/integration/keyless-build.test.ts +import { describe, test, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { CML } from '@lucid-evolution/lucid'; +import { startServer, stopServer, callTool, ServerContext } from '../setup.ts'; + +// The funded shared testnet wallet's ADDRESS — public information. This suite +// proves the keyless family builds real transactions against the live chain +// while holding zero secrets: no mnemonic anywhere in this file. +const OWN_ADDRESS = 'addr1qylasu4y34ccwt8hv55tkswa9fthtck9xtppvc4y03kwhaztd0jlxdzk72zj8mgy6x4269v9gytkgffh3k8d3l8987yqu5c35z'; + +// Always-succeeds PlutusV2 validator (accepts any datum/redeemer/context, returns True) +const ALWAYS_SUCCEEDS_V2 = '49480100002221200101'; + +function extractCbor(text: string): string { + const m = text.match(/```\n([0-9a-fA-F]+)\n```/); + assert.ok(m, `no CBOR block in tool response:\n${text.slice(0, 500)}`); + return m[1]; +} + +// Handler-level pin for "build tools never render a submitted claim" (folded +// in from Task 4's opus review). The forbidden pattern excludes literal "Not +// Submitted" via a negative lookbehind rather than a blanket allow: +// vector_build_transaction's own correct heading is "Unsigned Transaction +// Built (Not Submitted)", and a bare /Submitted\b/ would flag that required +// phrasing as a false positive — \b only anchors the boundary *after* the +// word, not before it, so "(Not Submitted)" satisfies "Submitted\b" too. +// Verified empirically against every tier-1 handler's actual response text; +// the fixed pattern still catches a real "# Transaction Submitted" claim. +function assertBuildNotSubmitted(text: string): void { + assert.doesNotMatch(text, /Transaction Submitted|(? { ctx = await startServer(); }); +after(async () => { await stopServer(ctx); }); + +describe('keyless build against live testnet', () => { + test('vector_build_send_apex builds a real, decodable, UNSIGNED self-send', { timeout: 120_000 }, async () => { + const text = await callTool(ctx.client, 'vector_build_send_apex', { + changeAddress: OWN_ADDRESS, recipientAddress: OWN_ADDRESS, amount: 3, + }); + const cbor = extractCbor(text); + const tx = CML.Transaction.from_cbor_hex(cbor); + const outs = tx.body().outputs(); + let paid = 0n; + for (let i = 0; i < outs.len(); i++) { + const o = outs.get(i); + assert.equal(o.address().to_bech32(), OWN_ADDRESS, 'self-send must only pay the own address'); + paid += BigInt(o.amount().coin().toString()); + } + const fee = BigInt(tx.body().fee().toString()); + assert.ok(fee >= 156_253n && fee <= 500_000n, `fee out of sane range: ${fee}`); + const vkeys = tx.witness_set().vkeywitnesses(); + assert.ok(vkeys === undefined || vkeys.len() === 0, 'hosted builder returned a SIGNED transaction'); + assert.ok(paid >= 3_000_000n, 'outputs below the requested amount'); + assertBuildNotSubmitted(text); + }); + + test('vector_dry_run accepts the unsigned CBOR and reports it valid', { timeout: 120_000 }, async () => { + const buildText = await callTool(ctx.client, 'vector_build_send_apex', { + changeAddress: OWN_ADDRESS, recipientAddress: OWN_ADDRESS, amount: 3, + }); + const cbor = extractCbor(buildText); + const text = await callTool(ctx.client, 'vector_dry_run', { txCbor: cbor }); + assert.match(text, /Valid: Yes/); + }); + + test('a bogus change address fails with a clear error, not a network error', { timeout: 60_000 }, async () => { + const text = await callTool(ctx.client, 'vector_build_send_apex', { + changeAddress: 'addr1notreal', recipientAddress: OWN_ADDRESS, amount: 3, + }); + assert.match(text, /Invalid change address/); + }); + + // --- Controller additions (Task 4's opus review): handler-level coverage + // for the remaining three build tools, still no secrets, no funds spent. --- + + test('vector_build_transaction builds a real, decodable, UNSIGNED multi-output self-send', { timeout: 120_000 }, async () => { + const text = await callTool(ctx.client, 'vector_build_transaction', { + changeAddress: OWN_ADDRESS, + outputs: [ + { address: OWN_ADDRESS, lovelace: 2_000_000 }, + { address: OWN_ADDRESS, lovelace: 2_000_000 }, + ], + }); + const cbor = extractCbor(text); + const tx = CML.Transaction.from_cbor_hex(cbor); + const outs = tx.body().outputs(); + let matchingOutputs = 0; + for (let i = 0; i < outs.len(); i++) { + const o = outs.get(i); + assert.equal(o.address().to_bech32(), OWN_ADDRESS, 'self-send must only pay the own address'); + if (BigInt(o.amount().coin().toString()) === 2_000_000n) matchingOutputs++; + } + assert.ok(matchingOutputs >= 2, `expected both requested 2 AP3X outputs present, found ${matchingOutputs}`); + const vkeys = tx.witness_set().vkeywitnesses(); + assert.ok(vkeys === undefined || vkeys.len() === 0, 'hosted builder returned a SIGNED transaction'); + assertBuildNotSubmitted(text); + }); + + test('vector_build_deploy_contract builds a real, decodable, UNSIGNED contract deployment', { timeout: 120_000 }, async () => { + const text = await callTool(ctx.client, 'vector_build_deploy_contract', { + changeAddress: OWN_ADDRESS, + scriptCbor: ALWAYS_SUCCEEDS_V2, + scriptType: 'PlutusV2', + }); + assert.match(text, /Script Address:/); + const cbor = extractCbor(text); + const tx = CML.Transaction.from_cbor_hex(cbor); + const vkeys = tx.witness_set().vkeywitnesses(); + assert.ok(vkeys === undefined || vkeys.len() === 0, 'hosted builder returned a SIGNED transaction'); + assertBuildNotSubmitted(text); + }); + + test('vector_build_interact_contract (lock) builds a real, decodable, UNSIGNED contract interaction', { timeout: 120_000 }, async () => { + const text = await callTool(ctx.client, 'vector_build_interact_contract', { + changeAddress: OWN_ADDRESS, + scriptCbor: ALWAYS_SUCCEEDS_V2, + scriptType: 'PlutusV2', + action: 'lock', + datum: 'd87980', + lovelaceAmount: 2_000_000, + }); + assert.match(text, /\*\*Action:\*\* lock/); + const cbor = extractCbor(text); + const tx = CML.Transaction.from_cbor_hex(cbor); + const vkeys = tx.witness_set().vkeywitnesses(); + assert.ok(vkeys === undefined || vkeys.len() === 0, 'hosted builder returned a SIGNED transaction'); + assertBuildNotSubmitted(text); + }); +}); diff --git a/packages/builder/test/integration/keyless-e2e.test.ts b/packages/builder/test/integration/keyless-e2e.test.ts new file mode 100644 index 0000000..265cfdc --- /dev/null +++ b/packages/builder/test/integration/keyless-e2e.test.ts @@ -0,0 +1,64 @@ +// packages/builder/test/integration/keyless-e2e.test.ts +import { describe, test, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { CML, walletFromSeed } from '@lucid-evolution/lucid'; +import { startServer, stopServer, callTool, getMnemonic, ServerContext } from '../setup.ts'; + +// Full non-custodial pipeline against the live testnet: +// keyless build (hosted tool) -> inline CML sign (this process, standing in +// for the local signer, which lives on PR #7's branch) -> submit tool -> +// await tool. A self-send, so the run costs only the fee (~0.16 AP3X). +// +// Gated: set VECTOR_E2E_SUBMIT=1 to run. Requires packages/builder/mnemonic.txt +// (the funded testnet wallet). NEVER runs in CI. +const RUN = process.env.VECTOR_E2E_SUBMIT === '1'; + +function extractCbor(text: string): string { + const m = text.match(/```\n([0-9a-fA-F]+)\n```/); + assert.ok(m, `no CBOR block in tool response:\n${text.slice(0, 500)}`); + return m[1]; +} +function extractTxHash(text: string): string { + const m = text.match(/Transaction Hash: ([0-9a-f]{64})/); + assert.ok(m, `no tx hash in tool response:\n${text.slice(0, 500)}`); + return m[1]; +} + +describe('keyless E2E: build -> sign -> submit -> await', { skip: !RUN }, () => { + let ctx: ServerContext; + before(async () => { ctx = await startServer(); }); + after(async () => { await stopServer(ctx); }); + + test('the four-call non-custodial flow lands a self-send on-chain', { timeout: 300_000 }, async () => { + const { address, paymentKey } = walletFromSeed(getMnemonic(), { network: 'Mainnet', accountIndex: 0 }); + + // 1. keyless build (hosted) + const buildText = await callTool(ctx.client, 'vector_build_send_apex', { + changeAddress: address, recipientAddress: address, amount: 3, + }); + const unsignedCbor = extractCbor(buildText); + + // 2. sign locally (stand-in for vector_signer_sign) + const tx = CML.Transaction.from_cbor_hex(unsignedCbor); + const txHash = CML.hash_transaction(tx.body()); + const sk = CML.PrivateKey.from_bech32(paymentKey); + const vkeyWitness = CML.make_vkey_witness(txHash, sk); + const witnessSet = CML.TransactionWitnessSet.new(); + const vkeys = CML.VkeywitnessList.new(); + vkeys.add(vkeyWitness); + witnessSet.set_vkeywitnesses(vkeys); + const signed = CML.Transaction.new(tx.body(), witnessSet, tx.is_valid(), tx.auxiliary_data()); + const signedCbor = signed.to_cbor_hex(); + + // 3. submit (hosted) + const submitText = await callTool(ctx.client, 'vector_submit_transaction', { signedTxCbor: signedCbor }); + const submittedHash = extractTxHash(submitText); + assert.equal(submittedHash, txHash.to_hex(), 'submitted hash differs from the locally computed body hash'); + + // 4. await confirmation (hosted) + const awaitText = await callTool(ctx.client, 'vector_await_transaction', { + txHash: submittedHash, timeoutSeconds: 240, + }); + assert.match(awaitText, /Transaction Confirmed/); + }); +}); diff --git a/packages/builder/test/integration/run.test.ts b/packages/builder/test/integration/run.test.ts index f774494..1009417 100644 --- a/packages/builder/test/integration/run.test.ts +++ b/packages/builder/test/integration/run.test.ts @@ -1,5 +1,6 @@ import { describe, test, before, after } from 'node:test'; import assert from 'node:assert/strict'; +import { walletFromSeed } from '@lucid-evolution/lucid'; import { startServer, stopServer, callTool, getMnemonic, wait, ServerContext } from '../setup.ts'; // Always-succeeds PlutusV2 validator (accepts any datum/redeemer/context, returns True) @@ -14,6 +15,10 @@ let agentDid: string | null = null; before(async () => { mnemonic = getMnemonic(); + // The keyless build tools take an address, not a mnemonic - derive it once + // up front instead of scraping it out of a vector_get_address response + // (that tool is gone; the signer owns address derivation from PR 7 on). + walletAddress = walletFromSeed(mnemonic, { network: 'Mainnet', accountIndex: 0 }).address; console.log('Starting MCP server...'); ctx = await startServer(); console.log(`MCP server running on port ${ctx.port}`); @@ -48,38 +53,28 @@ function assertSuccessOrKnownError(text: string, successPattern: RegExp, toolNam // ─── Wallet Tools ─────────────────────────────────────────────────────────── describe('Wallet Tools', () => { - test('vector_get_address', { timeout: 120_000 }, async () => { - const text = await callTool(ctx.client, 'vector_get_address', { mnemonic }); + // vector_get_address is gone (the signer owns address derivation from PR 7 + // on) - walletAddress now comes from walletFromSeed in before(). This test + // takes over its funded/unfunded bookkeeping via the keyless balance tool. + test('vector_get_balance', { timeout: 120_000 }, async () => { + const text = await callTool(ctx.client, 'vector_get_balance', { address: walletAddress }); console.log(text); - assert.match(text, /addr1/, 'Should contain a Vector address'); - assert.match(text, /ADA Balance:/, 'Should show ADA balance'); - assert.match(text, /UTXO Count:/, 'Should show UTXO count'); - - const addrMatch = text.match(/(addr1[a-z0-9]+)/); - assert.ok(addrMatch, 'Should be able to extract address'); - walletAddress = addrMatch![1]; + assert.match(text, /AP3X Balance:/, 'Should show AP3X balance'); + assert.ok(text.includes(walletAddress), 'Should contain the queried address'); // Check if wallet is funded - const balanceMatch = text.match(/ADA Balance:\s*([\d.]+)/); + const balanceMatch = text.match(/AP3X Balance:\s*([\d.]+)/); if (balanceMatch && parseFloat(balanceMatch[1]) > 0) { walletHasAda = true; walletBalanceAda = parseFloat(balanceMatch[1]); - console.log(`Wallet funded: ${balanceMatch[1]} ADA`); + console.log(`Wallet funded: ${balanceMatch[1]} AP3X`); } else { - console.log('Wallet has 0 ADA - transaction tests will verify error handling'); + console.log('Wallet has 0 AP3X - transaction tests will verify error handling'); } }); - test('vector_get_balance', { timeout: 120_000 }, async () => { - assert.ok(walletAddress, 'Wallet address should be set from previous test'); - const text = await callTool(ctx.client, 'vector_get_balance', { address: walletAddress }); - console.log(text); - assert.match(text, /ADA Balance:/, 'Should show ADA balance'); - assert.ok(text.includes(walletAddress), 'Should contain the queried address'); - }); - test('vector_get_utxos', { timeout: 120_000 }, async () => { - const text = await callTool(ctx.client, 'vector_get_utxos', { mnemonic }); + const text = await callTool(ctx.client, 'vector_get_utxos', { address: walletAddress }); console.log(text); assert.ok( text.includes('Total:') || text.includes('No UTxOs found'), @@ -91,16 +86,10 @@ describe('Wallet Tools', () => { // ─── History & Limits ─────────────────────────────────────────────────────── describe('History & Limits', () => { - test('vector_get_spend_limits', { timeout: 120_000 }, async () => { - const text = await callTool(ctx.client, 'vector_get_spend_limits', {}); - console.log(text); - assert.match(text, /Per-Transaction Limit:/, 'Should show per-tx limit'); - assert.match(text, /Daily Limit:/, 'Should show daily limit'); - assert.match(text, /Daily Remaining:/, 'Should show daily remaining'); - }); + // vector_get_spend_limits is gone (the signer owns spend limits from PR 7 on). test('vector_get_transaction_history', { timeout: 120_000 }, async () => { - const text = await callTool(ctx.client, 'vector_get_transaction_history', { mnemonic, limit: 5 }); + const text = await callTool(ctx.client, 'vector_get_transaction_history', { address: walletAddress, limit: 5 }); console.log(text); assert.ok( text.includes('Transaction History') || text.includes('No transactions found') @@ -111,39 +100,15 @@ describe('History & Limits', () => { }); // ─── UTxO Consolidation ───────────────────────────────────────────────────── -// Repeated test runs fragment the wallet into many small UTxOs. Plutus script -// transactions need collateral (≥5 ADA in ≤3 inputs). Consolidate up-front so -// later tests don't fail from fragmentation. - -describe('UTxO Consolidation', () => { - test('consolidate wallet UTxOs', { timeout: 120_000 }, async () => { - if (!walletHasAda) return; - const utxoText = await callTool(ctx.client, 'vector_get_utxos', { mnemonic }); - const countMatch = utxoText.match(/Total:\s*(\d+)/); - const utxoCount = countMatch ? parseInt(countMatch[1], 10) : 0; - if (utxoCount <= 3) { - console.log(`Only ${utxoCount} UTxO(s) — no consolidation needed`); - return; - } - // Send most of the balance to self, forcing Lucid to consume many inputs. - // This reduces the UTxO set to ~2 (output + change). - const consolidateAmount = Math.floor(walletBalanceAda) - 2; // leave buffer for fees - if (consolidateAmount < 2) { - console.log(`Balance too low (${walletBalanceAda} ADA) to consolidate`); - return; - } - console.log(`${utxoCount} UTxOs detected — consolidating ${consolidateAmount} ADA to self...`); - const text = await callTool(ctx.client, 'vector_send_apex', { - recipientAddress: walletAddress, - amount: consolidateAmount, - mnemonic, - }); - console.log(text); - assertSuccessOrKnownError(text, /Transaction Hash:/, 'consolidate UTxOs'); - console.log('Waiting 10s for consolidation tx to confirm...'); - await wait(10); - }); -}); +// Removed: consolidation only had a reason to exist because vector_send_apex +// could submit. The keyless build_* tools never submit (vector_send_apex and +// vector_send_tokens are gone outright), so there is no longer a way for this +// package to land a real consolidating transaction on its own - that becomes +// the local signer's concern once it drives the four-call non-custodial flow. +// Fragmentation-tolerance for the build/dry-run tests below is covered the +// same way every other wallet-state-dependent assertion in this file is: +// assertSuccessOrKnownError treats "Failed to build ..." as an acceptable +// known outcome, not a hard failure. // ─── Transaction Tools ────────────────────────────────────────────────────── @@ -152,117 +117,109 @@ describe('Transaction Tools', () => { assert.ok(walletAddress, 'Wallet address required'); const text = await callTool(ctx.client, 'vector_dry_run', { outputs: [{ address: walletAddress, lovelace: 2_000_000 }], - mnemonic, + changeAddress: walletAddress, }); console.log(text); assertSuccessOrKnownError(text, /Valid:.*\nEstimated Fee:/, 'vector_dry_run'); }); - test('vector_build_transaction (unsigned)', { timeout: 120_000 }, async () => { + test('vector_build_transaction', { timeout: 120_000 }, async () => { assert.ok(walletAddress, 'Wallet address required'); const text = await callTool(ctx.client, 'vector_build_transaction', { outputs: [{ address: walletAddress, lovelace: 2_000_000 }], - mnemonic, - submit: false, + changeAddress: walletAddress, }); console.log(text); - assertSuccessOrKnownError(text, /CBOR/, 'vector_build_transaction (unsigned)'); + assertSuccessOrKnownError(text, /CBOR/, 'vector_build_transaction'); }); - test('vector_build_transaction (submit)', { timeout: 120_000 }, async () => { - assert.ok(walletAddress, 'Wallet address required'); - const text = await callTool(ctx.client, 'vector_build_transaction', { - outputs: [{ address: walletAddress, lovelace: 2_000_000 }], - mnemonic, - submit: true, - }); - console.log(text); - assertSuccessOrKnownError(text, /Transaction Hash:/, 'vector_build_transaction (submit)'); - }); + // vector_build_transaction (submit) is gone: the tool no longer submits at + // all (no submit param exists on it anymore) - keyless-e2e.test.ts is the + // suite that now covers the build -> sign -> submit -> await pipeline. - test('vector_send_apex', { timeout: 120_000 }, async () => { + test('vector_build_send_apex', { timeout: 120_000 }, async () => { assert.ok(walletAddress, 'Wallet address required'); // Wait for previous tx UTxOs to settle if (walletHasAda) { console.log('Waiting 10s for UTxOs to settle...'); await wait(10); } - const text = await callTool(ctx.client, 'vector_send_apex', { + const text = await callTool(ctx.client, 'vector_build_send_apex', { + changeAddress: walletAddress, recipientAddress: walletAddress, amount: 2, - mnemonic, }); console.log(text); - assertSuccessOrKnownError(text, /Transaction Hash:/, 'vector_send_apex'); + assertSuccessOrKnownError(text, /Unsigned TX CBOR:/, 'vector_build_send_apex'); }); - test('vector_send_tokens', { timeout: 120_000 }, async () => { + test('vector_build_send_tokens', { timeout: 120_000 }, async () => { assert.ok(walletAddress, 'Wallet address required'); // Wait for send_apex UTxOs to settle if (walletHasAda) { console.log('Waiting 10s for UTxOs to settle...'); await wait(10); } - const text = await callTool(ctx.client, 'vector_send_tokens', { + const text = await callTool(ctx.client, 'vector_build_send_tokens', { + changeAddress: walletAddress, recipientAddress: walletAddress, policyId: 'a'.repeat(56), assetName: 'test', amount: '1', - mnemonic, }); console.log(text); - assertSuccessOrKnownError(text, /Transaction Hash:/, 'vector_send_tokens'); + assertSuccessOrKnownError(text, /Unsigned TX CBOR:/, 'vector_build_send_tokens'); }); }); // ─── Smart Contract Tools ─────────────────────────────────────────────────── describe('Smart Contract Tools', () => { - test('vector_deploy_contract', { timeout: 120_000 }, async () => { + test('vector_build_deploy_contract', { timeout: 120_000 }, async () => { if (walletHasAda) { console.log('Waiting 10s for UTxOs to settle before deploy...'); await wait(10); } - const text = await callTool(ctx.client, 'vector_deploy_contract', { + const text = await callTool(ctx.client, 'vector_build_deploy_contract', { scriptCbor: ALWAYS_SUCCEEDS_V2, scriptType: 'PlutusV2', - mnemonic, + changeAddress: walletAddress, }); console.log(text); - assertSuccessOrKnownError(text, /Script Address:/, 'vector_deploy_contract'); + assertSuccessOrKnownError(text, /Unsigned TX CBOR:/, 'vector_build_deploy_contract'); }); - test('vector_interact_contract (lock)', { timeout: 120_000 }, async () => { + test('vector_build_interact_contract (lock)', { timeout: 120_000 }, async () => { if (walletHasAda) { console.log('Waiting 10s for deploy tx to confirm...'); await wait(10); } - const text = await callTool(ctx.client, 'vector_interact_contract', { + const text = await callTool(ctx.client, 'vector_build_interact_contract', { scriptCbor: ALWAYS_SUCCEEDS_V2, scriptType: 'PlutusV2', action: 'lock', - mnemonic, + changeAddress: walletAddress, datum: 'd87980', lovelaceAmount: 2_000_000, }); console.log(text); - assertSuccessOrKnownError(text, /Transaction Hash:/, 'vector_interact_contract (lock)'); + assertSuccessOrKnownError(text, /Unsigned TX CBOR:/, 'vector_build_interact_contract (lock)'); }); - test('vector_interact_contract (spend)', { timeout: 120_000 }, async () => { + test('vector_build_interact_contract (spend)', { timeout: 120_000 }, async () => { if (walletHasAda) { console.log('Waiting 10s for lock tx to confirm...'); await wait(10); } - const text = await callTool(ctx.client, 'vector_interact_contract', { + const text = await callTool(ctx.client, 'vector_build_interact_contract', { scriptCbor: ALWAYS_SUCCEEDS_V2, scriptType: 'PlutusV2', action: 'spend', - mnemonic, + changeAddress: walletAddress, redeemer: 'd87980', }); console.log(text); - assertSuccessOrKnownError(text, /Transaction Hash:/, 'vector_interact_contract (spend)'); + assertSuccessOrKnownError(text, /Unsigned TX CBOR:/, 'vector_build_interact_contract (spend)'); }); }); From dde221fdba5e5388943c4f6ddecd3b86cea0853e Mon Sep 17 00:00:00 2001 From: FilipBl4gojevic <84703395+FilipBl4gojevic@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:56:33 +0200 Subject: [PATCH 11/14] fix(builder): disarm remaining seed-vocab landmine, tighten tier-1 amount assertion Opus review round 2 on Task 6: - agent-network.ts: seedUtxo -> oneShotUtxo (5 occurrences). This is one-shot-minting-policy vocabulary that survives PR 7's keyless rewrite, but the boundary test's FORBIDDEN regex is an unanchored case-insensitive substring match on "seed" - left as-is it would have false-red PR 7's guard the moment agent-network.ts drops off the custodial allowlist. Only selectWallet.fromSeed (genuine key-material vocabulary, deleted by PR 7 anyway) still matches `seed` in this file. File is @ts-nocheck, so this was verified mechanically, not just visually: line-match counts before (seedUtxo=5) and after (seedUtxo=0, oneShotUtxo=5) line up exactly, and a fresh `import()` of the module still loads error-free. - keyless-build.test.ts: the first tier-1 test summed every output (`paid >= 3_000_000n`), but a self-send's change output lands at the same address as the payment, so the sum floor could never actually detect a dropped or wrong payment amount - change alone clears it. Added an exact-count check (outputs at exactly 3_000_000n lovelace, >=1) alongside the existing sum/fee/unsigned assertions, matching the pattern already used by the build_transaction test in the same file. Also added the missing assertBuildNotSubmitted check on the dry-run test's own build response. - run.test.ts: deleted walletBalanceAda - write-only since the UTxO Consolidation block (its only reader) was removed earlier in this task. Re-verified full gate plus one live re-run of the tightened tier-1 suite (build-only, no funds spent, no E2E resubmission). --- packages/builder/src/vector/agent-network.ts | 10 +++++----- .../builder/test/integration/keyless-build.test.ts | 12 +++++++++++- packages/builder/test/integration/run.test.ts | 2 -- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/builder/src/vector/agent-network.ts b/packages/builder/src/vector/agent-network.ts index ede7b89..1001f6e 100644 --- a/packages/builder/src/vector/agent-network.ts +++ b/packages/builder/src/vector/agent-network.ts @@ -306,21 +306,21 @@ ${capList} if (!vkeyHash) throw new Error('Cannot derive payment key hash from wallet address'); const utxos = await lucid.utxosAt(await lucid.wallet().address()); - const seedUtxo = utxos.find(u => { + const oneShotUtxo = utxos.find(u => { const keys = Object.keys(u.assets); return keys.length === 1 && keys[0] === 'lovelace' && u.assets['lovelace'] >= MIN_AP3X_DEPOSIT + 2_000_000n; }) || utxos[0]; - if (!seedUtxo) throw new Error('No UTxOs in wallet. Please fund the wallet first.'); + if (!oneShotUtxo) throw new Error('No UTxOs in wallet. Please fund the wallet first.'); - const nftAssetName = deriveNftAssetName(seedUtxo.txHash, seedUtxo.outputIndex); + const nftAssetName = deriveNftAssetName(oneShotUtxo.txHash, oneShotUtxo.outputIndex); const nftUnit = `${REGISTRY_POLICY_ID}${nftAssetName}`; const datum = buildAgentDatum(vkeyHash, name, description, capabilities, framework, endpoint); const registryScript = { type: 'PlutusV3' as const, script: REGISTRY_SCRIPT_CBOR }; const registryAddress = validatorToAddress('Mainnet', registryScript); - const registerRedeemer = Data.to(new Constr(0, [new Constr(0, [seedUtxo.txHash, BigInt(seedUtxo.outputIndex)])])); + const registerRedeemer = Data.to(new Constr(0, [new Constr(0, [oneShotUtxo.txHash, BigInt(oneShotUtxo.outputIndex)])])); const tx = await lucid.newTx() - .collectFrom([seedUtxo]) + .collectFrom([oneShotUtxo]) .mintAssets({ [nftUnit]: 1n }, registerRedeemer) .attach.MintingPolicy(registryScript) .pay.ToAddressWithData(registryAddress, { kind: "inline", value: datum }, { lovelace: MIN_AP3X_DEPOSIT, [nftUnit]: 1n }) diff --git a/packages/builder/test/integration/keyless-build.test.ts b/packages/builder/test/integration/keyless-build.test.ts index 76c1951..cc3c63a 100644 --- a/packages/builder/test/integration/keyless-build.test.ts +++ b/packages/builder/test/integration/keyless-build.test.ts @@ -45,11 +45,20 @@ describe('keyless build against live testnet', () => { const tx = CML.Transaction.from_cbor_hex(cbor); const outs = tx.body().outputs(); let paid = 0n; + // A self-send means the payment output and the change output land at the + // same address, so summing every output can't tell a correct 3 AP3X + // payment apart from a dropped/wrong one (change alone dwarfs the floor). + // Count outputs at exactly the requested lovelace amount instead - the + // same exact-count pattern the build_transaction test below uses. + let paymentOutputs = 0; for (let i = 0; i < outs.len(); i++) { const o = outs.get(i); assert.equal(o.address().to_bech32(), OWN_ADDRESS, 'self-send must only pay the own address'); - paid += BigInt(o.amount().coin().toString()); + const lovelace = BigInt(o.amount().coin().toString()); + paid += lovelace; + if (lovelace === 3_000_000n) paymentOutputs++; } + assert.ok(paymentOutputs >= 1, `expected a 3 AP3X payment output, found ${paymentOutputs} (total paid: ${paid})`); const fee = BigInt(tx.body().fee().toString()); assert.ok(fee >= 156_253n && fee <= 500_000n, `fee out of sane range: ${fee}`); const vkeys = tx.witness_set().vkeywitnesses(); @@ -62,6 +71,7 @@ describe('keyless build against live testnet', () => { const buildText = await callTool(ctx.client, 'vector_build_send_apex', { changeAddress: OWN_ADDRESS, recipientAddress: OWN_ADDRESS, amount: 3, }); + assertBuildNotSubmitted(buildText); const cbor = extractCbor(buildText); const text = await callTool(ctx.client, 'vector_dry_run', { txCbor: cbor }); assert.match(text, /Valid: Yes/); diff --git a/packages/builder/test/integration/run.test.ts b/packages/builder/test/integration/run.test.ts index 1009417..89bb122 100644 --- a/packages/builder/test/integration/run.test.ts +++ b/packages/builder/test/integration/run.test.ts @@ -10,7 +10,6 @@ let ctx: ServerContext; let mnemonic: string; let walletAddress: string; let walletHasAda = false; -let walletBalanceAda = 0; let agentDid: string | null = null; before(async () => { @@ -66,7 +65,6 @@ describe('Wallet Tools', () => { const balanceMatch = text.match(/AP3X Balance:\s*([\d.]+)/); if (balanceMatch && parseFloat(balanceMatch[1]) > 0) { walletHasAda = true; - walletBalanceAda = parseFloat(balanceMatch[1]); console.log(`Wallet funded: ${balanceMatch[1]} AP3X`); } else { console.log('Wallet has 0 AP3X - transaction tests will verify error handling'); From 714d4ebcd4e90ad8f533e4efbf898e1568e1e6d7 Mon Sep 17 00:00:00 2001 From: FilipBl4gojevic <84703395+FilipBl4gojevic@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:22:31 +0200 Subject: [PATCH 12/14] docs: wallet/tx family is keyless - security notice, tool tables, spend-limit scope README, .env.example, and docs/architecture/non-custodial-split.md updated for the family-1 keyless migration (build_* tools, no mnemonic parameters): custody notice rewritten to describe the keyless boundary precisely, tool tables synced to the 23-tool surface (4 renames, 4 changed in place, 2 removed), self-hosting and testing copy updated, .env.example spend-limit scope annotated, and the architecture doc's PR-sequence table marks family 1 as landed. docs/VECTOR.md (flagged by Task 4 as referencing stale tool names) is also updated: renamed/removed tool sections corrected, keyless changeAddress params added, and the Safety Controls / Error Handling sections stop claiming server-side spend limits and mnemonic requirements that no longer apply to the tools this page documents. Edits confined to tool names and custody claims - no restructuring, no wholesale ADA to AP3X pass beyond the sections touched. --- .env.example | 4 + README.md | 49 ++++++------ docs/VECTOR.md | 94 ++++++++++-------------- docs/architecture/non-custodial-split.md | 2 +- 4 files changed, 68 insertions(+), 81 deletions(-) diff --git a/.env.example b/.env.example index fc847bc..0b11cbe 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,10 @@ VECTOR_SUBMIT_URL=https://submit.vector.testnet.apexfusion.org/api/submit/tx VECTOR_EXPLORER_URL=https://vector.testnet.apexscan.org # Safety Limits (in lovelace, 1 ADA = 1,000,000 lovelace) +# Scope note (non-custodial migration): the keyless build_* family ignores these - +# spend limits for it are enforced by the LOCAL signer, per user. These server-side +# limits still govern the remaining custodial families (agent registry, +# self-improvement) and will be removed when those families go keyless. VECTOR_SPEND_LIMIT_PER_TX=100000000 # 100 ADA per transaction VECTOR_SPEND_LIMIT_DAILY=500000000 # 500 ADA daily limit diff --git a/README.md b/README.md index a79dba1..e53acbc 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Built on [Ogmios](https://ogmios.dev/) + [Koios](https://www.koios.rest/) - no B ## Hosted servers - no install -Hosted instances run on both networks, exposing all 25 tools: +Hosted instances run on both networks, exposing all 23 tools: | Network | Endpoint | |---------|----------| @@ -21,13 +21,15 @@ Connect from Claude Code in one command: claude mcp add --transport sse vector-mcp https://mcp.vector.mainnet.apexfusion.org/sse ``` -> **Security notice — custody.** Signing tools currently take your wallet mnemonic as a -> tool-call parameter. It therefore passes through your MCP client, your model provider, -> and the hosted server's memory — it is not written to disk, but it is exposed. Use only -> a hot wallet holding funds you can afford to lose, and prefer self-hosting until the -> non-custodial migration lands. `vector_submit_transaction` and `vector_await_transaction` -> are the exception — they take no mnemonic and sign nothing; they only broadcast a -> transaction signed elsewhere (for example by a local signer) and poll for confirmation. +> **Security notice — custody.** The wallet, transaction, and smart-contract tools are now +> **keyless**: `build_*` tools construct unsigned transactions from a wallet *address* and +> never accept a mnemonic. Sign the returned CBOR with the local signer companion and +> broadcast it with `vector_submit_transaction` — your seed phrase never leaves your machine. +> **The agent-registry and self-improvement tool families still take a mnemonic as a +> tool-call parameter** (their keyless migration is in progress — see the architecture doc). +> For those tools the mnemonic passes through your MCP client, your model provider, and the +> hosted server's memory: use only a hot wallet holding funds you can afford to lose, and +> prefer self-hosting until the migration completes. > See [docs/architecture/non-custodial-split.md](docs/architecture/non-custodial-split.md). Self-hosting instructions are below. @@ -49,34 +51,32 @@ it ships, the security notice above still applies. ## Features -- **Wallet management** - derive addresses from mnemonic, query balances and UTxOs -- **Transactions** - send AP3X and native tokens, build multi-output transactions, dry-run simulations +- **Wallet & queries** - balances, UTxOs, and transaction history for any address (no key material) +- **Keyless transaction building** - build unsigned AP3X/token/multi-output transactions and contract interactions; sign locally, broadcast via submit - **Smart contracts** - deploy Plutus/Aiken validators, lock and spend UTxOs at script addresses - **Agent registry** - register, discover, update, transfer, and deregister on-chain AI agent identities via soulbound NFTs - **Agent messaging** - send on-chain messages between agents via TX metadata - **Self-Improvement Module** - browse, submit, critique, and endorse improvement proposals (live on Vector mainnet) -- **Safety controls** - per-transaction and daily spend limits, persistent audit log, rate limiting +- **Safety controls** - per-identity rate limiting; spend limits for the keyless family are enforced by the local signer (registry/self-improvement families keep server-side limits until their keyless migration) - **SSE transport** - HTTP server with Server-Sent Events for MCP client connectivity -## MCP Tools (25) +## MCP Tools (23) ### Wallet & Queries | Tool | Description | |------|-------------| | `vector_get_balance` | Get AP3X and token balances for any address | -| `vector_get_address` | Get the wallet address, balance, and holdings from a mnemonic | -| `vector_get_utxos` | List UTxOs for an address or wallet | -| `vector_get_spend_limits` | Check spend limits, daily usage, and audit log | -| `vector_get_transaction_history` | Get transaction history for a wallet | +| `vector_get_utxos` | List UTxOs for an address | +| `vector_get_transaction_history` | Get transaction history for an address | ### Transactions | Tool | Description | |------|-------------| -| `vector_send_apex` | Send AP3X (respects spend limits) | -| `vector_send_tokens` | Send native tokens with optional AP3X | -| `vector_build_transaction` | Build multi-output transactions (sign+submit or return unsigned CBOR) | +| `vector_build_send_apex` | Build an unsigned AP3X payment (keyless — sign with the local signer) | +| `vector_build_send_tokens` | Build an unsigned native-token transfer (keyless) | +| `vector_build_transaction` | Build an unsigned multi-output transaction (keyless, never submits) | | `vector_dry_run` | Simulate a transaction without submitting - estimate fees and validate | | `vector_submit_transaction` | Broadcast an already-signed transaction (for example one signed by the local signer) | | `vector_await_transaction` | Wait for a submitted transaction to be confirmed on-chain | @@ -85,8 +85,8 @@ it ships, the security notice above still applies. | Tool | Description | |------|-------------| -| `vector_deploy_contract` | Deploy a Plutus V1/V2/V3 or Aiken validator to the chain | -| `vector_interact_contract` | Lock AP3X at a script address or spend from it with a redeemer | +| `vector_build_deploy_contract` | Build an unsigned deployment of a Plutus V1/V2/V3 or Aiken validator (keyless) | +| `vector_build_interact_contract` | Build an unsigned lock or spend at a script address, with a redeemer (keyless) | ### Agent Registry @@ -128,8 +128,9 @@ cp .env.example .env # Edit .env with your endpoint URLs (defaults point to Vector testnet; mainnet URLs below) ``` -The mnemonic is passed per-call by the MCP client rather than stored in the environment. This -avoids environment dumps, but does not make it private — see the security notice above. +The wallet/tx family is keyless: `build_*` tools take a wallet address, never a mnemonic. +The registry and self-improvement families still take a per-call mnemonic until their +keyless migration lands — see the security notice above. ### 3. Run @@ -228,7 +229,7 @@ echo "your mnemonic words here" > packages/builder/mnemonic.txt npm run test:integration ``` -Requires `mnemonic.txt` in `packages/builder/` containing a **funded Vector testnet** mnemonic. Covers the core tools end-to-end against Vector testnet, including the full agent lifecycle: register, discover, profile, update, transfer, message, and deregister. Never runs in CI. +Requires `mnemonic.txt` in `packages/builder/` containing a **funded Vector testnet** mnemonic. Covers the core tools end-to-end against Vector testnet, including the full agent lifecycle: register, discover, profile, update, transfer, message, and deregister. Also runs `keyless-build.test.ts`, which needs no mnemonic at all - it builds every `build_*` tool's unsigned transaction against a live public address only. Set `VECTOR_E2E_SUBMIT=1` to additionally run the full non-custodial pipeline end-to-end: build → local sign → submit → await, landing one real self-send (~0.16 AP3X fee) on-chain. Never runs in CI. ```bash npm run test:smoke:signer diff --git a/docs/VECTOR.md b/docs/VECTOR.md index a68539d..7d91c14 100644 --- a/docs/VECTOR.md +++ b/docs/VECTOR.md @@ -6,7 +6,7 @@ The Vector MCP Server exposes blockchain tools that AI agents can use to interac ## Prerequisites -1. A funded Vector wallet (15 or 24-word mnemonic) +1. A Vector wallet address (source of funds) — the tools below are keyless and take no mnemonic; signing happens separately via your local signer 2. Access to Vector network endpoints (Ogmios, submit API, Koios) 3. Node.js >= 14.0.0 @@ -21,111 +21,100 @@ Get ADA and token balances for any Vector address. **Example prompt:** "What's the balance of addr1qx..." -### vector_get_address - -Get the agent's own wallet address, balance, and token holdings. - -**Parameters:** None - -**Example prompt:** "What's my wallet address and balance?" - ### vector_get_utxos -List unspent transaction outputs for an address or the agent's wallet. +List unspent transaction outputs for an address. **Parameters:** -- `address` (string, optional) — Address to query. Uses agent's wallet if omitted. +- `address` (string, required) — Address to query (addr1...) -**Example prompt:** "Show me the UTxOs in my wallet" +**Example prompt:** "Show me the UTxOs at addr1qx..." -### vector_send_apex +### vector_build_send_apex -Send ADA from the agent's wallet to a recipient. Enforces spend limits. +Build an unsigned transaction sending AP3X to a recipient (keyless — takes no key material). Sign the returned CBOR with your local signer, then broadcast with `vector_submit_transaction`. **Parameters:** +- `changeAddress` (string, required) — Your wallet address; source of funds and change (addr1...) - `recipientAddress` (string, required) — Recipient address (addr1...) -- `amount` (number, required) — Amount of ADA to send (minimum 1) +- `amount` (number, required) — Amount of AP3X to send (minimum 1) - `metadata` (string, optional) — Transaction metadata in JSON format -**Example prompt:** "Send 5 ADA to addr1qy..." +**Example prompt:** "Send 5 AP3X to addr1qy..." -### vector_send_tokens +### vector_build_send_tokens -Send Vector native tokens from the agent's wallet. +Build an unsigned transaction sending Vector native tokens (keyless — takes no key material). Sign the returned CBOR with your local signer, then broadcast with `vector_submit_transaction`. **Parameters:** +- `changeAddress` (string, required) — Your wallet address; source of funds and change (addr1...) - `recipientAddress` (string, required) — Recipient address (addr1...) - `policyId` (string, required) — Token policy ID - `assetName` (string, required) — Asset name (can be empty) - `amount` (string, required) — Amount of tokens to send -- `adaAmount` (number, optional) — ADA to include with tokens +- `adaAmount` (number, optional) — AP3X to include with tokens **Example prompt:** "Send 100 of token with policy abc123... to addr1qy..." -### vector_get_spend_limits - -Check current spend limits, daily usage, and recent transaction audit log. - -**Parameters:** None - -**Example prompt:** "What are my current spend limits?" - ### vector_build_transaction -Build a complex multi-output transaction with metadata. Optionally sign and submit. +Build an unsigned multi-output transaction with metadata (keyless, never submits). Sign the returned CBOR with your local signer, then broadcast with `vector_submit_transaction`. **Parameters:** +- `changeAddress` (string, required) — Your wallet address; source of funds and change (addr1...) - `outputs` (array, required) — Array of `{ address, lovelace, assets? }` objects - `metadata` (string, optional) — JSON metadata (attached under label 674) -- `submit` (boolean, optional) — If true, sign and submit. If false/omitted, return unsigned CBOR. -**Example prompt:** "Build a transaction sending 5 ADA to addr1qx... and 3 ADA to addr1qy..." +**Example prompt:** "Build a transaction sending 5 AP3X to addr1qx... and 3 AP3X to addr1qy..." ### vector_dry_run -Simulate a transaction without submitting — returns fee estimate and validation. +Simulate a transaction without submitting — returns fee estimate and validation (keyless). **Parameters:** - `txCbor` (string, optional) — Hex CBOR of a built transaction to evaluate - `outputs` (array, optional) — If no txCbor, build a TX from these outputs and evaluate +- `changeAddress` (string, optional) — Your wallet address; required when building from outputs - `metadata` (string, optional) — JSON metadata when building from outputs -**Example prompt:** "How much would it cost to send 10 ADA to addr1qx...?" +**Example prompt:** "How much would it cost to send 10 AP3X to addr1qx...?" ### vector_get_transaction_history Get transaction history for an address via Koios indexed queries. **Parameters:** -- `address` (string, optional) — Address to query. Uses agent's wallet if omitted. +- `address` (string, required) — Address to query (addr1...) - `limit` (number, optional) — Number of transactions (default: 20, max: 50) - `offset` (number, optional) — Pagination offset (default: 0) -**Example prompt:** "Show me my recent transactions" +**Example prompt:** "Show me recent transactions for addr1qx..." -### vector_deploy_contract +### vector_build_deploy_contract -Deploy a Plutus/Aiken smart contract by locking funds at its script address. +Build an unsigned transaction deploying a Plutus/Aiken smart contract by locking funds at its script address (keyless). Sign the returned CBOR with your local signer, then broadcast with `vector_submit_transaction`. **Parameters:** +- `changeAddress` (string, required) — Your wallet address; source of funds and change (addr1...) - `scriptCbor` (string, required) — Compiled script CBOR hex - `scriptType` (string, required) — "PlutusV1", "PlutusV2", or "PlutusV3" - `initialDatum` (string, optional) — Datum CBOR hex (default: void `d87980`) -- `lovelaceAmount` (number, optional) — ADA to lock in lovelace (default: 2 ADA) +- `lovelaceAmount` (number, optional) — AP3X to lock in lovelace (default: 2 AP3X) -**Example prompt:** "Deploy this smart contract with 5 ADA locked" +**Example prompt:** "Deploy this smart contract with 5 AP3X locked" -### vector_interact_contract +### vector_build_interact_contract -Interact with a deployed smart contract — lock funds or spend from it. +Build an unsigned transaction interacting with a deployed smart contract — lock funds or spend from it (keyless). Sign the returned CBOR with your local signer, then broadcast with `vector_submit_transaction`. **Parameters:** +- `changeAddress` (string, required) — Your wallet address; source of funds, change, and the required signer for spends (addr1...) - `scriptCbor` (string, required) — Compiled script CBOR hex - `scriptType` (string, required) — "PlutusV1", "PlutusV2", or "PlutusV3" - `action` (string, required) — "spend" or "lock" - `redeemer` (string, optional) — Redeemer CBOR hex (for spend, default: void) - `datum` (string, optional) — Datum CBOR hex (for lock, default: void) -- `lovelaceAmount` (number, optional) — ADA to lock in lovelace (for lock) +- `lovelaceAmount` (number, optional) — Lovelace to lock (for lock) - `utxoRef` (object, optional) — `{ txHash, outputIndex }` for specific UTxO (for spend) - `assets` (object, optional) — Native assets for lock: `{ "unit": "quantity" }` @@ -133,19 +122,14 @@ Interact with a deployed smart contract — lock funds or spend from it. ## Safety Controls -The server enforces configurable spend limits: +The tools above are keyless and take no key material, so the server no longer enforces +spend limits on them. Spend policy is enforced by your **local signer** when it signs the +CBOR these tools return — see the security notice in the repo [README](../README.md) and +[docs/architecture/non-custodial-split.md](architecture/non-custodial-split.md). -- **Per-transaction limit** — Maximum ADA per single transaction (default: 100 ADA) -- **Daily limit** — Maximum ADA per 24-hour period (default: 500 ADA) -- **Audit log** — All transactions are logged with timestamp, amount, and recipient -- **Persistent audit log** — Audit log is written to a JSON file that survives server restarts. Daily spend is recalculated from the log on startup. - -Limits are configured via environment variables: -``` -VECTOR_SPEND_LIMIT_PER_TX=100000000 # 100 ADA in lovelace -VECTOR_SPEND_LIMIT_DAILY=500000000 # 500 ADA in lovelace -VECTOR_AUDIT_LOG_PATH=./vector-audit-log.json # Audit log file path -``` +`VECTOR_SPEND_LIMIT_PER_TX` / `VECTOR_SPEND_LIMIT_DAILY` / `VECTOR_AUDIT_LOG_PATH` still +govern the server's remaining custodial tool families (agent registry, self-improvement) +until their own keyless migration lands. ## Network Endpoints @@ -160,8 +144,6 @@ VECTOR_AUDIT_LOG_PATH=./vector-audit-log.json # Audit log file path All tools return helpful error messages with troubleshooting tips. Common issues: -- **Invalid mnemonic** — Check VECTOR_MNEMONIC has exactly 15 or 24 words -- **Insufficient balance** — Ensure wallet has enough ADA for the transaction + fees -- **Spend limit exceeded** — Check limits with `vector_get_spend_limits` +- **Insufficient balance** — Ensure the wallet at `changeAddress` has enough AP3X for the transaction + fees - **Connection failed** — Verify Ogmios endpoint is reachable - **Invalid address** — Vector addresses start with `addr1` diff --git a/docs/architecture/non-custodial-split.md b/docs/architecture/non-custodial-split.md index 95658ba..491e997 100644 --- a/docs/architecture/non-custodial-split.md +++ b/docs/architecture/non-custodial-split.md @@ -188,7 +188,7 @@ TDD throughout: failing test before implementation, every PR. | **3** | Workspace scaffolding — pure `git mv`, zero logic change, validated by PR 1–2 CI | no | | **4** | **Signer package** (purely additive): `KeySource`, decode, `PolicyEngine`, audit, 4 tools. Heavy TDD | no | | **5** | Builder hardening: auth (enforced when configured), per-identity rate limits, `submit_transaction`, `await_transaction` | no | -| **6** | **Family 1 — wallet/tx keyless.** Mnemonics removed; `build_*` tools land | **yes** | +| **6** | **Family 1 — wallet/tx keyless.** Mnemonics removed; `build_*` tools land ✅ **(landed — this PR)** | **yes** | | **7** | **Family 2 — agent registry keyless.** Drops `@ts-nocheck` from `agent-network.ts` | yes | | **8** | **Family 3 — governance keyless.** Plus env-driven ref-UTxO config and a CBOR encoding fix | yes | | **9** | Mainnet cutover: README, security-page correction, deliberate `workflow_dispatch` | — | From 66f6b60dbd76a80012ccb31c91bf6156c633cf79 Mon Sep 17 00:00:00 2001 From: FilipBl4gojevic <84703395+FilipBl4gojevic@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:37:59 +0200 Subject: [PATCH 13/14] docs: signer is in-tree - align custody claims after #7/#8 merged #7 (signer) and #8 (builder hardening) merged into main this morning; this branch is rebased onto the result and packages/signer is now in-tree. Three sites still said pairing the signer with the hosted builder "does not yet remove the custody exposure" (true when #7 was written, before this PR's keyless build_* tools existed - now false for the wallet/tx/contract family): - README.md's "Local signer" section: now states the 4-call non-custodial flow (signer_get_address -> build_* -> signer_sign -> submit -> await) is live today for that family, names the signer's four tools, and keeps the agent-registry/self-improvement families explicitly scoped out. - packages/signer/README.md's "Known limitations" bullet and "Running it" closing line: same correction, with the specific now-keyless build_* tool names and an accurate (not overclaiming) description of what the E2E test exercises - it signs with the same CML primitives sign.ts uses, as a stand-in for calling vector_signer_sign directly, not a call into the signer package's own code. Grepped case-insensitively across the repo for "does not yet remove" / "custody exposure" / "signer half is ready" / "builder half is not yet" before and after: exactly these 3 sites found, all corrected, zero stale instances remain. --- README.md | 23 +++++++++++++++-------- packages/signer/README.md | 38 +++++++++++++++++++++++++------------- 2 files changed, 40 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index e53acbc..49af92e 100644 --- a/README.md +++ b/README.md @@ -39,15 +39,22 @@ Self-hosting instructions are below. `packages/signer` is a local MCP server that holds your key and signs transactions locally instead of handing your mnemonic to a shared host. It has **no network access at all** — stdio transport, no Provider, no egress — so a key given to it never reaches a shared server -or your model provider. +or your model provider. Four tools: `vector_signer_get_address`, `vector_signer_decode_transaction`, +`vector_signer_sign`, `vector_signer_get_spend_limits`. -See [`packages/signer/README.md`](packages/signer/README.md) for configuration, its tools, and -known limitations — including the gap that matters most right now: the hosted builder's tools -above still require your mnemonic as a call parameter, so pairing the signer with *this* -hosted builder does not yet remove the custody exposure described above. Closing that gap is -the next stage of the migration described in -[`docs/architecture/non-custodial-split.md`](docs/architecture/non-custodial-split.md); until -it ships, the security notice above still applies. +**Paired with this hosted builder, the full non-custodial flow is live today** for the wallet, +transaction, and smart-contract family: `vector_signer_get_address` → `build_*` → +`vector_signer_sign` → `vector_submit_transaction` → `vector_await_transaction`. No step in that +chain puts a mnemonic in front of this server or your model provider. The build → submit → await +half is E2E-proven on Vector testnet, landing a real signed transaction on-chain; see +[`packages/signer/README.md`](packages/signer/README.md#known-limitations) for exactly what that +test does and does not exercise on the signer's side. **The agent-registry and self-improvement +families are not part of that pairing yet** — they still take a mnemonic as a tool-call +parameter until their own keyless migration lands (see the security notice above and +[docs/architecture/non-custodial-split.md](docs/architecture/non-custodial-split.md)). + +See [`packages/signer/README.md`](packages/signer/README.md) for configuration, tools, and known +limitations. ## Features diff --git a/packages/signer/README.md b/packages/signer/README.md index 7dd30d5..4dacad8 100644 --- a/packages/signer/README.md +++ b/packages/signer/README.md @@ -144,17 +144,28 @@ over-counts, which refuses too much rather than too little. - **Prompt injection is bounded, not eliminated.** Policy refuses out-of-limit transactions, but an injected agent can still cause any transaction *within* policy. Recipient allowlisting is the next lever. -- **Pairing this signer with the hosted builder does not yet remove the custody exposure it - is designed to fix.** The signer's own four tools are complete and independently verified - end to end against live chain data (`test/integration/roundtrip.test.ts`). What is not yet - true is a wired-up keyless round trip through the hosted *builder's* actual tools: today - every builder tool that builds or sends a transaction (`vector_send_apex`, - `vector_build_transaction`, the registry and governance tools, ...) still requires your - mnemonic as a call parameter, and no `submit_transaction` tool exists yet to submit a - transaction this signer produced. That migration is tracked separately in +- **Pairing this signer with the hosted builder now removes the custody exposure it was + designed to fix — for the wallet, transaction, and smart-contract family.** The signer's own + four tools are complete and independently verified end to end against live chain data + (`test/integration/roundtrip.test.ts`), and the hosted *builder's* tools in that family are + keyless: `vector_build_send_apex`, `vector_build_send_tokens`, `vector_build_transaction`, + `vector_build_deploy_contract`, and `vector_build_interact_contract` all take a wallet + *address*, never a mnemonic, and `vector_submit_transaction` / `vector_await_transaction` + complete the round trip. The pipeline — hosted keyless build → sign → submit → await — is + E2E-proven on Vector testnet (`packages/builder/test/integration/keyless-e2e.test.ts`): a + builder-produced unsigned transaction is signed with the same CML primitives `sign.ts` uses + (hash the body, produce a vkey witness, attach it — explicitly a stand-in for calling + `vector_signer_sign` directly, not a call to this package's own code), then submitted and + confirmed on real Vector testnet blocks. This signer's own tool surface, policy engine, and + audit log are exercised separately, by this package's own suite + (`test/integration/roundtrip.test.ts` and the smoke tests above). **What is still open: the + agent-registry and self-improvement tool families.** Their tools still require your mnemonic + as a call + parameter, so pairing this signer with the builder does not yet cover them. That migration is + tracked separately in [`docs/architecture/non-custodial-split.md`](../../docs/architecture/non-custodial-split.md) - (PRs 6-8) and has not shipped. Until it does, the root README's custody notice still - describes the hosted builder accurately. + and has not shipped. Until it does, the root README's custody notice still describes those two + families accurately. ## Running it @@ -180,9 +191,10 @@ example): } ``` -Register it alongside the hosted builder so an agent has both sets of tools available. See the -last bullet under Known limitations above before expecting that pairing to keep your mnemonic -off the hosted server today — the signer half is ready; the builder half is not yet. +Register it alongside the hosted builder so an agent has both sets of tools available. For the +wallet, transaction, and smart-contract family, this pairing keeps your mnemonic off the hosted +server today — both halves are ready. See the last bullet under Known limitations above for +what's still open: the agent-registry and self-improvement families. ## Testing From d822a47177b7ba1856c9d693b94e624e8434f0da Mon Sep 17 00:00:00 2001 From: FilipBl4gojevic <84703395+FilipBl4gojevic@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:02:48 +0200 Subject: [PATCH 14/14] docs: scope the safety-layer diagram and spend-limit copy to the custodial families Four items from the final whole-branch review, all docs-only: 1. Architecture diagram: the ASCII diagram routed every tool call through Rate Limiter -> Safety Layer -> Lucid/Ogmios, which is false for the keyless build_* path (safety.ts is never consulted there). Annotated the Safety Layer box with a footnote marker instead of redrawing the diagram - box alignment verified programmatically before and after (same column positions for every vertical bar). 2. Configuration table: added one sentence stating VECTOR_SPEND_LIMIT_PER_TX/ DAILY govern only the still-custodial families, matching .env.example's existing scope note. 3. Testing section: "builds every build_* tool's unsigned transaction" overclaimed - keyless-build.test.ts covers 4 of 5 (verified directly against the test source: vector_build_send_tokens is absent from it, covered instead by unit tests and the legacy integration suite). Reworded to name the gap explicitly. 4. packages/builder/test/integration/keyless-e2e.test.ts:9 (comment only): "which lives on PR #7's branch" was stale now that #7 is merged and packages/signer is in-tree. Reworded to name the in-tree package and be precise about what the stand-in does and doesn't exercise (same CML primitives, without decode/policy/audit). --- README.md | 11 +++++++++-- packages/builder/test/integration/keyless-e2e.test.ts | 5 +++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 49af92e..4c636d4 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,10 @@ If this instance will be reachable by anyone but you, set `MCP_AUTH_TOKENS` firs | `VECTOR_RATE_LIMIT_PER_MINUTE` | Max tool calls per minute | `60` | | `MCP_AUTH_TOKENS` | Bearer tokens that may call this server. Comma-separated; each entry is `label:token` or a bare token. **When unset, the server is open to anyone who can reach it.** | _(unset — auth disabled)_ | +`VECTOR_SPEND_LIMIT_PER_TX` / `VECTOR_SPEND_LIMIT_DAILY` govern only the still-custodial +families (agent registry, self-improvement) — the keyless `build_*` family ignores them; its +spend limits are enforced by the local signer instead. + > **Running a public instance?** Set `MCP_AUTH_TOKENS`. Without it every caller > is treated as one anonymous identity and shares a single rate-limit bucket, so > one busy client throttles everyone. Rate limits are applied per identity, so @@ -236,7 +240,7 @@ echo "your mnemonic words here" > packages/builder/mnemonic.txt npm run test:integration ``` -Requires `mnemonic.txt` in `packages/builder/` containing a **funded Vector testnet** mnemonic. Covers the core tools end-to-end against Vector testnet, including the full agent lifecycle: register, discover, profile, update, transfer, message, and deregister. Also runs `keyless-build.test.ts`, which needs no mnemonic at all - it builds every `build_*` tool's unsigned transaction against a live public address only. Set `VECTOR_E2E_SUBMIT=1` to additionally run the full non-custodial pipeline end-to-end: build → local sign → submit → await, landing one real self-send (~0.16 AP3X fee) on-chain. Never runs in CI. +Requires `mnemonic.txt` in `packages/builder/` containing a **funded Vector testnet** mnemonic. Covers the core tools end-to-end against Vector testnet, including the full agent lifecycle: register, discover, profile, update, transfer, message, and deregister. Also runs `keyless-build.test.ts`, which needs no mnemonic at all - it builds unsigned transactions with four of the five `build_*` tools against a live public address only (`vector_build_send_tokens` is covered offline, by unit tests and the legacy suite above, not by this live suite). Set `VECTOR_E2E_SUBMIT=1` to additionally run the full non-custodial pipeline end-to-end: build → local sign → submit → await, landing one real self-send (~0.16 AP3X fee) on-chain. Never runs in CI. ```bash npm run test:smoke:signer @@ -268,7 +272,7 @@ against live chain data. **Never submits.** Never runs in CI. See │ └────────┬───────────┘ │ │ │ │ │ ┌────────▼───────────┐ │ - │ │ Safety Layer │ │ + │ │ Safety Layer * │ │ │ │ - Per-tx limits │ │ │ │ - Daily limits │ │ │ │ - Audit log │ │ @@ -286,6 +290,9 @@ against live chain data. **Never submits.** Never runs in CI. See └──────────────────────────┘ ``` +\* Custodial families only (agent registry, self-improvement) — the keyless `build_*` path +never reaches the Safety Layer; its spend limits are enforced by the local signer instead. + ## About Vector Vector is Apex Fusion's eUTXO L2, running Cardano mainnet parameters (Conway era, Plutus V3). Sub-1-second optimistic finality and deterministic fees make it a natural chain for AI agent workloads. Mainnet is live. diff --git a/packages/builder/test/integration/keyless-e2e.test.ts b/packages/builder/test/integration/keyless-e2e.test.ts index 265cfdc..5d4d34c 100644 --- a/packages/builder/test/integration/keyless-e2e.test.ts +++ b/packages/builder/test/integration/keyless-e2e.test.ts @@ -6,8 +6,9 @@ import { startServer, stopServer, callTool, getMnemonic, ServerContext } from '. // Full non-custodial pipeline against the live testnet: // keyless build (hosted tool) -> inline CML sign (this process, standing in -// for the local signer, which lives on PR #7's branch) -> submit tool -> -// await tool. A self-send, so the run costs only the fee (~0.16 AP3X). +// for the in-tree local signer (packages/signer) — same CML primitives, +// without the signer's decode/policy/audit path) -> submit tool -> await +// tool. A self-send, so the run costs only the fee (~0.16 AP3X). // // Gated: set VECTOR_E2E_SUBMIT=1 to run. Requires packages/builder/mnemonic.txt // (the funded testnet wallet). NEVER runs in CI.