Merge pull request #9 from FilipBl4gojevic/feat/wallet-tx-keyless

Wallet/tx family keyless: build_* tools, no mnemonic parameters (non-custodial split, family 1)
This commit is contained in:
dcejtlin-apex
2026-07-31 12:39:29 +02:00
committed by GitHub
27 changed files with 3015 additions and 1921 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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`

View File

@@ -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` | — |

View File

@@ -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();
}

View File

@@ -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<string, string | undefined> = process.env): AuthConfig {
const raw = (env.MCP_AUTH_TOKENS ?? '').trim();
if (raw === '') return { enabled: false, identities: new Map() };

View File

@@ -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`,
}],
};
}

View File

@@ -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<LucidEvolution> {
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<VectorUnsignedBuildResult> {
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<VectorUnsignedBuildResult> {
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<VectorUnsignedBuildResult> {
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<string, bigint> = { lovelace: BigInt(output.lovelace) };
if (output.assets) {
for (const [unit, qty] of Object.entries(output.assets)) assets[unit] = BigInt(qty);
}
tx = tx.pay.ToAddress(output.address, assets);
}
if (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<VectorBuildDeployResult> {
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<string, string>;
},
): Promise<VectorBuildInteractResult> {
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<string, bigint> = { 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'`);
}
}

View File

@@ -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';

View File

@@ -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`,
}],
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -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`);
}
});
});

View File

@@ -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/,
);
});
});

View File

@@ -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<string, bigint> }> {
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<string, bigint> = {};
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/,
);
});
});

View File

@@ -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');
});
});

View File

@@ -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}`);

View File

@@ -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<ProtocolParameters> {
return fixtureProtocolParameters();
}
async getUtxos(addressOrCredential: Address | Credential): Promise<UTxO[]> {
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<OutRef>): Promise<UTxO[]> {
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<UTxO[]> {
throw new Error('FixtureProvider: offline — getUtxosWithUnit unavailable');
}
async getUtxoByUnit(_u: Unit): Promise<UTxO> {
throw new Error('FixtureProvider: offline — getUtxoByUnit unavailable');
}
async getDelegation(_r: RewardAddress): Promise<Delegation> {
throw new Error('FixtureProvider: offline — getDelegation unavailable');
}
async getDatum(_d: DatumHash): Promise<Datum> {
throw new Error('FixtureProvider: offline — getDatum unavailable');
}
awaitTx(_tx: TxHash, _interval?: number): Promise<boolean> {
throw new Error('FixtureProvider: offline — awaitTx unavailable');
}
async submitTx(_tx: Transaction): Promise<TxHash> {
throw new Error('FixtureProvider: offline — submitTx unavailable');
}
async evaluateTx(_tx: Transaction, _additional?: UTxO[]): Promise<any> {
throw new Error('FixtureProvider: offline — evaluateTx unavailable');
}
}

View File

@@ -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"
}
}

View File

@@ -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|(?<!Not )Submitted\b/);
assert.match(text, /Unsigned|NOT been submitted|Not Submitted/i);
}
let ctx: ServerContext;
before(async () => { 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);
});
});

View File

@@ -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/);
});
});

View File

@@ -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");
});

View File

@@ -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)');
});
});

View File

@@ -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<string, unknown> = JSON.parse(readFileSync(SNAPSHOT_PATH, 'utf-8'));
const schemaSnapshot: Record<string, { description?: string; inputSchema: unknown }> =
JSON.parse(readFileSync(SNAPSHOT_PATH, 'utf-8'));
let ctx: ServerContext;
let actualNames: string[];
let actualToolsByName: Map<string, { name: string; inputSchema: unknown }>;
let actualToolsByName: Map<string, { name: string; description?: string; inputSchema: unknown }>;
before(async () => {
ctx = await startServer();
@@ -82,11 +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.'
);
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -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"]
}

View File

@@ -1,43 +1,5 @@
// Vector-specific type definitions
export interface VectorToken {
unit: string;
name: string;
quantity: string;
}
export interface VectorWalletInfo {
address: string;
utxoCount: number;
ada: string;
tokens: VectorToken[];
}
export interface VectorAdaTransactionResult {
txHash: string;
senderAddress: string;
recipientAddress: string;
amount: number;
links: {
explorer: string;
};
}
export interface VectorTokenTransactionResult {
txHash: string;
senderAddress: string;
recipientAddress: string;
token: {
policyId: string;
name: string;
amount: string;
};
ada: string;
links: {
explorer: string;
};
}
export interface SpendLimits {
perTransaction: number; // lovelace
daily: number; // lovelace
@@ -67,19 +29,6 @@ export interface TxOutput {
assets?: Record<string, string>; // unit -> quantity
}
export interface VectorBuildTransactionResult {
txCbor: string;
txHash: string;
fee: string;
feeAda: string;
outputCount: number;
totalAda: string;
submitted: boolean;
links?: {
explorer: string;
};
}
// --- Dry Run ---
export interface VectorDryRunResult {
@@ -108,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 ---

View File

@@ -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