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:
@@ -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
|
||||
|
||||
|
||||
81
README.md
81
README.md
@@ -8,7 +8,7 @@ Built on [Ogmios](https://ogmios.dev/) + [Koios](https://www.koios.rest/) - no B
|
||||
|
||||
## Hosted servers - no install
|
||||
|
||||
Hosted instances run on both networks, exposing all 25 tools:
|
||||
Hosted instances run on both networks, exposing all 23 tools:
|
||||
|
||||
| Network | Endpoint |
|
||||
|---------|----------|
|
||||
@@ -21,13 +21,15 @@ Connect from Claude Code in one command:
|
||||
claude mcp add --transport sse vector-mcp https://mcp.vector.mainnet.apexfusion.org/sse
|
||||
```
|
||||
|
||||
> **Security notice — custody.** Signing tools currently take your wallet mnemonic as a
|
||||
> tool-call parameter. It therefore passes through your MCP client, your model provider,
|
||||
> and the hosted server's memory — it is not written to disk, but it is exposed. Use only
|
||||
> a hot wallet holding funds you can afford to lose, and prefer self-hosting until the
|
||||
> non-custodial migration lands. `vector_submit_transaction` and `vector_await_transaction`
|
||||
> are the exception — they take no mnemonic and sign nothing; they only broadcast a
|
||||
> transaction signed elsewhere (for example by a local signer) and poll for confirmation.
|
||||
> **Security notice — custody.** The wallet, transaction, and smart-contract tools are now
|
||||
> **keyless**: `build_*` tools construct unsigned transactions from a wallet *address* and
|
||||
> never accept a mnemonic. Sign the returned CBOR with the local signer companion and
|
||||
> broadcast it with `vector_submit_transaction` — your seed phrase never leaves your machine.
|
||||
> **The agent-registry and self-improvement tool families still take a mnemonic as a
|
||||
> tool-call parameter** (their keyless migration is in progress — see the architecture doc).
|
||||
> For those tools the mnemonic passes through your MCP client, your model provider, and the
|
||||
> hosted server's memory: use only a hot wallet holding funds you can afford to lose, and
|
||||
> prefer self-hosting until the migration completes.
|
||||
> See [docs/architecture/non-custodial-split.md](docs/architecture/non-custodial-split.md).
|
||||
|
||||
Self-hosting instructions are below.
|
||||
@@ -37,46 +39,51 @@ Self-hosting instructions are below.
|
||||
`packages/signer` is a local MCP server that holds your key and signs transactions locally
|
||||
instead of handing your mnemonic to a shared host. It has **no network access at all** —
|
||||
stdio transport, no Provider, no egress — so a key given to it never reaches a shared server
|
||||
or your model provider.
|
||||
or your model provider. Four tools: `vector_signer_get_address`, `vector_signer_decode_transaction`,
|
||||
`vector_signer_sign`, `vector_signer_get_spend_limits`.
|
||||
|
||||
See [`packages/signer/README.md`](packages/signer/README.md) for configuration, its tools, and
|
||||
known limitations — including the gap that matters most right now: the hosted builder's tools
|
||||
above still require your mnemonic as a call parameter, so pairing the signer with *this*
|
||||
hosted builder does not yet remove the custody exposure described above. Closing that gap is
|
||||
the next stage of the migration described in
|
||||
[`docs/architecture/non-custodial-split.md`](docs/architecture/non-custodial-split.md); until
|
||||
it ships, the security notice above still applies.
|
||||
**Paired with this hosted builder, the full non-custodial flow is live today** for the wallet,
|
||||
transaction, and smart-contract family: `vector_signer_get_address` → `build_*` →
|
||||
`vector_signer_sign` → `vector_submit_transaction` → `vector_await_transaction`. No step in that
|
||||
chain puts a mnemonic in front of this server or your model provider. The build → submit → await
|
||||
half is E2E-proven on Vector testnet, landing a real signed transaction on-chain; see
|
||||
[`packages/signer/README.md`](packages/signer/README.md#known-limitations) for exactly what that
|
||||
test does and does not exercise on the signer's side. **The agent-registry and self-improvement
|
||||
families are not part of that pairing yet** — they still take a mnemonic as a tool-call
|
||||
parameter until their own keyless migration lands (see the security notice above and
|
||||
[docs/architecture/non-custodial-split.md](docs/architecture/non-custodial-split.md)).
|
||||
|
||||
See [`packages/signer/README.md`](packages/signer/README.md) for configuration, tools, and known
|
||||
limitations.
|
||||
|
||||
## Features
|
||||
|
||||
- **Wallet management** - derive addresses from mnemonic, query balances and UTxOs
|
||||
- **Transactions** - send AP3X and native tokens, build multi-output transactions, dry-run simulations
|
||||
- **Wallet & queries** - balances, UTxOs, and transaction history for any address (no key material)
|
||||
- **Keyless transaction building** - build unsigned AP3X/token/multi-output transactions and contract interactions; sign locally, broadcast via submit
|
||||
- **Smart contracts** - deploy Plutus/Aiken validators, lock and spend UTxOs at script addresses
|
||||
- **Agent registry** - register, discover, update, transfer, and deregister on-chain AI agent identities via soulbound NFTs
|
||||
- **Agent messaging** - send on-chain messages between agents via TX metadata
|
||||
- **Self-Improvement Module** - browse, submit, critique, and endorse improvement proposals (live on Vector mainnet)
|
||||
- **Safety controls** - per-transaction and daily spend limits, persistent audit log, rate limiting
|
||||
- **Safety controls** - per-identity rate limiting; spend limits for the keyless family are enforced by the local signer (registry/self-improvement families keep server-side limits until their keyless migration)
|
||||
- **SSE transport** - HTTP server with Server-Sent Events for MCP client connectivity
|
||||
|
||||
## MCP Tools (25)
|
||||
## MCP Tools (23)
|
||||
|
||||
### Wallet & Queries
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `vector_get_balance` | Get AP3X and token balances for any address |
|
||||
| `vector_get_address` | Get the wallet address, balance, and holdings from a mnemonic |
|
||||
| `vector_get_utxos` | List UTxOs for an address or wallet |
|
||||
| `vector_get_spend_limits` | Check spend limits, daily usage, and audit log |
|
||||
| `vector_get_transaction_history` | Get transaction history for a wallet |
|
||||
| `vector_get_utxos` | List UTxOs for an address |
|
||||
| `vector_get_transaction_history` | Get transaction history for an address |
|
||||
|
||||
### Transactions
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `vector_send_apex` | Send AP3X (respects spend limits) |
|
||||
| `vector_send_tokens` | Send native tokens with optional AP3X |
|
||||
| `vector_build_transaction` | Build multi-output transactions (sign+submit or return unsigned CBOR) |
|
||||
| `vector_build_send_apex` | Build an unsigned AP3X payment (keyless — sign with the local signer) |
|
||||
| `vector_build_send_tokens` | Build an unsigned native-token transfer (keyless) |
|
||||
| `vector_build_transaction` | Build an unsigned multi-output transaction (keyless, never submits) |
|
||||
| `vector_dry_run` | Simulate a transaction without submitting - estimate fees and validate |
|
||||
| `vector_submit_transaction` | Broadcast an already-signed transaction (for example one signed by the local signer) |
|
||||
| `vector_await_transaction` | Wait for a submitted transaction to be confirmed on-chain |
|
||||
@@ -85,8 +92,8 @@ it ships, the security notice above still applies.
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `vector_deploy_contract` | Deploy a Plutus V1/V2/V3 or Aiken validator to the chain |
|
||||
| `vector_interact_contract` | Lock AP3X at a script address or spend from it with a redeemer |
|
||||
| `vector_build_deploy_contract` | Build an unsigned deployment of a Plutus V1/V2/V3 or Aiken validator (keyless) |
|
||||
| `vector_build_interact_contract` | Build an unsigned lock or spend at a script address, with a redeemer (keyless) |
|
||||
|
||||
### Agent Registry
|
||||
|
||||
@@ -128,8 +135,9 @@ cp .env.example .env
|
||||
# Edit .env with your endpoint URLs (defaults point to Vector testnet; mainnet URLs below)
|
||||
```
|
||||
|
||||
The mnemonic is passed per-call by the MCP client rather than stored in the environment. This
|
||||
avoids environment dumps, but does not make it private — see the security notice above.
|
||||
The wallet/tx family is keyless: `build_*` tools take a wallet address, never a mnemonic.
|
||||
The registry and self-improvement families still take a per-call mnemonic until their
|
||||
keyless migration lands — see the security notice above.
|
||||
|
||||
### 3. Run
|
||||
|
||||
@@ -188,6 +196,10 @@ If this instance will be reachable by anyone but you, set `MCP_AUTH_TOKENS` firs
|
||||
| `VECTOR_RATE_LIMIT_PER_MINUTE` | Max tool calls per minute | `60` |
|
||||
| `MCP_AUTH_TOKENS` | Bearer tokens that may call this server. Comma-separated; each entry is `label:token` or a bare token. **When unset, the server is open to anyone who can reach it.** | _(unset — auth disabled)_ |
|
||||
|
||||
`VECTOR_SPEND_LIMIT_PER_TX` / `VECTOR_SPEND_LIMIT_DAILY` govern only the still-custodial
|
||||
families (agent registry, self-improvement) — the keyless `build_*` family ignores them; its
|
||||
spend limits are enforced by the local signer instead.
|
||||
|
||||
> **Running a public instance?** Set `MCP_AUTH_TOKENS`. Without it every caller
|
||||
> is treated as one anonymous identity and shares a single rate-limit bucket, so
|
||||
> one busy client throttles everyone. Rate limits are applied per identity, so
|
||||
@@ -228,7 +240,7 @@ echo "your mnemonic words here" > packages/builder/mnemonic.txt
|
||||
npm run test:integration
|
||||
```
|
||||
|
||||
Requires `mnemonic.txt` in `packages/builder/` containing a **funded Vector testnet** mnemonic. Covers the core tools end-to-end against Vector testnet, including the full agent lifecycle: register, discover, profile, update, transfer, message, and deregister. Never runs in CI.
|
||||
Requires `mnemonic.txt` in `packages/builder/` containing a **funded Vector testnet** mnemonic. Covers the core tools end-to-end against Vector testnet, including the full agent lifecycle: register, discover, profile, update, transfer, message, and deregister. Also runs `keyless-build.test.ts`, which needs no mnemonic at all - it builds unsigned transactions with four of the five `build_*` tools against a live public address only (`vector_build_send_tokens` is covered offline, by unit tests and the legacy suite above, not by this live suite). Set `VECTOR_E2E_SUBMIT=1` to additionally run the full non-custodial pipeline end-to-end: build → local sign → submit → await, landing one real self-send (~0.16 AP3X fee) on-chain. Never runs in CI.
|
||||
|
||||
```bash
|
||||
npm run test:smoke:signer
|
||||
@@ -260,7 +272,7 @@ against live chain data. **Never submits.** Never runs in CI. See
|
||||
│ └────────┬───────────┘ │
|
||||
│ │ │
|
||||
│ ┌────────▼───────────┐ │
|
||||
│ │ Safety Layer │ │
|
||||
│ │ Safety Layer * │ │
|
||||
│ │ - Per-tx limits │ │
|
||||
│ │ - Daily limits │ │
|
||||
│ │ - Audit log │ │
|
||||
@@ -278,6 +290,9 @@ against live chain data. **Never submits.** Never runs in CI. See
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
\* Custodial families only (agent registry, self-improvement) — the keyless `build_*` path
|
||||
never reaches the Safety Layer; its spend limits are enforced by the local signer instead.
|
||||
|
||||
## About Vector
|
||||
|
||||
Vector is Apex Fusion's eUTXO L2, running Cardano mainnet parameters (Conway era, Plutus V3). Sub-1-second optimistic finality and deterministic fees make it a natural chain for AI agent workloads. Mainnet is live.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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` | — |
|
||||
|
||||
74
packages/builder/scripts/regen-tool-schemas.mjs
Normal file
74
packages/builder/scripts/regen-tool-schemas.mjs
Normal 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();
|
||||
}
|
||||
@@ -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() };
|
||||
|
||||
|
||||
@@ -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`,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
196
packages/builder/src/vector/build.ts
Normal file
196
packages/builder/src/vector/build.ts
Normal 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'`);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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
89
packages/builder/test/boundary.test.ts
Normal file
89
packages/builder/test/boundary.test.ts
Normal 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`);
|
||||
}
|
||||
});
|
||||
});
|
||||
175
packages/builder/test/build-contracts.test.ts
Normal file
175
packages/builder/test/build-contracts.test.ts
Normal 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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
184
packages/builder/test/build.test.ts
Normal file
184
packages/builder/test/build.test.ts
Normal 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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
40
packages/builder/test/fixture-provider.test.ts
Normal file
40
packages/builder/test/fixture-provider.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
55
packages/builder/test/fixtures/capture-protocol-params.mjs
vendored
Normal file
55
packages/builder/test/fixtures/capture-protocol-params.mjs
vendored
Normal 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}`);
|
||||
87
packages/builder/test/fixtures/fixture-provider.ts
vendored
Normal file
87
packages/builder/test/fixtures/fixture-provider.ts
vendored
Normal 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');
|
||||
}
|
||||
}
|
||||
678
packages/builder/test/fixtures/protocol-params.fixture.json
vendored
Normal file
678
packages/builder/test/fixtures/protocol-params.fixture.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
143
packages/builder/test/integration/keyless-build.test.ts
Normal file
143
packages/builder/test/integration/keyless-build.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
65
packages/builder/test/integration/keyless-e2e.test.ts
Normal file
65
packages/builder/test/integration/keyless-e2e.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user