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..4c636d4 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. @@ -37,46 +39,51 @@ 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 -- **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 +92,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 +135,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 @@ -188,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 @@ -228,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. 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 @@ -260,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 │ │ @@ -278,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/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` | — | diff --git a/packages/builder/scripts/regen-tool-schemas.mjs b/packages/builder/scripts/regen-tool-schemas.mjs new file mode 100644 index 0000000..5820fea --- /dev/null +++ b/packages/builder/scripts/regen-tool-schemas.mjs @@ -0,0 +1,74 @@ +// packages/builder/scripts/regen-tool-schemas.mjs +// Boots the BUILT server, lists tools over SSE, rewrites the smoke snapshot +// 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 +// 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 { 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', [SERVER_ENTRY], { + 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( + SNAPSHOT_PATH, + 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/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/src/vector/agent-network.ts b/packages/builder/src/vector/agent-network.ts index 4157e59..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 }) @@ -357,8 +357,8 @@ 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 -3. Each wallet can register multiple agents (different seed UTxOs) +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 one-shot 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/build.ts b/packages/builder/src/vector/build.ts new file mode 100644 index 0000000..0446096 --- /dev/null +++ b/packages/builder/src/vector/build.ts @@ -0,0 +1,196 @@ +// 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()); +} + +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') { + // '' 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); + } + 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'`); + } +} 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/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 9cceda7..12ba578 100644 --- a/packages/builder/src/vector/vector.ts +++ b/packages/builder/src/vector/vector.ts @@ -1,7 +1,5 @@ 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 * as dotenv from 'dotenv'; import { resolve } from 'path'; @@ -12,16 +10,15 @@ 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, VectorAdaTransactionResult, VectorTokenTransactionResult, - TxOutput, VectorBuildTransactionResult, VectorDryRunResult, VectorDeployContractResult, - VectorInteractContractResult, -} from '@apexfusion/vector-mcp-shared/types'; +import type { 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); @@ -37,440 +34,13 @@ if (existsSync(envPath)) { } } -// Initialize Lucid instance with Ogmios provider -async function initLucid(mnemonic: string, accountIndex: number = 0) { - const provider = new OgmiosProvider({ +// 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, }); - - // 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, - }; -} - -// 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) }, - }; - } } // Register all Vector MCP tools @@ -550,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}`, }], }; } @@ -648,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) @@ -667,66 +172,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 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...)"), 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 +213,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 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...)"), - 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) + 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: `# Unsigned Token Transaction Built -**Token:** ${formatAssetName(assetNameHex) || policyId.substring(0,8) + '...'} +**Token:** ${displayName} **Amount:** ${amount} **To:** ${recipientAddress} -**Included AP3X:** ${lovelaceToAda(outputLovelace)} AP3X -**Estimated Fee:** ${lovelaceToAda(fee)} AP3X +**Estimated Fee:** ${result.feeAda} AP3X +**Tx Hash:** ${result.txHash} **Unsigned TX CBOR:** \`\`\` -${txCbor} +${result.txCbor} \`\`\` -This transaction has NOT been submitted. Sign and submit it separately.`, - }], - }; - } - - const result = await sendTokens(recipientAddress, policyId, assetName, amount, mnemonic, adaAmount); - return { - content: [{ - type: "text", - text: `# Token Transaction Successful - -Transaction Hash: ${result.txHash} -From: ${result.senderAddress} -To: ${result.recipientAddress} - -Token Details: -- Policy ID: ${result.token.policyId} -- Asset Name: ${result.token.name || '(none)'} -- Amount: ${result.token.amount} - -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,114 +270,56 @@ 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...)`, }], }; } } ); - // 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 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 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({ 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, broadcast with vector_submit_transaction, then confirm with vector_await_transaction { txHash }.`, }], }; } catch (err) { @@ -947,8 +331,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`, }], }; } @@ -958,18 +341,18 @@ Use vector_dry_run with this CBOR to simulate, or call vector_build_transaction // 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.` }] }; @@ -979,42 +362,20 @@ Use vector_dry_run with this CBOR to simulate, or call vector_build_transaction 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 { @@ -1040,7 +401,6 @@ Use vector_dry_run with this CBOR to simulate, or call vector_build_transaction 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, @@ -1182,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}`, }], }; } @@ -1225,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) { @@ -1251,44 +599,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 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"), 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 confirm with vector_await_transaction { txHash }. Once confirmed, use vector_build_interact_contract to interact with the deployed contract.`, }], }; } catch (err) { @@ -1296,28 +642,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 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"), 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 +672,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 }, then confirm with vector_await_transaction { txHash }.`, }], }; } catch (err) { @@ -1366,14 +706,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/boundary.test.ts b/packages/builder/test/boundary.test.ts new file mode 100644 index 0000000..d8d30de --- /dev/null +++ b/packages/builder/test/boundary.test.ts @@ -0,0 +1,89 @@ +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 +]); +// 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[] = []; + 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`, + ); + } + }); + + // 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/build-contracts.test.ts b/packages/builder/test/build-contracts.test.ts new file mode 100644 index 0000000..e41a579 --- /dev/null +++ b/packages/builder/test/build-contracts.test.ts @@ -0,0 +1,175 @@ +// packages/builder/test/build-contracts.test.ts +import { describe, test, before } from 'node:test'; +import assert from 'node:assert/strict'; +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'; + +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/); + }); + + 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', () => { + 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); + 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'); + 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('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, + 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'); + // 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 () => { + await assert.rejects( + () => buildInteractContract(lucid, { + scriptCbor: ALWAYS_SUCCEEDS_V2, scriptType: 'PlutusV2', action: 'spend', + changeAddress: OWN_ADDRESS, redeemer: 'd87980', + }), + /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/, + ); + }); +}); 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/builder/test/fixture-provider.test.ts b/packages/builder/test/fixture-provider.test.ts new file mode 100644 index 0000000..95d1c61 --- /dev/null +++ b/packages/builder/test/fixture-provider.test.ts @@ -0,0 +1,40 @@ +// 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 './fixtures/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/capture-protocol-params.mjs b/packages/builder/test/fixtures/capture-protocol-params.mjs new file mode 100644 index 0000000..89ad1d3 --- /dev/null +++ b/packages/builder/test/fixtures/capture-protocol-params.mjs @@ -0,0 +1,55 @@ +// 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(); + +// 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'); +console.log(`wrote ${out}`); 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" + } +} 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..cc3c63a --- /dev/null +++ b/packages/builder/test/integration/keyless-build.test.ts @@ -0,0 +1,143 @@ +// 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; + // 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'); + 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(); + 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, + }); + assertBuildNotSubmitted(buildText); + 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..5d4d34c --- /dev/null +++ b/packages/builder/test/integration/keyless-e2e.test.ts @@ -0,0 +1,65 @@ +// 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 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. +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/rate-limit-gating.test.ts b/packages/builder/test/integration/rate-limit-gating.test.ts index fec0196..35e0d74 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 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 // 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/integration/run.test.ts b/packages/builder/test/integration/run.test.ts index f774494..89bb122 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) @@ -9,11 +10,14 @@ let ctx: ServerContext; let mnemonic: string; let walletAddress: string; let walletHasAda = false; -let walletBalanceAda = 0; 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 +52,27 @@ 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 +84,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 +98,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 +115,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)'); }); }); diff --git a/packages/builder/test/smoke/tool-inventory.test.ts b/packages/builder/test/smoke/tool-inventory.test.ts index ffcf808..78e5099 100644 --- a/packages/builder/test/smoke/tool-inventory.test.ts +++ b/packages/builder/test/smoke/tool-inventory.test.ts @@ -5,28 +5,33 @@ 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` + * 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 + * 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', + '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', - 'vector_get_address', 'vector_get_agent_profile', 'vector_get_balance', - '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,41 +39,46 @@ 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', ]; /** - * 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 +92,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 +127,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 +136,27 @@ 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().` ); + // 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( - 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 e6de772..fa1871c 100644 --- a/packages/builder/test/smoke/tool-schemas.snapshot.json +++ b/packages/builder/test/smoke/tool-schemas.snapshot.json @@ -1,813 +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": { + "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": { + "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": { + "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": { + "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": { + "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": { + "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": { + "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": { + "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": { - "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 and confirm with vector_await_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" } }, - "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" - ], - "$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_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": { - "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_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": { - "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/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"] } diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index a63baf5..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,27 +57,23 @@ export interface VectorTransactionHistoryResult { total: number; } -// --- Deploy Contract --- +// --- 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 VectorDeployContractResult { - txHash: string; - scriptAddress: string; - scriptHash: string; - scriptType: string; - links: { - explorer: string; - }; +export interface VectorUnsignedBuildResult { + txCbor: string; // hex CBOR of the UNSIGNED transaction + txHash: string; // body hash — stable across signing + fee: string; // lovelace + feeAda: string; } -// --- Interact Contract --- +export interface VectorBuildDeployResult extends VectorUnsignedBuildResult { + scriptAddress: string; scriptHash: string; scriptType: string; +} -export interface VectorInteractContractResult { - txHash: string; - scriptAddress: string; - action: 'spend' | 'lock'; - links: { - explorer: string; - }; +export interface VectorBuildInteractResult extends VectorUnsignedBuildResult { + scriptAddress: string; action: 'lock' | 'spend'; } // --- Agent Network --- 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