diff --git a/.env.example b/.env.example index 0b11cbe..dd19c5c 100644 --- a/.env.example +++ b/.env.example @@ -8,10 +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. +# Scope note (non-custodial migration): the keyless build_* families (wallet/tx/contract, +# agent registry) ignore these - spend limits for them are enforced by the LOCAL signer, per +# user. These server-side limits still govern the one remaining custodial family +# (self-improvement) and will be removed when it goes keyless too. VECTOR_SPEND_LIMIT_PER_TX=100000000 # 100 ADA per transaction VECTOR_SPEND_LIMIT_DAILY=500000000 # 500 ADA daily limit diff --git a/README.md b/README.md index 4c636d4..96a0e42 100644 --- a/README.md +++ b/README.md @@ -21,15 +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.** 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. +> **Security notice — custody.** The wallet, transaction, smart-contract, and agent-registry +> 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 Self-Improvement Module (5 tools) is the only family still taking a mnemonic as a +> tool-call parameter** (its keyless migration is next — 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. @@ -43,15 +43,16 @@ or your model provider. Four tools: `vector_signer_get_address`, `vector_signer_ `vector_signer_sign`, `vector_signer_get_spend_limits`. **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)). +transaction, smart-contract, and agent-registry 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 for both: a payment self-send, and +a full register → update → deregister agent lifecycle with the 10 AP3X deposit round-tripped +exactly; see [`packages/signer/README.md`](packages/signer/README.md#known-limitations) for +exactly what that testing does and does not exercise on the signer's side. **The +Self-Improvement Module is not part of that pairing yet** — its 5 tools 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. @@ -61,10 +62,10 @@ limitations. - **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 +- **Agent registry** - register, discover, update, transfer, and deregister on-chain AI agent identities via soulbound NFTs (keyless - build, sign locally, broadcast) +- **Agent messaging** - send on-chain messages between agents via TX metadata (keyless) - **Self-Improvement Module** - browse, submit, critique, and endorse improvement proposals (live on Vector mainnet) -- **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) +- **Safety controls** - per-identity rate limiting; spend limits for the keyless families (wallet/tx/contract, agent registry) are enforced by the local signer (the Self-Improvement Module keeps server-side limits until its keyless migration) - **SSE transport** - HTTP server with Server-Sent Events for MCP client connectivity ## MCP Tools (23) @@ -99,13 +100,13 @@ limitations. | Tool | Description | |------|-------------| -| `vector_register_agent` | Register an agent - mints a soulbound identity NFT and locks a 10 AP3X deposit | +| `vector_build_register_agent` | Build an unsigned agent registration - mints a soulbound identity NFT and locks a 10 AP3X deposit (keyless) | | `vector_discover_agents` | Discover registered agents, filter by capability or framework (no wallet needed) | | `vector_get_agent_profile` | Get an agent's full profile by DID (no wallet needed) | -| `vector_update_agent` | Update an agent's name, description, capabilities, framework, or endpoint | -| `vector_transfer_agent` | Transfer agent ownership to a new address | -| `vector_deregister_agent` | Deregister an agent - burns the identity NFT and returns the 10 AP3X deposit | -| `vector_message_agent` | Send an on-chain message to an agent via TX metadata (label 674) | +| `vector_build_update_agent` | Build an unsigned update to an agent's name, description, capabilities, framework, or endpoint (keyless) | +| `vector_build_transfer_agent` | Build an unsigned transfer of agent ownership to a new address (keyless) | +| `vector_build_deregister_agent` | Build an unsigned deregistration - burns the identity NFT and returns the 10 AP3X deposit (keyless) | +| `vector_build_message_agent` | Build an unsigned on-chain message to an agent via TX metadata (label 674) (keyless) | ### Self-Improvement Module @@ -119,6 +120,9 @@ limitations. Agent DIDs follow the format: `did:vector:agent:{policyId}:{nftAssetName}` +Example non-custodial flow for a registry op: `vector_build_register_agent` → +`vector_signer_sign` → `vector_submit_transaction` → `vector_await_transaction`. + ## Self-hosting ### 1. Install and build @@ -135,9 +139,9 @@ cp .env.example .env # Edit .env with your endpoint URLs (defaults point to Vector testnet; mainnet URLs below) ``` -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. +The wallet/tx/contract and agent-registry families are keyless: `build_*` tools take a wallet +address, never a mnemonic. The Self-Improvement Module still takes a per-call mnemonic until +its keyless migration lands — see the security notice above. ### 3. Run @@ -197,8 +201,8 @@ If this instance will be reachable by anyone but you, set `MCP_AUTH_TOKENS` firs | `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. +Self-Improvement family — the keyless `build_*` families (wallet/tx/contract, agent registry) +ignore them; their 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 @@ -240,7 +244,7 @@ echo "your mnemonic words here" > packages/builder/mnemonic.txt npm run test:integration ``` -Requires `mnemonic.txt` in `packages/builder/` containing a **funded Vector testnet** mnemonic. Covers the core tools end-to-end against Vector testnet, including the full agent lifecycle: register, discover, profile, update, transfer, message, and deregister. Also runs `keyless-build.test.ts`, which needs no mnemonic at all - it builds 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. +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 (all keyless now: build → sign → submit → await per step). Also runs `keyless-build.test.ts` and `registry-keyless.test.ts`, which need no mnemonic at all - they build unsigned transactions (four of the five wallet/tx `build_*` tools, and the registry's register + message builds, plus a non-owner rejection path on update) against live public data only (`vector_build_send_tokens` is covered offline, by unit tests and the legacy suite above, not by either live suite). The full registry lifecycle (register → update → deregister) is exercised by the gated E2E suite. Set `VECTOR_E2E_SUBMIT=1` to additionally run the full non-custodial pipeline end-to-end, on-chain: `keyless-e2e.test.ts` lands one real self-send (~0.16 AP3X fee), and `registry-e2e.test.ts` runs a full register → update → deregister agent lifecycle (the 10 AP3X deposit round-tripped exactly, fees only as net cost). Never runs in CI. ```bash npm run test:smoke:signer @@ -290,8 +294,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. +\* The still-custodial Self-Improvement family only — the keyless `build_*` path (wallet/tx/ +contract, agent registry) never reaches the Safety Layer; its spend limits are enforced by the +local signer instead. ## About Vector diff --git a/docs/VECTOR.md b/docs/VECTOR.md index 7d91c14..92e9c35 100644 --- a/docs/VECTOR.md +++ b/docs/VECTOR.md @@ -120,6 +120,68 @@ Build an unsigned transaction interacting with a deployed smart contract — loc **Example prompt:** "Collect funds from the escrow contract at addr1..." +### vector_build_register_agent + +Build an unsigned transaction that registers an agent in the Vector on-chain registry — mints a soulbound identity NFT and locks a 10 AP3X deposit (keyless). Sign the returned CBOR with your local signer, then broadcast with `vector_submit_transaction`. + +**Parameters:** +- `changeAddress` (string, required) — Your wallet address; source of the deposit and change (addr1...) +- `name` (string, required) — Agent name (max 64 chars) +- `description` (string, required) — Short description of the agent's purpose (max 256 chars) +- `capabilities` (array of strings, required) — Capability tags (e.g. `["investing", "research"]`) +- `framework` (string, required) — Framework used (e.g. "OpenClaw", "LangChain", "CrewAI", "custom") +- `endpoint` (string, required) — A2A communication endpoint URL (or empty string if not applicable) + +**Example prompt:** "Register an agent named TradingBot with investing and research capabilities" + +### vector_build_update_agent + +Build an unsigned transaction that updates a registered agent's profile fields — only the specified fields change, the rest are preserved (keyless). Sign the returned CBOR with your local signer, then broadcast with `vector_submit_transaction`. + +**Parameters:** +- `changeAddress` (string, required) — The agent owner's wallet address (addr1...) +- `agent_id` (string, required) — Agent DID to update: `did:vector:agent:{policyId}:{nftAssetName}` +- `name` (string, optional) — New agent name +- `description` (string, optional) — New description +- `capabilities` (array of strings, optional) — New capability tags (replaces the existing list) +- `framework` (string, optional) — New framework identifier +- `endpoint` (string, optional) — New A2A endpoint URL (or empty string to clear) + +**Example prompt:** "Update my agent's description to mention DeFi support" + +### vector_build_transfer_agent + +Build an unsigned transaction that transfers agent ownership to a new address (keyless). Sign the returned CBOR with your local signer, then broadcast with `vector_submit_transaction`. + +**Parameters:** +- `changeAddress` (string, required) — The CURRENT owner's wallet address (addr1...) +- `agent_id` (string, required) — Agent DID to transfer: `did:vector:agent:{policyId}:{nftAssetName}` +- `new_owner_address` (string, required) — Bech32 address of the new owner (must be a verification-key address, not a script address) + +**Example prompt:** "Transfer my agent to addr1qy..." + +### vector_build_deregister_agent + +Build an unsigned transaction that deregisters an agent — burns the identity NFT and returns the 10 AP3X deposit to the owner wallet (keyless). Sign the returned CBOR with your local signer, then broadcast with `vector_submit_transaction`. + +**Parameters:** +- `changeAddress` (string, required) — The agent owner's wallet address; receives the returned deposit (addr1...) +- `agent_id` (string, required) — Agent DID to deregister: `did:vector:agent:{policyId}:{nftAssetName}` + +**Example prompt:** "Deregister my agent and reclaim the deposit" + +### vector_build_message_agent + +Build an unsigned transaction that sends an on-chain message to a registered agent via TX metadata (label 674), delivering 2 AP3X to the agent's owner address (keyless). Sign the returned CBOR with your local signer, then broadcast with `vector_submit_transaction`. + +**Parameters:** +- `changeAddress` (string, required) — Your wallet address; pays the 2 AP3X delivery and fee (addr1...) +- `agent_id` (string, required) — Recipient agent DID: `did:vector:agent:{policyId}:{nftAssetName}` +- `message_type` (string, required) — Type of message: "inquiry", "proposal", or "result" +- `payload` (string, required) — Message payload (max 512 chars) + +**Example prompt:** "Send an inquiry message to did:vector:agent:... asking about pricing" + ## Safety Controls The tools above are keyless and take no key material, so the server no longer enforces @@ -128,8 +190,8 @@ CBOR these tools return — see the security notice in the repo [README](../READ [docs/architecture/non-custodial-split.md](architecture/non-custodial-split.md). `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. +govern the server's one remaining custodial tool family (self-improvement) until its own +keyless migration lands. ## Network Endpoints diff --git a/docs/architecture/non-custodial-split.md b/docs/architecture/non-custodial-split.md index 491e997..4439894 100644 --- a/docs/architecture/non-custodial-split.md +++ b/docs/architecture/non-custodial-split.md @@ -134,7 +134,7 @@ This is why governance is the last family: the LLM must drive a stateful, multi- ## 7. Tool inventory -**Builder — 24 tools, zero keys.** +**Builder — 24 tools at full migration, zero keys.** | Group | Tools | |---|---| @@ -189,7 +189,7 @@ TDD throughout: failing test before implementation, every PR. | **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 ✅ **(landed — this PR)** | **yes** | -| **7** | **Family 2 — agent registry keyless.** Drops `@ts-nocheck` from `agent-network.ts` | yes | +| **7** | **Family 2 — agent registry keyless.** Drops `@ts-nocheck` from `agent-network.ts` ✅ **(landed — this PR)** | yes | | **8** | **Family 3 — governance keyless.** Plus env-driven ref-UTxO config and a CBOR encoding fix | yes | | **9** | Mainnet cutover: README, security-page correction, deliberate `workflow_dispatch` | — | diff --git a/packages/builder/src/vector/agent-network.ts b/packages/builder/src/vector/agent-network.ts index 1001f6e..b9a790d 100644 --- a/packages/builder/src/vector/agent-network.ts +++ b/packages/builder/src/vector/agent-network.ts @@ -1,164 +1,29 @@ -// @ts-nocheck -// Agent network tools: register, discover, message, get_profile, update, deregister, transfer - +// Agent-registry family: 2 read-only tools + 5 keyless build_* tools. +// KEYLESS since spec PR 7: no tool here takes key material. Builds are +// unsigned; the 4-call flow is get_address -> build_* -> signer sign -> submit. +// Registry constants and build logic live in registry-build.ts. import { z } from "zod/v4"; -import { Lucid, fromText, toText, Data, Constr, validatorToAddress, validatorToScriptHash, getAddressDetails, credentialToAddress } from '@lucid-evolution/lucid'; -import { blake2b } from '@noble/hashes/blake2b'; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { OgmiosProvider } from '@apexfusion/vector-mcp-shared/provider'; -import { metadataStr, lovelaceToAda, deriveNftAssetName } from '@apexfusion/vector-mcp-shared/tx'; +import { lovelaceToAda } from '@apexfusion/vector-mcp-shared/tx'; import { - VECTOR_OGMIOS_URL, VECTOR_SUBMIT_URL, VECTOR_KOIOS_URL, VECTOR_EXPLORER_URL, explorerTxLink, + VECTOR_OGMIOS_URL, VECTOR_SUBMIT_URL, VECTOR_KOIOS_URL, } from '@apexfusion/vector-mcp-shared/config'; -export { deriveNftAssetName }; -import { safetyLayer } from './safety.js'; import { limiterFor } from './rate-limiter.js'; - -// Registry constants - agent-registry v2 (audited, compliant) -// Source blueprint: vector-ai-agents/agent-registry/deploy/agent-registry/plutus.json -const REGISTRY_SCRIPT_CBOR = "5909d101010029800aba2aba1aba0aab9faab9eaab9dab9a4888888966003300130033754011370e90004dc3a40052300730080019180398041804000c8c01cc020c020c020c02000644b30010018a40011337009001198010011804800a00c91803980418041804000c88c8c8cc004004010896600200300389919912cc004cdc8803801456600266e3c01c00a20030064025133005005300f00440246eb8c020004dd598048009805800a01214bd6f7b630488c8cc00400400c896600200314a115980098019805000c528c4cc008008c02c00500520109ba5480026e1d2004488888888888a60026026019301200c912cc004c034c040dd5000c4c8c8cc004004dd6180b180b980b980b980b980b980b980b980b98099baa0042259800800c528456600266e3cdd7180b800801c528c4cc008008c060005012202a375c602860226ea8006294100f48966002601a60206ea800a2646464646464653001375a6036003375c603600d375c603600b37586036009375c6036007375c60360049111112cc004c08801e2646644b3001301d002899192cc004c09c00a0071640906eb8c094004c084dd5001c566002603800513232598009813801400e2c8120dd7181280098109baa0038b203e407c603c6ea80044c8cc00400401489660020030118991980180198130011bae30240014088604201b16407c301b001301a00130190013018001301700130160013011375400516403d259800980618079baa0018a518a504039300600691119199119801001000912cc00400600713233225980099b910070028acc004cdc78038014400600c80b226600a00a603800880b0dd7180a8009bad3016001301800140586601000800629000488c8cc00400400c896600200314c103d87a80008992cc004c0100062600e6602c00297ae0899801801980c001202430160014050911111114c004c0680226034603601125980099b89371a6eb8c048c060dd5000a41000915980099b89371a6eb8c044c060dd5000a41002115980099b89371a6eb8c040c060dd5000a41000515980099b89371a6eb8c06cc070c070c070c070c070c060dd5000a41001115980099b8930043758601c60306ea80052040899198008009bac300f3019375400444b30010018a518acc004cdc49b8d375c603a00290400144cc008008c078006294101820368a50405914a080b229410164528202c8a50405929800800d220100a44100400c9111192cc004c0600062646644b3001301b0018992cc004c090006264b3001301d30203754003132323322598009814801c0222c8130dd698130009bae302600230260013021375400316407c6046003164084603e6ea80222b3001301a0018acc004c07cdd5004400a2c81022c80e901d0992cc004c068c074dd5003c4c8cc89660026602a6eb0c090c084dd5008919baf30253022375400200915980099912cc0040060051598009813800c4c96600266e3cdd71811800802c4c07cdd69812000c52820443026001801204840902940cc058dd5980c98109baa011005899912cc0040060051598009813800c4c966002602c60466ea8006264660260022b300130123028302537540031598009980a00a981418129baa00189806000c52820468a50408c604e60486ea80062c8110c070c08cdd51813000c009024204814a064660020026eb0c06cc088dd5009112cc004006297ae0899912cc0056600266ebcc0a0c094dd5001002c566002b3001301730243754603c604a6ea800a294629410234566002604130013756603e604a6ea800a013006404113371290406d620498059bab301f3025375400514a0811a29410234528204689981380119802002000c4cc01001000502318130009813800a0488a50407d14a080f8dca1bb300130020033021301e375400f1332259800800c00a2b30013024001899192cc004cdc39bad30220024800626464b300130203023375400313259800980b98121baa0018991980a00089980a80b181498131baa00130283025375400316408c603a60486ea8c078c090dd5181398121baa0018a5040886601a6eb0c098c08cdd500992cc004cdd7981398121baa301e302437540020051301f98009bab301e30243754603c60486ea8006011003403d14a08110c01401a2c8100dd718100009811800c009021204214a0660266eacc058c078dd5007001203823011330203374a9001198101ba90014bd7019810260103d87a80004bd70180e1baa006375c603e60386ea80122b30013017001899199119912cc004c0740062b3001302137540150028b20448acc004c0700062b3001302137540150028b20448b203e407c2b3001301b301e3754003198009811180f9baa00191192cc004c078c084dd5000c4c094c088dd5180e18111baa30253022375400316408066016004466ebcc094c088dd500080148c966002603860406ea800626eb8c090c084dd5000c5901f181198101baa00191192cc004c078c084dd5000c4dd5980e18111baa301c30223754604a60446ea80062c8100cc02c0088cdd7981298111baa00100291192cc004c098006264b3001301e375a60460031375c6044003164084604a00316408c6602c004002911112cc004c080c08cdd5006c4c8cc88cc896600266030032605860526ea802a2b3001302432330010013758605a60546ea8068896600200314800226644b30013375e6060605a6ea8c09cc0b4dd5001004c4cdc0000a4005100140ac605c00266004004605e0028162264b30013026302937540031323259800980f18159baa0018991980d8008acc004cdc39bad3005302d37540026eb4c014c0b4dd5007456600260346060605a6ea80062b30013014001899b89301300730133756604e605a6ea800e294102b452820568a5040ac605e60586ea80062c8150c090c0acdd5000981698151baa0018a5040a0660266eb0c088c0a4dd500c92cc004cdd7981698151baa00100689812cc004dd5981218151baa001802c00d015452820508a50409d14a08138cc0100040088c0acc0b0c0b0c0b0c0b0c0b0c0b0004c010004cc00cdd6181418129baa015008330043758604e60486ea805001e26464b30013301501630293026375400f15980099912cc0040060051598009816000c4c96600266e3cdd7181400080244cdc39bad30290014800629410271815800c009029205214a0660366eacc078c098dd500b00144c9660026046604c6ea8006264660386eb0c084c0a0dd500c12cc004cdd7981618149baa302c30293754002605860526ea800a266e252080dac409300f3756604660526ea80062941027180d19814980d19814981518139baa0014bd7019814a6103d87a80004bd704528204a30293026375400f14a08122294102419801198019bac30283025375402a0100026006660086eb0c09cc090dd500a003a04445901d180f1baa008302000130203021001301c375400916406880d0c068dd500188a4d13656400401"; -const REGISTRY_POLICY_ID = "be1a0a2912da180757ed3cd61b56bb8eab0188c19dc3c0e3912d2c01"; -const MIN_AP3X_DEPOSIT = 10_000_000n; -const AGENT_MESSAGE_LABEL = 674; - -// ─── Helpers ───────────────────────────────────────────────────────────────── +import { lucidForAddress } from './build.js'; +import { + MIN_AP3X_DEPOSIT, getRegistryAddress, resolveAgentUtxo, parseAgentDatum, + buildRegisterAgent, buildUpdateAgent, buildTransferAgent, buildDeregisterAgent, buildMessageAgent, +} from './registry-build.js'; function newProvider() { return new OgmiosProvider({ ogmiosUrl: VECTOR_OGMIOS_URL, submitUrl: VECTOR_SUBMIT_URL, koiosUrl: VECTOR_KOIOS_URL }); } -function buildAgentDatum(vkeyHash, name, description, capabilities, framework, endpoint, registeredAt) { - return Data.to(new Constr(0, [ - new Constr(0, [vkeyHash]), - fromText(name), - fromText(description), - capabilities.map(c => fromText(c)), - fromText(framework), - fromText(endpoint), - BigInt(registeredAt ?? Date.now()), - ])); -} +const SIGN_AND_SUBMIT_COPY = + 'Sign it locally with vector_signer_sign { txCbor }, broadcast with vector_submit_transaction { signedTxCbor }, then confirm with vector_await_transaction { txHash }.'; -function parseAgentDatum(datumCbor, utxoRef, assets) { - try { - const c = Data.from(datumCbor); - if (Number(c.index) !== 0) return null; - const ownerCred = c.fields[0]; - const vkeyHash = ownerCred.fields[0]; - const nftUnit = Object.keys(assets).find( - u => u.startsWith(REGISTRY_POLICY_ID) && assets[u] === 1n - ); - const nftAssetName = nftUnit ? nftUnit.slice(REGISTRY_POLICY_ID.length) : ''; - return { - agentId: `did:vector:agent:${REGISTRY_POLICY_ID}:${nftAssetName}`, - name: toText(c.fields[1]), - description: toText(c.fields[2]), - capabilities: c.fields[3].map(toText), - framework: toText(c.fields[4]), - endpoint: toText(c.fields[5]), - registeredAt: Number(c.fields[6]), - utxoRef, - ownerVkeyHash: vkeyHash, - }; - } catch (err) { - console.warn(`parseAgentDatum failed for ${utxoRef}:`, err?.message || err); - return null; - } -} - -// ─── DID & Agent Resolution ────────────────────────────────────────────────── - -function parseDid(agentId) { - const parts = agentId.split(':'); - if (parts.length !== 5 || parts[0] !== 'did' || parts[1] !== 'vector' || parts[2] !== 'agent') { - throw new Error('Invalid agent DID format. Expected: did:vector:agent:{policyId}:{nftAssetName}'); - } - if (!/^[a-f0-9]+$/.test(parts[3]) || !/^[a-f0-9]+$/.test(parts[4])) { - throw new Error('Invalid agent DID: policyId and assetName must be hex strings.'); - } - return { policyId: parts[3], assetName: parts[4], unit: `${parts[3]}${parts[4]}` }; -} - -async function resolveAgentUtxo(provider, agentId) { - const { unit } = parseDid(agentId); - - let utxo; - try { - utxo = await provider.getUtxoByUnit(unit); - } catch { - // Koios unavailable or asset not found - will try Ogmios fallback below - } - - // If Koios returned a UTxO without datum, or didn't find one, scan via Ogmios - if (!utxo || !utxo.datum) { - const registryAddress = await getRegistryAddress(); - const allUtxos = await provider.getUtxos(registryAddress); - const ogmiosUtxo = allUtxos.find(u => u.assets[unit] && u.assets[unit] > 0n); - if (ogmiosUtxo) { - utxo = ogmiosUtxo; - } - } - - if (!utxo) throw new Error(`Agent not found: no UTxO holds NFT ${unit}. The agent may not exist or may have deregistered.`); - if (!utxo.datum) throw new Error('Registry UTxO found but has no inline datum.'); - const profile = parseAgentDatum(utxo.datum, `${utxo.txHash}#${utxo.outputIndex}`, utxo.assets); - if (!profile) throw new Error('Could not parse agent datum. The on-chain data may be malformed.'); - return { profile, utxo, nftUnit: unit }; -} - -function verifyOwnership(profile, walletVkeyHash) { - if (profile.ownerVkeyHash !== walletVkeyHash) { - throw new Error('Ownership check failed: your wallet does not own this agent. The agent\'s owner verification key does not match your wallet\'s payment key.'); - } -} - -// ─── Validation ────────────────────────────────────────────────────────────── - -function validateEndpoint(endpoint) { - if (!endpoint) return; // empty string is allowed - try { - new URL(endpoint); - } catch { - throw new Error(`Invalid endpoint URL: "${endpoint}". Must be a valid URL (e.g. https://example.com/api) or empty string.`); - } -} - -function validateCapabilities(capabilities) { - for (const cap of capabilities) { - if (typeof cap !== 'string' || cap.trim().length === 0) { - throw new Error('Each capability must be a non-empty string.'); - } - } -} - -// ─── Lucid Init ────────────────────────────────────────────────────────────── - -async function initLucid(mnemonic, accountIndex = 0) { - const provider = newProvider(); - const lucid = await Lucid(provider, 'Mainnet'); - if (!mnemonic) throw new Error('mnemonic is required'); - const trimmed = mnemonic.trim(); - const words = trimmed.split(/\s+/); - const validLengths = [12, 15, 18, 21, 24]; - if (!validLengths.includes(words.length)) { - throw new Error(`Invalid mnemonic: Expected 12, 15, 18, 21 or 24 words, got ${words.length}`); - } - lucid.selectWallet.fromSeed(trimmed, { accountIndex }); - - return lucid; -} - -// Cached registry address (derived from script hash, doesn't need a wallet) -let _registryAddress = null; -async function getRegistryAddress() { - if (_registryAddress) return _registryAddress; - const registryScript = { type: 'PlutusV3' as const, script: REGISTRY_SCRIPT_CBOR }; - _registryAddress = validatorToAddress('Mainnet', registryScript); - return _registryAddress; -} - -// ─── Tool Registration ─────────────────────────────────────────────────────── - -export function registerAgentNetworkTools(server, identity) { +export function registerAgentNetworkTools(server: McpServer, identity: string) { const rateLimiter = limiterFor(identity); // Rate limit wrapper - defined inside the register function (not at module @@ -167,12 +32,12 @@ export function registerAgentNetworkTools(server, identity) { function checkRateLimit() { const rateCheck = rateLimiter.check(); if (!rateCheck.allowed) { - return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] }; + return { content: [{ type: "text" as const, text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] }; } return null; } - // vector_get_agent_profile (read-only - no mnemonic needed) + // vector_get_agent_profile (read-only) — UNCHANGED behavior; body moves to typed helpers server.tool( "vector_get_agent_profile", "Get a registered agent's profile from the on-chain registry by DID (did:vector:agent:...)", @@ -186,11 +51,11 @@ export function registerAgentNetworkTools(server, identity) { const provider = newProvider(); const { profile } = await resolveAgentUtxo(provider, agent_id); const capList = profile.capabilities.length > 0 - ? profile.capabilities.map(c => `- ${c}`).join('\n') + ? profile.capabilities.map((c) => `- ${c}`).join('\n') : '- (none listed)'; return { content: [{ - type: "text", + type: "text" as const, text: `# Agent Profile **DID:** ${profile.agentId} @@ -209,8 +74,8 @@ ${capList} } catch (err) { return { content: [{ - type: "text", - text: `Failed to get agent profile: ${err.message} + type: "text" as const, + text: `Failed to get agent profile: ${err instanceof Error ? err.message : String(err)} **Troubleshooting Tips:** 1. Verify the DID format: did:vector:agent:{policyId}:{nftAssetName} @@ -222,7 +87,7 @@ ${capList} } ); - // vector_discover_agents (read-only - no mnemonic needed) + // vector_discover_agents (read-only) — UNCHANGED behavior server.tool( "vector_discover_agents", "Discover registered agents in the Vector on-chain registry, optionally filtered by capability or framework", @@ -235,37 +100,36 @@ ${capList} const rateLimited = checkRateLimit(); if (rateLimited) return rateLimited; try { - const registryAddress = await getRegistryAddress(); const provider = newProvider(); - const utxos = await provider.getUtxos(registryAddress); + const utxos = await provider.getUtxos(getRegistryAddress()); const profiles = []; for (const utxo of utxos) { if (!utxo.datum) continue; const profile = parseAgentDatum(utxo.datum, `${utxo.txHash}#${utxo.outputIndex}`, utxo.assets); if (!profile) continue; - if (capability && !profile.capabilities.some(c => c.toLowerCase().includes(capability.toLowerCase()))) continue; + if (capability && !profile.capabilities.some((c) => c.toLowerCase().includes(capability.toLowerCase()))) continue; if (framework && profile.framework.toLowerCase() !== framework.toLowerCase()) continue; profiles.push(profile); if (profiles.length >= (limit ?? 20)) break; } if (profiles.length === 0) { const filterDesc = [capability ? `capability="${capability}"` : '', framework ? `framework="${framework}"` : ''].filter(Boolean).join(', '); - return { content: [{ type: "text", text: `No agents found${filterDesc ? ` matching ${filterDesc}` : ''} in the registry.\n\nTotal UTxOs at registry: ${utxos.length}` }] }; + return { content: [{ type: "text" as const, text: `No agents found${filterDesc ? ` matching ${filterDesc}` : ''} in the registry.\n\nTotal UTxOs at registry: ${utxos.length}` }] }; } const agentList = profiles.map((p, i) => `### ${i + 1}. ${p.name}\n**DID:** ${p.agentId}\n**Description:** ${p.description}\n**Capabilities:** ${p.capabilities.join(', ') || '(none)'}\n**Framework:** ${p.framework || '(not specified)'}\n**Endpoint:** ${p.endpoint || '(not specified)'}\n**Registered:** ${new Date(p.registeredAt).toISOString()}` ).join('\n\n'); return { content: [{ - type: "text", + type: "text" as const, text: `# Agent Discovery Results\n\nFound **${profiles.length}** agent${profiles.length !== 1 ? 's' : ''}${capability ? ` with capability "${capability}"` : ''}${framework ? ` using framework "${framework}"` : ''}:\n\n${agentList}`, }], }; } catch (err) { return { content: [{ - type: "text", - text: `Failed to discover agents: ${err.message} + type: "text" as const, + text: `Failed to discover agents: ${err instanceof Error ? err.message : String(err)} **Troubleshooting Tips:** 1. Check that Ogmios is reachable at ${VECTOR_OGMIOS_URL} @@ -277,101 +141,66 @@ ${capList} } ); - // vector_register_agent + // vector_build_register_agent — KEYLESS server.tool( - "vector_register_agent", - "Register an agent in the Vector on-chain agent registry. Mints a soulbound identity NFT and locks a 10 AP3X deposit.", + "vector_build_register_agent", + "Build an UNSIGNED transaction that registers an agent in the Vector on-chain registry (mints a soulbound identity NFT, locks a 10 AP3X deposit). Takes no key material. Sign locally with vector_signer_sign, broadcast with vector_submit_transaction, then confirm with vector_await_transaction.", { - mnemonic: z.string().describe("15 or 24-word BIP39 mnemonic for the registering wallet"), + changeAddress: z.string().describe("Your wallet address (source of the deposit and change). Get it from vector_signer_get_address."), name: z.string().min(1).max(64).describe("Agent name (e.g. 'TradingBot', 'ResearchAgent')"), description: z.string().max(256).describe("Short description of the agent's purpose"), capabilities: z.array(z.string()).describe("List of capability tags (e.g. ['investing', 'research', 'environmental'])"), framework: z.string().describe("Framework used (e.g. 'OpenClaw', 'LangChain', 'CrewAI', 'custom')"), endpoint: z.string().describe("A2A communication endpoint URL (or empty string if not applicable)"), }, - async ({ mnemonic, name, description, capabilities, framework, endpoint }) => { + async ({ changeAddress, name, description, capabilities, framework, endpoint }) => { const rateLimited = checkRateLimit(); if (rateLimited) return rateLimited; try { - validateEndpoint(endpoint); - validateCapabilities(capabilities); - - const safetyCheck = safetyLayer.checkTransaction(Number(MIN_AP3X_DEPOSIT)); - if (!safetyCheck.allowed) throw new Error(`Safety limit exceeded: ${safetyCheck.reason}`); - - const lucid = await initLucid(mnemonic); - const walletAddress = await lucid.wallet().address(); - const addressDetails = getAddressDetails(walletAddress); - const vkeyHash = addressDetails.paymentCredential?.hash; - if (!vkeyHash) throw new Error('Cannot derive payment key hash from wallet address'); - - const utxos = await lucid.utxosAt(await lucid.wallet().address()); - 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 (!oneShotUtxo) throw new Error('No UTxOs in wallet. Please fund the wallet first.'); - - 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, [oneShotUtxo.txHash, BigInt(oneShotUtxo.outputIndex)])])); - - const tx = await lucid.newTx() - .collectFrom([oneShotUtxo]) - .mintAssets({ [nftUnit]: 1n }, registerRedeemer) - .attach.MintingPolicy(registryScript) - .pay.ToAddressWithData(registryAddress, { kind: "inline", value: datum }, { lovelace: MIN_AP3X_DEPOSIT, [nftUnit]: 1n }) - .addSigner(walletAddress) - .complete(); - const signedTx = await tx.sign.withWallet().complete(); - const txHash = await signedTx.submit(); - safetyLayer.recordTransaction(txHash, Number(MIN_AP3X_DEPOSIT), registryAddress); - - const agentId = `did:vector:agent:${REGISTRY_POLICY_ID}:${nftAssetName}`; + const lucid = await lucidForAddress(newProvider(), changeAddress); + const r = await buildRegisterAgent(lucid, { changeAddress, name, description, capabilities, framework, endpoint }); return { content: [{ - type: "text", - text: `# Agent Registered Successfully + type: "text" as const, + text: `# Unsigned Registration Built (Not Submitted) -**Agent DID:** ${agentId} +**Agent DID:** ${r.agentId} **Name:** ${name} -**NFT Asset Name:** ${nftAssetName} -**Registry Address:** ${registryAddress} -**Transaction:** ${txHash} -**Deposit:** ${lovelaceToAda(MIN_AP3X_DEPOSIT)} AP3X (returned on deregistration) +**NFT Asset Name:** ${r.nftAssetName} +**Registry Address:** ${r.registryAddress} +**Deposit:** ${lovelaceToAda(r.depositLovelace)} AP3X (returned on deregistration) +**Estimated Fee:** ${r.feeAda} AP3X -[View on Explorer](${explorerTxLink(txHash)}) +**Unsigned TX CBOR:** +\`\`\` +${r.txCbor} +\`\`\` -Save your Agent DID - you'll need it to update, deregister, or let other agents discover you.`, +The DID above is bound to the exact wallet UTxO this build consumed - if that UTxO is spent by another transaction first, rebuild to get a new DID. Your signer must allow ~${lovelaceToAda((MIN_AP3X_DEPOSIT + 500_000n).toString())} AP3X net outflow (deposit + fee). ${SIGN_AND_SUBMIT_COPY}`, }], }; } catch (err) { - const errMsg = err instanceof Error ? err.message : (typeof err === 'string' ? err : JSON.stringify(err)); return { content: [{ - type: "text", - text: `Failed to register agent: ${errMsg} + type: "text" as const, + text: `Failed to build agent registration: ${err instanceof Error ? err.message : String(err)} **Troubleshooting Tips:** -1. Ensure wallet has at least 12 AP3X (10 deposit + fees) -2. Server spend limits for this tool are set via VECTOR_SPEND_LIMIT_PER_TX / VECTOR_SPEND_LIMIT_DAILY -3. Each wallet can register multiple agents (different one-shot UTxOs) -4. Verify Ogmios endpoint is reachable at ${VECTOR_OGMIOS_URL}`, +1. Ensure the changeAddress wallet holds at least 12 AP3X (10 deposit + fees) +2. Each wallet can register multiple agents (different one-shot UTxOs) +3. Verify Ogmios endpoint is reachable at ${VECTOR_OGMIOS_URL}`, }], }; } } ); - // vector_update_agent + // vector_build_update_agent — KEYLESS server.tool( - "vector_update_agent", - "Update a registered agent's profile fields (name, description, capabilities, framework, endpoint). Only the specified fields are changed; others are preserved.", + "vector_build_update_agent", + "Build an UNSIGNED transaction that updates a registered agent's profile fields. Only the specified fields change; others are preserved. Takes no key material - the on-chain validator requires the owner's signature, which you add locally with vector_signer_sign before vector_submit_transaction, then confirm with vector_await_transaction.", { - mnemonic: z.string().describe("15 or 24-word BIP39 mnemonic for the agent's owner wallet"), + changeAddress: z.string().describe("The agent OWNER's wallet address. Get it from vector_signer_get_address."), agent_id: z.string().describe("Agent DID to update: did:vector:agent:{policyId}:{nftAssetName}"), name: z.string().min(1).max(64).optional().describe("New agent name"), description: z.string().max(256).optional().describe("New description"), @@ -379,300 +208,193 @@ Save your Agent DID - you'll need it to update, deregister, or let other agents framework: z.string().optional().describe("New framework identifier"), endpoint: z.string().optional().describe("New A2A endpoint URL (or empty string to clear)"), }, - async ({ mnemonic, agent_id, name, description, capabilities, framework, endpoint }) => { + async ({ changeAddress, agent_id, name, description, capabilities, framework, endpoint }) => { const rateLimited = checkRateLimit(); if (rateLimited) return rateLimited; try { - if (name === undefined && description === undefined && capabilities === undefined && framework === undefined && endpoint === undefined) { - throw new Error('At least one field must be provided to update (name, description, capabilities, framework, or endpoint).'); - } - if (endpoint !== undefined) validateEndpoint(endpoint); - if (capabilities !== undefined) validateCapabilities(capabilities); - - const lucid = await initLucid(mnemonic); - const walletAddress = await lucid.wallet().address(); - const vkeyHash = getAddressDetails(walletAddress).paymentCredential?.hash; - if (!vkeyHash) throw new Error('Cannot derive payment key hash from wallet address'); - const provider = newProvider(); - const { profile, utxo, nftUnit } = await resolveAgentUtxo(provider, agent_id); - verifyOwnership(profile, vkeyHash); - - // Merge: use new values where provided, keep old values otherwise - const newName = name ?? profile.name; - const newDesc = description ?? profile.description; - const newCaps = capabilities ?? profile.capabilities; - const newFramework = framework ?? profile.framework; - const newEndpoint = endpoint ?? profile.endpoint; - - const newDatum = buildAgentDatum(vkeyHash, newName, newDesc, newCaps, newFramework, newEndpoint, profile.registeredAt); - const registryScript = { type: 'PlutusV3' as const, script: REGISTRY_SCRIPT_CBOR }; - const registryAddress = validatorToAddress('Mainnet', registryScript); - const spendRedeemer = Data.to(new Constr(0, [])); // Update - - const tx = await lucid.newTx() - .collectFrom([utxo], spendRedeemer) - .attach.SpendingValidator(registryScript) - .pay.ToAddressWithData(registryAddress, { kind: "inline", value: newDatum }, { lovelace: MIN_AP3X_DEPOSIT, [nftUnit]: 1n }) - .addSigner(walletAddress) - .complete(); - const signedTx = await tx.sign.withWallet().complete(); - const txHash = await signedTx.submit(); - safetyLayer.recordTransaction(txHash, 0, registryAddress); - - const updatedFields = []; - if (name !== undefined) updatedFields.push('name'); - if (description !== undefined) updatedFields.push('description'); - if (capabilities !== undefined) updatedFields.push('capabilities'); - if (framework !== undefined) updatedFields.push('framework'); - if (endpoint !== undefined) updatedFields.push('endpoint'); - + const lucid = await lucidForAddress(provider, changeAddress); + const r = await buildUpdateAgent(lucid, provider, { changeAddress, agentId: agent_id, name, description, capabilities, framework, endpoint }); return { content: [{ - type: "text", - text: `# Agent Updated Successfully + type: "text" as const, + text: `# Unsigned Update Built (Not Submitted) **Agent DID:** ${agent_id} -**Updated fields:** ${updatedFields.join(', ')} -**Transaction:** ${txHash} +**Fields to update:** ${r.detail} +**Estimated Fee:** ${r.feeAda} AP3X -[View on Explorer](${explorerTxLink(txHash)}) +**Unsigned TX CBOR:** +\`\`\` +${r.txCbor} +\`\`\` -The agent's profile has been updated on-chain. The identity NFT and deposit are unchanged.`, +The identity NFT and deposit are unchanged by this update. ${SIGN_AND_SUBMIT_COPY}`, }], }; } catch (err) { return { content: [{ - type: "text", - text: `Failed to update agent: ${err.message} + type: "text" as const, + text: `Failed to build agent update: ${err instanceof Error ? err.message : String(err)} **Troubleshooting Tips:** 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. Server spend limits for this tool are set via VECTOR_SPEND_LIMIT_PER_TX / VECTOR_SPEND_LIMIT_DAILY`, +2. The changeAddress must be the agent's current owner wallet +3. Use vector_get_agent_profile to check the agent's current state`, }], }; } } ); - // vector_deregister_agent + // vector_build_transfer_agent — KEYLESS server.tool( - "vector_deregister_agent", - "Deregister an agent from the Vector registry. Burns the identity NFT and returns the 10 AP3X deposit to your wallet.", + "vector_build_transfer_agent", + "Build an UNSIGNED transaction that transfers agent ownership to a new address (must be a verification-key address, not a script). Takes no key material. Sign locally with vector_signer_sign, broadcast with vector_submit_transaction, then confirm with vector_await_transaction.", { - mnemonic: z.string().describe("15 or 24-word BIP39 mnemonic for the agent's owner wallet"), - agent_id: z.string().describe("Agent DID to deregister: did:vector:agent:{policyId}:{nftAssetName}"), - }, - async ({ mnemonic, agent_id }) => { - const rateLimited = checkRateLimit(); - if (rateLimited) return rateLimited; - try { - const lucid = await initLucid(mnemonic); - const walletAddress = await lucid.wallet().address(); - const vkeyHash = getAddressDetails(walletAddress).paymentCredential?.hash; - if (!vkeyHash) throw new Error('Cannot derive payment key hash from wallet address'); - - const provider = newProvider(); - const { profile, utxo, nftUnit } = await resolveAgentUtxo(provider, agent_id); - verifyOwnership(profile, vkeyHash); - - const registryScript = { type: 'PlutusV3' as const, script: REGISTRY_SCRIPT_CBOR }; - const spendRedeemer = Data.to(new Constr(1, [])); // Deregister - const mintRedeemer = Data.to(new Constr(1, [])); // Burn - - const tx = await lucid.newTx() - .collectFrom([utxo], spendRedeemer) - .attach.SpendingValidator(registryScript) - .mintAssets({ [nftUnit]: -1n }, mintRedeemer) - .attach.MintingPolicy(registryScript) - .addSigner(walletAddress) - .complete(); - const signedTx = await tx.sign.withWallet().complete(); - const txHash = await signedTx.submit(); - safetyLayer.recordTransaction(txHash, 0, walletAddress); - - return { - content: [{ - type: "text", - text: `# Agent Deregistered Successfully - -**Agent DID:** ${agent_id} -**Name:** ${profile.name} -**Deposit returned:** ${lovelaceToAda(MIN_AP3X_DEPOSIT)} AP3X -**Transaction:** ${txHash} - -[View on Explorer](${explorerTxLink(txHash)}) - -The agent's identity NFT has been burned and the 10 AP3X deposit has been returned to your wallet.`, - }], - }; - } catch (err) { - return { - content: [{ - type: "text", - text: `Failed to deregister agent: ${err.message} - -**Troubleshooting Tips:** -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. Use vector_get_agent_profile to check the agent's current state`, - }], - }; - } - } - ); - - // vector_transfer_agent - server.tool( - "vector_transfer_agent", - "Transfer agent ownership to a new address. The new owner must have a verification key credential (not a script address).", - { - mnemonic: z.string().describe("15 or 24-word BIP39 mnemonic for the current owner's wallet"), + changeAddress: z.string().describe("The CURRENT owner's wallet address. Get it from vector_signer_get_address."), agent_id: z.string().describe("Agent DID to transfer: did:vector:agent:{policyId}:{nftAssetName}"), new_owner_address: z.string().describe("Bech32 address of the new owner (must be a verification key address, not a script address)"), }, - async ({ mnemonic, agent_id, new_owner_address }) => { + async ({ changeAddress, agent_id, new_owner_address }) => { const rateLimited = checkRateLimit(); if (rateLimited) return rateLimited; try { - const lucid = await initLucid(mnemonic); - const walletAddress = await lucid.wallet().address(); - const vkeyHash = getAddressDetails(walletAddress).paymentCredential?.hash; - if (!vkeyHash) throw new Error('Cannot derive payment key hash from wallet address'); - - // Validate new owner address - let newOwnerDetails; - try { - newOwnerDetails = getAddressDetails(new_owner_address); - } catch { - throw new Error(`Invalid new owner address: "${new_owner_address}". Must be a valid bech32 address.`); - } - if (newOwnerDetails.paymentCredential?.type !== 'Key') { - throw new Error('New owner address must be a verification key credential, not a script address. The on-chain contract rejects script credentials as owners.'); - } - const newOwnerVkeyHash = newOwnerDetails.paymentCredential.hash; - const provider = newProvider(); - const { profile, utxo, nftUnit } = await resolveAgentUtxo(provider, agent_id); - verifyOwnership(profile, vkeyHash); - - // Build datum with new owner, everything else preserved - const newDatum = buildAgentDatum( - newOwnerVkeyHash, profile.name, profile.description, - profile.capabilities, profile.framework, profile.endpoint, - profile.registeredAt - ); - const registryScript = { type: 'PlutusV3' as const, script: REGISTRY_SCRIPT_CBOR }; - const registryAddress = validatorToAddress('Mainnet', registryScript); - const spendRedeemer = Data.to(new Constr(0, [])); // Update (transfer uses Update redeemer) - - const tx = await lucid.newTx() - .collectFrom([utxo], spendRedeemer) - .attach.SpendingValidator(registryScript) - .pay.ToAddressWithData(registryAddress, { kind: "inline", value: newDatum }, { lovelace: MIN_AP3X_DEPOSIT, [nftUnit]: 1n }) - .addSigner(walletAddress) - .complete(); - const signedTx = await tx.sign.withWallet().complete(); - const txHash = await signedTx.submit(); - safetyLayer.recordTransaction(txHash, 0, registryAddress); - + const lucid = await lucidForAddress(provider, changeAddress); + const r = await buildTransferAgent(lucid, provider, { changeAddress, agentId: agent_id, newOwnerAddress: new_owner_address }); return { content: [{ - type: "text", - text: `# Agent Transferred Successfully + type: "text" as const, + text: `# Unsigned Transfer Built (Not Submitted) **Agent DID:** ${agent_id} -**Name:** ${profile.name} -**Previous owner:** ${walletAddress} +**Name:** ${r.agentName} +**Current owner (changeAddress):** ${changeAddress} **New owner:** ${new_owner_address} -**Transaction:** ${txHash} +**Estimated Fee:** ${r.feeAda} AP3X -[View on Explorer](${explorerTxLink(txHash)}) +**Unsigned TX CBOR:** +\`\`\` +${r.txCbor} +\`\`\` -Ownership has been transferred. The new owner can now update, transfer, or deregister this agent.`, +After this transaction confirms, only the new owner can update, transfer, or deregister the agent. ${SIGN_AND_SUBMIT_COPY}`, }], }; } catch (err) { return { content: [{ - type: "text", - text: `Failed to transfer agent: ${err.message} + type: "text" as const, + text: `Failed to build agent transfer: ${err instanceof Error ? err.message : String(err)} **Troubleshooting Tips:** 1. Verify the agent DID is correct -2. Your wallet must be the agent's current owner -3. The new owner address must be a verification key address (not a script) -4. Ensure wallet has enough AP3X for transaction fees`, +2. The changeAddress must be the agent's current owner wallet +3. The new owner address must be a verification key address (not a script)`, }], }; } } ); - // vector_message_agent + // vector_build_deregister_agent — KEYLESS server.tool( - "vector_message_agent", - "Send an on-chain message to a registered Vector agent via TX metadata (label 674). Delivers 2 AP3X to the agent's owner address.", + "vector_build_deregister_agent", + "Build an UNSIGNED transaction that deregisters an agent (burns the identity NFT, returns the 10 AP3X deposit to the owner wallet). Takes no key material. Sign locally with vector_signer_sign, broadcast with vector_submit_transaction, then confirm with vector_await_transaction.", { - agent_id: z.string().describe("Recipient agent DID: did:vector:agent:{policyId}:{nftAssetName}"), - message_type: z.enum(["inquiry", "proposal", "result"]).describe("Type of message"), - payload: z.string().max(512).describe("Message payload (string, max 512 chars)"), - mnemonic: z.string().describe("15 or 24-word BIP39 mnemonic for the sending wallet"), + changeAddress: z.string().describe("The agent OWNER's wallet address (receives the returned deposit). Get it from vector_signer_get_address."), + agent_id: z.string().describe("Agent DID to deregister: did:vector:agent:{policyId}:{nftAssetName}"), }, - async ({ agent_id, message_type, payload, mnemonic }) => { + async ({ changeAddress, agent_id }) => { const rateLimited = checkRateLimit(); if (rateLimited) return rateLimited; try { const provider = newProvider(); - const { profile } = await resolveAgentUtxo(provider, agent_id); - if (!profile.ownerVkeyHash) throw new Error('Could not parse agent owner from registry datum'); - - const lucid = await initLucid(mnemonic); - const senderAddress = await lucid.wallet().address(); - const recipientAddress = credentialToAddress('Mainnet', { type: 'Key', hash: profile.ownerVkeyHash }); - const minAda = 2_000_000n; - const safetyCheck = safetyLayer.checkTransaction(Number(minAda)); - if (!safetyCheck.allowed) throw new Error(`Safety limit exceeded: ${safetyCheck.reason}`); - - const tx = await lucid.newTx() - .pay.ToAddress(recipientAddress, { lovelace: minAda }) - .attachMetadata(AGENT_MESSAGE_LABEL, { msg: ['a2a'], from: metadataStr(senderAddress), to: metadataStr(agent_id), type: message_type, payload: metadataStr(payload) }) - .complete(); - const signedTx = await tx.sign.withWallet().complete(); - const txHash = await signedTx.submit(); - safetyLayer.recordTransaction(txHash, Number(minAda), recipientAddress); - + const lucid = await lucidForAddress(provider, changeAddress); + const r = await buildDeregisterAgent(lucid, provider, { changeAddress, agentId: agent_id }); return { content: [{ - type: "text", - text: `# Message Sent + type: "text" as const, + text: `# Unsigned Deregistration Built (Not Submitted) -**To:** ${profile.name} (${agent_id}) -**Type:** ${message_type} -**Payload:** ${payload} -**Recipient Address:** ${recipientAddress} -**Transaction:** ${txHash} +**Agent DID:** ${agent_id} +**Name:** ${r.agentName} +**Deposit returned on confirmation:** ${lovelaceToAda(r.detail)} AP3X +**Estimated Fee:** ${r.feeAda} AP3X -[View on Explorer](${explorerTxLink(txHash)}) +**Unsigned TX CBOR:** +\`\`\` +${r.txCbor} +\`\`\` -The message is recorded on-chain in TX metadata label 674.`, +The identity NFT is burned by this transaction; the deposit returns to the changeAddress as change. ${SIGN_AND_SUBMIT_COPY}`, }], }; } catch (err) { return { content: [{ - type: "text", - text: `Failed to send message: ${err.message} + type: "text" as const, + text: `Failed to build agent deregistration: ${err instanceof Error ? err.message : String(err)} + +**Troubleshooting Tips:** +1. Verify the agent DID is correct +2. The changeAddress must be the agent's current owner wallet +3. Use vector_get_agent_profile to check the agent's current state +4. If the error mentions needing a plain AP3X-only UTxO: the wallet must hold at least one token-free UTxO; send yourself a small AP3X payment first.`, + }], + }; + } + } + ); + + // vector_build_message_agent — KEYLESS + server.tool( + "vector_build_message_agent", + "Build an UNSIGNED transaction that sends an on-chain message to a registered agent via TX metadata (label 674), delivering 2 AP3X to the agent's owner address. Takes no key material. Sign locally with vector_signer_sign, broadcast with vector_submit_transaction, then confirm with vector_await_transaction.", + { + changeAddress: z.string().describe("Your wallet address (pays the 2 AP3X delivery and fee; recorded as the sender). Get it from vector_signer_get_address."), + agent_id: z.string().describe("Recipient agent DID: did:vector:agent:{policyId}:{nftAssetName}"), + message_type: z.enum(["inquiry", "proposal", "result"]).describe("Type of message"), + payload: z.string().max(512).describe("Message payload (string, max 512 chars)"), + }, + async ({ changeAddress, agent_id, message_type, payload }) => { + const rateLimited = checkRateLimit(); + if (rateLimited) return rateLimited; + try { + const provider = newProvider(); + const lucid = await lucidForAddress(provider, changeAddress); + const r = await buildMessageAgent(lucid, provider, { changeAddress, agentId: agent_id, messageType: message_type, payload }); + return { + content: [{ + type: "text" as const, + text: `# Unsigned Message Built (Not Submitted) + +**To:** ${r.agentName} (${agent_id}) +**Type:** ${message_type} +**Payload:** ${payload} +**Recipient Address:** ${r.recipientAddress} +**Delivery:** 2 AP3X + ${r.feeAda} AP3X fee + +**Unsigned TX CBOR:** +\`\`\` +${r.txCbor} +\`\`\` + +The message will be recorded on-chain in TX metadata label 674 once submitted. ${SIGN_AND_SUBMIT_COPY}`, + }], + }; + } catch (err) { + return { + content: [{ + type: "text" as const, + text: `Failed to build agent message: ${err instanceof Error ? err.message : String(err)} **Troubleshooting Tips:** 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. Server spend limits for this tool are set via VECTOR_SPEND_LIMIT_PER_TX / VECTOR_SPEND_LIMIT_DAILY`, +2. Ensure the changeAddress wallet holds at least 3 AP3X (2 AP3X delivery + fees) +3. Use vector_get_agent_profile to verify the agent exists first`, }], }; } diff --git a/packages/builder/src/vector/build.ts b/packages/builder/src/vector/build.ts index 0446096..a9ea847 100644 --- a/packages/builder/src/vector/build.ts +++ b/packages/builder/src/vector/build.ts @@ -36,7 +36,7 @@ export async function lucidForAddress(provider: Provider, changeAddress: string) return lucid; } -function toBuildResult(completed: TxSignBuilder): VectorUnsignedBuildResult { +export function toBuildResult(completed: TxSignBuilder): VectorUnsignedBuildResult { const txJson = (completed as any).toJSON?.() ?? {}; const fee = String(txJson?.body?.fee ?? '0'); return { diff --git a/packages/builder/src/vector/registry-build.ts b/packages/builder/src/vector/registry-build.ts new file mode 100644 index 0000000..7b081bd --- /dev/null +++ b/packages/builder/src/vector/registry-build.ts @@ -0,0 +1,357 @@ +// Keyless build core for the agent-registry family (spec PR 7). +// Constructs UNSIGNED transactions only. The user's key never enters this +// package: signing happens on the local signer (packages/signer), submission +// via vector_submit_transaction. The registry validator itself enforces +// ownership on-chain; the verifyOwnership() check here is a fail-fast courtesy. +import { + fromText, toText, Data, Constr, validatorToAddress, getAddressDetails, credentialToAddress, +} from '@lucid-evolution/lucid'; +import type { LucidEvolution, SpendingValidator, UTxO } from '@lucid-evolution/lucid'; +import type { OgmiosProvider } from '@apexfusion/vector-mcp-shared/provider'; +import { deriveNftAssetName, metadataStr } from '@apexfusion/vector-mcp-shared/tx'; +import type { + VectorBuildRegisterResult, VectorBuildAgentOpResult, VectorBuildMessageResult, +} from '@apexfusion/vector-mcp-shared/types'; +import { toBuildResult } from './build.js'; + +// Registry constants - agent-registry v2 (audited, compliant) +// Source blueprint: vector-ai-agents/agent-registry/deploy/agent-registry/plutus.json +export const REGISTRY_SCRIPT_CBOR = '5909d101010029800aba2aba1aba0aab9faab9eaab9dab9a4888888966003300130033754011370e90004dc3a40052300730080019180398041804000c8c01cc020c020c020c02000644b30010018a40011337009001198010011804800a00c91803980418041804000c88c8c8cc004004010896600200300389919912cc004cdc8803801456600266e3c01c00a20030064025133005005300f00440246eb8c020004dd598048009805800a01214bd6f7b630488c8cc00400400c896600200314a115980098019805000c528c4cc008008c02c00500520109ba5480026e1d2004488888888888a60026026019301200c912cc004c034c040dd5000c4c8c8cc004004dd6180b180b980b980b980b980b980b980b980b98099baa0042259800800c528456600266e3cdd7180b800801c528c4cc008008c060005012202a375c602860226ea8006294100f48966002601a60206ea800a2646464646464653001375a6036003375c603600d375c603600b37586036009375c6036007375c60360049111112cc004c08801e2646644b3001301d002899192cc004c09c00a0071640906eb8c094004c084dd5001c566002603800513232598009813801400e2c8120dd7181280098109baa0038b203e407c603c6ea80044c8cc00400401489660020030118991980180198130011bae30240014088604201b16407c301b001301a00130190013018001301700130160013011375400516403d259800980618079baa0018a518a504039300600691119199119801001000912cc00400600713233225980099b910070028acc004cdc78038014400600c80b226600a00a603800880b0dd7180a8009bad3016001301800140586601000800629000488c8cc00400400c896600200314c103d87a80008992cc004c0100062600e6602c00297ae0899801801980c001202430160014050911111114c004c0680226034603601125980099b89371a6eb8c048c060dd5000a41000915980099b89371a6eb8c044c060dd5000a41002115980099b89371a6eb8c040c060dd5000a41000515980099b89371a6eb8c06cc070c070c070c070c070c060dd5000a41001115980099b8930043758601c60306ea80052040899198008009bac300f3019375400444b30010018a518acc004cdc49b8d375c603a00290400144cc008008c078006294101820368a50405914a080b229410164528202c8a50405929800800d220100a44100400c9111192cc004c0600062646644b3001301b0018992cc004c090006264b3001301d30203754003132323322598009814801c0222c8130dd698130009bae302600230260013021375400316407c6046003164084603e6ea80222b3001301a0018acc004c07cdd5004400a2c81022c80e901d0992cc004c068c074dd5003c4c8cc89660026602a6eb0c090c084dd5008919baf30253022375400200915980099912cc0040060051598009813800c4c96600266e3cdd71811800802c4c07cdd69812000c52820443026001801204840902940cc058dd5980c98109baa011005899912cc0040060051598009813800c4c966002602c60466ea8006264660260022b300130123028302537540031598009980a00a981418129baa00189806000c52820468a50408c604e60486ea80062c8110c070c08cdd51813000c009024204814a064660020026eb0c06cc088dd5009112cc004006297ae0899912cc0056600266ebcc0a0c094dd5001002c566002b3001301730243754603c604a6ea800a294629410234566002604130013756603e604a6ea800a013006404113371290406d620498059bab301f3025375400514a0811a29410234528204689981380119802002000c4cc01001000502318130009813800a0488a50407d14a080f8dca1bb300130020033021301e375400f1332259800800c00a2b30013024001899192cc004cdc39bad30220024800626464b300130203023375400313259800980b98121baa0018991980a00089980a80b181498131baa00130283025375400316408c603a60486ea8c078c090dd5181398121baa0018a5040886601a6eb0c098c08cdd500992cc004cdd7981398121baa301e302437540020051301f98009bab301e30243754603c60486ea8006011003403d14a08110c01401a2c8100dd718100009811800c009021204214a0660266eacc058c078dd5007001203823011330203374a9001198101ba90014bd7019810260103d87a80004bd70180e1baa006375c603e60386ea80122b30013017001899199119912cc004c0740062b3001302137540150028b20448acc004c0700062b3001302137540150028b20448b203e407c2b3001301b301e3754003198009811180f9baa00191192cc004c078c084dd5000c4c094c088dd5180e18111baa30253022375400316408066016004466ebcc094c088dd500080148c966002603860406ea800626eb8c090c084dd5000c5901f181198101baa00191192cc004c078c084dd5000c4dd5980e18111baa301c30223754604a60446ea80062c8100cc02c0088cdd7981298111baa00100291192cc004c098006264b3001301e375a60460031375c6044003164084604a00316408c6602c004002911112cc004c080c08cdd5006c4c8cc88cc896600266030032605860526ea802a2b3001302432330010013758605a60546ea8068896600200314800226644b30013375e6060605a6ea8c09cc0b4dd5001004c4cdc0000a4005100140ac605c00266004004605e0028162264b30013026302937540031323259800980f18159baa0018991980d8008acc004cdc39bad3005302d37540026eb4c014c0b4dd5007456600260346060605a6ea80062b30013014001899b89301300730133756604e605a6ea800e294102b452820568a5040ac605e60586ea80062c8150c090c0acdd5000981698151baa0018a5040a0660266eb0c088c0a4dd500c92cc004cdd7981698151baa00100689812cc004dd5981218151baa001802c00d015452820508a50409d14a08138cc0100040088c0acc0b0c0b0c0b0c0b0c0b0c0b0004c010004cc00cdd6181418129baa015008330043758604e60486ea805001e26464b30013301501630293026375400f15980099912cc0040060051598009816000c4c96600266e3cdd7181400080244cdc39bad30290014800629410271815800c009029205214a0660366eacc078c098dd500b00144c9660026046604c6ea8006264660386eb0c084c0a0dd500c12cc004cdd7981618149baa302c30293754002605860526ea800a266e252080dac409300f3756604660526ea80062941027180d19814980d19814981518139baa0014bd7019814a6103d87a80004bd704528204a30293026375400f14a08122294102419801198019bac30283025375402a0100026006660086eb0c09cc090dd500a003a04445901d180f1baa008302000130203021001301c375400916406880d0c068dd500188a4d13656400401'; +export const REGISTRY_POLICY_ID = 'be1a0a2912da180757ed3cd61b56bb8eab0188c19dc3c0e3912d2c01'; +export const MIN_AP3X_DEPOSIT = 10_000_000n; +export const AGENT_MESSAGE_LABEL = 674; + +export function registryScript(): SpendingValidator { + return { type: 'PlutusV3', script: REGISTRY_SCRIPT_CBOR }; +} + +let _registryAddress: string | null = null; +export function getRegistryAddress(): string { + if (!_registryAddress) _registryAddress = validatorToAddress('Mainnet', registryScript()); + return _registryAddress; +} + +export interface RegistryAgentProfile { + agentId: string; + name: string; + description: string; + capabilities: string[]; + framework: string; + endpoint: string; + registeredAt: number; + utxoRef: string; + ownerVkeyHash: string; +} + +export function paymentKeyHashOf(address: string): string { + let details; + try { + details = getAddressDetails(address); + } catch (e) { + throw new Error(`Invalid address: ${e instanceof Error ? e.message : String(e)}`); + } + if (details.paymentCredential?.type !== 'Key' || !details.paymentCredential.hash) { + throw new Error('Address must have a verification key payment credential, not a script credential.'); + } + return details.paymentCredential.hash; +} + +export function validateEndpoint(endpoint: string): void { + if (!endpoint) return; // empty string is allowed + try { + new URL(endpoint); + } catch { + throw new Error(`Invalid endpoint URL: "${endpoint}". Must be a valid URL (e.g. https://example.com/api) or empty string.`); + } +} + +export function validateCapabilities(capabilities: string[]): void { + for (const cap of capabilities) { + if (typeof cap !== 'string' || cap.trim().length === 0) { + throw new Error('Each capability must be a non-empty string.'); + } + } +} + +export function buildAgentDatum( + vkeyHash: string, name: string, description: string, capabilities: string[], + framework: string, endpoint: string, registeredAt?: number, +): string { + return Data.to(new Constr(0, [ + new Constr(0, [vkeyHash]), + fromText(name), + fromText(description), + capabilities.map((c) => fromText(c)), + fromText(framework), + fromText(endpoint), + BigInt(registeredAt ?? Date.now()), + ])); +} + +export function parseAgentDatum( + datumCbor: string, utxoRef: string, assets: Record, +): RegistryAgentProfile | null { + try { + const c = Data.from(datumCbor); + if (!(c instanceof Constr) || c.index !== 0 || c.fields.length !== 7) return null; + const ownerCred = c.fields[0]; + if (!(ownerCred instanceof Constr) || typeof ownerCred.fields[0] !== 'string') return null; + const nftUnit = Object.keys(assets).find( + (u) => u.startsWith(REGISTRY_POLICY_ID) && assets[u] === 1n, + ); + const nftAssetName = nftUnit ? nftUnit.slice(REGISTRY_POLICY_ID.length) : ''; + return { + agentId: `did:vector:agent:${REGISTRY_POLICY_ID}:${nftAssetName}`, + name: toText(c.fields[1] as string), + description: toText(c.fields[2] as string), + capabilities: (c.fields[3] as string[]).map(toText), + framework: toText(c.fields[4] as string), + endpoint: toText(c.fields[5] as string), + registeredAt: Number(c.fields[6] as bigint), + utxoRef, + ownerVkeyHash: ownerCred.fields[0], + }; + } catch (err) { + console.warn(`parseAgentDatum failed for ${utxoRef}:`, err instanceof Error ? err.message : err); + return null; + } +} + +export function parseDid(agentId: string): { policyId: string; assetName: string; unit: string } { + const parts = agentId.split(':'); + if (parts.length !== 5 || parts[0] !== 'did' || parts[1] !== 'vector' || parts[2] !== 'agent') { + throw new Error('Invalid agent DID format. Expected: did:vector:agent:{policyId}:{nftAssetName}'); + } + if (!/^[a-f0-9]+$/.test(parts[3]) || !/^[a-f0-9]+$/.test(parts[4])) { + throw new Error('Invalid agent DID: policyId and assetName must be hex strings.'); + } + return { policyId: parts[3], assetName: parts[4], unit: `${parts[3]}${parts[4]}` }; +} + +export async function resolveAgentUtxo( + provider: Pick, agentId: string, +): Promise<{ profile: RegistryAgentProfile; utxo: UTxO; nftUnit: string }> { + const { unit } = parseDid(agentId); + let utxo: UTxO | undefined; + try { + utxo = await provider.getUtxoByUnit(unit); + } catch { + // Koios unavailable, asset not found, or fixture provider - fall through to the address scan + } + if (!utxo || !utxo.datum) { + const allUtxos = await provider.getUtxos(getRegistryAddress()); + const scanned = allUtxos.find((u) => u.assets[unit] && u.assets[unit] > 0n); + if (scanned) utxo = scanned; + } + if (!utxo) throw new Error(`Agent not found: no UTxO holds NFT ${unit}. The agent may not exist or may have deregistered.`); + if (!utxo.datum) throw new Error('Registry UTxO found but has no inline datum.'); + const profile = parseAgentDatum(utxo.datum, `${utxo.txHash}#${utxo.outputIndex}`, utxo.assets); + if (!profile) throw new Error('Could not parse agent datum. The on-chain data may be malformed.'); + return { profile, utxo, nftUnit: unit }; +} + +export function verifyOwnership(profile: RegistryAgentProfile, walletVkeyHash: string): void { + if (profile.ownerVkeyHash !== walletVkeyHash) { + throw new Error("Ownership check failed: the changeAddress wallet does not own this agent. The agent's owner verification key does not match that wallet's payment key. (The on-chain validator would also reject this.)"); + } +} + +export async function buildRegisterAgent(lucid: LucidEvolution, p: { + changeAddress: string; name: string; description: string; + capabilities: string[]; framework: string; endpoint: string; +}): Promise { + validateEndpoint(p.endpoint); + validateCapabilities(p.capabilities); + const vkeyHash = paymentKeyHashOf(p.changeAddress); + const utxos = await lucid.wallet().getUtxos(); + // Same selection heuristic as the old custodial tool (behavior parity): + // prefer a pure-lovelace UTxO covering deposit + fee headroom, else the first. + 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 (!oneShotUtxo) throw new Error('No UTxOs at the changeAddress. Fund the wallet first.'); + const nftAssetName = deriveNftAssetName(oneShotUtxo.txHash, oneShotUtxo.outputIndex); + const nftUnit = `${REGISTRY_POLICY_ID}${nftAssetName}`; + const datum = buildAgentDatum(vkeyHash, p.name, p.description, p.capabilities, p.framework, p.endpoint); + const registerRedeemer = Data.to(new Constr(0, [new Constr(0, [oneShotUtxo.txHash, BigInt(oneShotUtxo.outputIndex)])])); + const completed = await lucid.newTx() + .collectFrom([oneShotUtxo]) + .mintAssets({ [nftUnit]: 1n }, registerRedeemer) + .attach.MintingPolicy(registryScript()) + .pay.ToAddressWithData(getRegistryAddress(), { kind: 'inline', value: datum }, { lovelace: MIN_AP3X_DEPOSIT, [nftUnit]: 1n }) + .addSigner(p.changeAddress) + .complete(); + return { + ...toBuildResult(completed), + agentId: `did:vector:agent:${REGISTRY_POLICY_ID}:${nftAssetName}`, + nftAssetName, + registryAddress: getRegistryAddress(), + depositLovelace: MIN_AP3X_DEPOSIT.toString(), + }; +} + +export async function buildUpdateAgent( + lucid: LucidEvolution, + provider: Pick, + p: { + changeAddress: string; agentId: string; name?: string; description?: string; + capabilities?: string[]; framework?: string; endpoint?: string; + }, +): Promise { + if (p.name === undefined && p.description === undefined && p.capabilities === undefined + && p.framework === undefined && p.endpoint === undefined) { + throw new Error('At least one field must be provided to update (name, description, capabilities, framework, or endpoint).'); + } + if (p.endpoint !== undefined) validateEndpoint(p.endpoint); + if (p.capabilities !== undefined) validateCapabilities(p.capabilities); + const vkeyHash = paymentKeyHashOf(p.changeAddress); + const { profile, utxo } = await resolveAgentUtxo(provider, p.agentId); + verifyOwnership(profile, vkeyHash); + // Owner is written from profile.ownerVkeyHash, not the caller's vkeyHash: + // makes owner-immutability on this path structural (the datum literally + // cannot carry a different owner out of buildUpdateAgent) rather than + // depending solely on the verifyOwnership() call two lines up. + const newDatum = buildAgentDatum( + profile.ownerVkeyHash, + p.name ?? profile.name, + p.description ?? profile.description, + p.capabilities ?? profile.capabilities, + p.framework ?? profile.framework, + p.endpoint ?? profile.endpoint, + profile.registeredAt, + ); + const spendRedeemer = Data.to(new Constr(0, [])); // Update + // The continuing output must carry the spent input's value EXACTLY + // (utxo.assets - lovelace AND the NFT), not a hardcoded + // { lovelace: MIN_AP3X_DEPOSIT, [nftUnit]: 1n } - the deployed validator + // enforces value preservation on Update (differential-tested; see + // task-3-report.md's correction section). Hardcoding the floor here was + // the same latent defect the old custodial code had. + const completed = await lucid.newTx() + .collectFrom([utxo], spendRedeemer) + .attach.SpendingValidator(registryScript()) + .pay.ToAddressWithData(getRegistryAddress(), { kind: 'inline', value: newDatum }, utxo.assets) + .addSigner(p.changeAddress) + .complete(); + const updatedFields: string[] = []; + if (p.name !== undefined) updatedFields.push('name'); + if (p.description !== undefined) updatedFields.push('description'); + if (p.capabilities !== undefined) updatedFields.push('capabilities'); + if (p.framework !== undefined) updatedFields.push('framework'); + if (p.endpoint !== undefined) updatedFields.push('endpoint'); + return { + ...toBuildResult(completed), + agentId: p.agentId, op: 'update', agentName: profile.name, detail: updatedFields.join(', '), + }; +} + +export async function buildTransferAgent( + lucid: LucidEvolution, + provider: Pick, + p: { changeAddress: string; agentId: string; newOwnerAddress: string }, +): Promise { + const vkeyHash = paymentKeyHashOf(p.changeAddress); + let newOwnerHash: string; + try { + newOwnerHash = paymentKeyHashOf(p.newOwnerAddress); + } catch (e) { + throw new Error(`Invalid new owner address: ${e instanceof Error ? e.message : String(e)} The on-chain contract rejects script credentials as owners.`); + } + const { profile, utxo } = await resolveAgentUtxo(provider, p.agentId); + verifyOwnership(profile, vkeyHash); + const newDatum = buildAgentDatum( + newOwnerHash, profile.name, profile.description, + profile.capabilities, profile.framework, profile.endpoint, profile.registeredAt, + ); + const spendRedeemer = Data.to(new Constr(0, [])); // Update (transfer uses the Update redeemer) + // Value-preserving output, same reasoning as buildUpdateAgent above: the + // deployed validator requires the continuing output to carry the spent + // input's value exactly, not a hardcoded deposit-floor amount. + const completed = await lucid.newTx() + .collectFrom([utxo], spendRedeemer) + .attach.SpendingValidator(registryScript()) + .pay.ToAddressWithData(getRegistryAddress(), { kind: 'inline', value: newDatum }, utxo.assets) + .addSigner(p.changeAddress) + .complete(); + return { + ...toBuildResult(completed), + agentId: p.agentId, op: 'transfer', agentName: profile.name, detail: p.newOwnerAddress, + }; +} + +export async function buildDeregisterAgent( + lucid: LucidEvolution, + provider: Pick, + p: { changeAddress: string; agentId: string }, +): Promise { + const vkeyHash = paymentKeyHashOf(p.changeAddress); + const { profile, utxo, nftUnit } = await resolveAgentUtxo(provider, p.agentId); + verifyOwnership(profile, vkeyHash); + const spendRedeemer = Data.to(new Constr(1, [])); // Deregister + const mintRedeemer = Data.to(new Constr(1, [])); // Burn + // CORRECTED root cause (see task-3-report.md's correction section for the + // full account - this replaces an earlier, wrong "Lucid library bug" + // diagnosis). The DEPLOYED registry validator (REGISTRY_SCRIPT_CBOR / + // REGISTRY_POLICY_ID above) does NOT match the in-workspace Aiken source + // under agent-infrastructure/contracts/agent-registry/ - that source + // compiles to policy 5dd51189..., a different script entirely (confirmed + // by hashing both; provenance worth resolving before the governance + // family trusts that source as ground truth). Differential-tested + // directly against the deployed bytecode instead: deregister enforces a + // floor on the owner's returned change - a sole-input deregister of a + // registry UTxO holding exactly MIN_AP3X_DEPOSIT fails, the identical + // build against a 25 AP3X registry UTxO succeeds unaided. Feeding in a + // second, plain pure-AP3X wallet UTxO - the LARGEST one available, so the + // returned change clears the floor with margin rather than by a hair - + // satisfies it for the common exactly-the-floor case. + const walletUtxos = await lucid.wallet().getUtxos(); + const feeUtxo = walletUtxos + .filter((u) => { + const keys = Object.keys(u.assets); + return keys.length === 1 && keys[0] === 'lovelace'; + }) + .sort((a, b) => Number(b.assets.lovelace - a.assets.lovelace))[0]; + if (!feeUtxo) { + throw new Error("Deregistration needs a plain AP3X-only UTxO in the wallet to clear the validator's returned-change floor; send yourself a small AP3X-only payment first."); + } + const completed = await lucid.newTx() + .collectFrom([utxo], spendRedeemer) + .attach.SpendingValidator(registryScript()) + .collectFrom([feeUtxo]) + .mintAssets({ [nftUnit]: -1n }, mintRedeemer) + .attach.MintingPolicy(registryScript()) + .addSigner(p.changeAddress) + .complete(); + return { + ...toBuildResult(completed), + agentId: p.agentId, op: 'deregister', agentName: profile.name, detail: String(utxo.assets.lovelace), + }; +} + +export async function buildMessageAgent( + lucid: LucidEvolution, + provider: Pick, + p: { changeAddress: string; agentId: string; messageType: string; payload: string }, +): Promise { + paymentKeyHashOf(p.changeAddress); // validate before any resolution work + const { profile } = await resolveAgentUtxo(provider, p.agentId); + if (!profile.ownerVkeyHash) throw new Error('Could not parse agent owner from registry datum'); + const recipientAddress = credentialToAddress('Mainnet', { type: 'Key', hash: profile.ownerVkeyHash }); + const completed = await lucid.newTx() + .pay.ToAddress(recipientAddress, { lovelace: 2_000_000n }) + .attachMetadata(AGENT_MESSAGE_LABEL, { + msg: ['a2a'], + from: metadataStr(p.changeAddress), + to: metadataStr(p.agentId), + type: p.messageType, + payload: metadataStr(p.payload), + }) + .complete(); + return { + ...toBuildResult(completed), + agentId: p.agentId, + agentName: profile.name, + recipientAddress, + }; +} diff --git a/packages/builder/src/vector/self-improvement.ts b/packages/builder/src/vector/self-improvement.ts index 91afc19..889e793 100644 --- a/packages/builder/src/vector/self-improvement.ts +++ b/packages/builder/src/vector/self-improvement.ts @@ -628,7 +628,7 @@ Each batch UTxO holds ~30 AP3X for adoption rewards.`, } catch { throw new Error( `Agent registry NFT not found on-chain. Expected token: ${AGENT_REGISTRY_POLICY.slice(0, 12)}...${agentDid.slice(0, 12)}... ` + - `The agent may need to re-register with vector_register_agent.` + `The agent may need to re-register with vector_build_register_agent.` ); } diff --git a/packages/builder/test/boundary.test.ts b/packages/builder/test/boundary.test.ts index d8d30de..9531a36 100644 --- a/packages/builder/test/boundary.test.ts +++ b/packages/builder/test/boundary.test.ts @@ -12,9 +12,9 @@ import { resolve, join, relative } from 'node:path'; * non-custodial split; growing it should be impossible to do accidentally. */ const SRC_ROOT = resolve(import.meta.dirname!, '../src'); +const SCRIPTS_ROOT = resolve(import.meta.dirname!, '../scripts'); 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 + 'vector/self-improvement.ts', // family 3 (governance) — 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 @@ -46,6 +46,16 @@ describe('builder custody boundary', () => { if (FORBIDDEN.test(line)) offenders.push(`${rel}:${i + 1}: ${line.trim()}`); }); } + // scripts/ is dev-tooling, not shipped builder source - it has no + // custodial carve-out, so nothing here is ever checked against the + // allowlist (unlike SRC_ROOT files above). + for (const file of sourceFiles(SCRIPTS_ROOT)) { + const rel = relative(SCRIPTS_ROOT, file).replace(/\\/g, '/'); + const lines = readFileSync(file, 'utf-8').split('\n'); + lines.forEach((line, i) => { + if (FORBIDDEN.test(line)) offenders.push(`scripts/${rel}:${i + 1}: ${line.trim()}`); + }); + } assert.deepEqual(offenders, [], `key-material vocabulary found in keyless builder source:\n${offenders.join('\n')}`); }); @@ -59,12 +69,12 @@ describe('builder custody boundary', () => { } }); - // The allowlist may only shrink (PR 7 drops agent-network.ts to reach 1, PR 8 - // drops self-improvement.ts to reach 0 - see the "goes keyless in spec PR N" - // comments above). Pinning the size means any widening is a deliberate, + // The allowlist may only shrink (PR 7 dropped agent-network.ts to reach 1, + // PR 8 drops self-improvement.ts to reach 0 - see the "goes keyless in spec + // PR N" comment 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'); + assert.equal(CUSTODIAL_UNTIL_LATER_PR.size, 1, 'the custodial allowlist may only shrink - spec PR 8 takes it to 0'); }); // Pins every FORBIDDEN alternative individually against a fixture corpus, so diff --git a/packages/builder/test/build-contracts.test.ts b/packages/builder/test/build-contracts.test.ts index e41a579..2e7ce7e 100644 --- a/packages/builder/test/build-contracts.test.ts +++ b/packages/builder/test/build-contracts.test.ts @@ -66,6 +66,14 @@ describe('buildDeployContract', () => { const vkeys = CML.Transaction.from_cbor_hex(r.txCbor).witness_set().vkeywitnesses(); assert.ok(vkeys === undefined || vkeys.len() === 0); }); + + test("deploy with initialDatum '' fails loudly instead of silently voiding", async () => { + // '' is falsy but caller-supplied; ?? must let it flow through and fail + // loudly downstream rather than defaulting to Data.void(). + await assert.rejects(buildDeployContract(lucid, { + scriptCbor: ALWAYS_SUCCEEDS_V2, scriptType: 'PlutusV2', initialDatum: '', + })); + }); }); describe('buildInteractContract', () => { @@ -89,6 +97,24 @@ describe('buildInteractContract', () => { })); }); + test('lock datum is inline (kind), not a hash reference', async () => { + const r = await buildInteractContract(lucid, { + scriptCbor: ALWAYS_SUCCEEDS_V2, scriptType: 'PlutusV2', action: 'lock', + changeAddress: OWN_ADDRESS, lovelaceAmount: 2_000_000, datum: 'd87980', + }); + const tx = CML.Transaction.from_cbor_hex(r.txCbor); + const outs = tx.body().outputs(); + let inlineSeen = false; + for (let i = 0; i < outs.len(); i++) { + const d = outs.get(i).datum(); + // CML DatumOptionKind: Hash = 0, Datum = 1 (verified against the + // package's own .d.ts) - kind() === 1 is an inline datum. + if (d && d.kind() === 1) inlineSeen = true; + if (d && d.kind() === 0) assert.fail('lock produced a datum HASH output - must be inline'); + } + assert.ok(inlineSeen, 'no inline-datum output found on the lock tx'); + }); + test('rejects an invalid action', async () => { await assert.rejects( () => buildInteractContract(lucid, { @@ -172,4 +198,19 @@ describe('buildInteractContract', () => { /not at script address/, ); }); + + test("spend: rejects an empty-string redeemer 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(). + 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); + await assert.rejects(buildInteractContract(l, { + scriptCbor: ALWAYS_SUCCEEDS_V2, scriptType: 'PlutusV2', action: 'spend', + changeAddress: OWN_ADDRESS, redeemer: '', + })); + }); }); diff --git a/packages/builder/test/integration/keyless-e2e.test.ts b/packages/builder/test/integration/keyless-e2e.test.ts index 5d4d34c..5829c1c 100644 --- a/packages/builder/test/integration/keyless-e2e.test.ts +++ b/packages/builder/test/integration/keyless-e2e.test.ts @@ -1,8 +1,9 @@ // 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 { walletFromSeed } from '@lucid-evolution/lucid'; import { startServer, stopServer, callTool, getMnemonic, ServerContext } from '../setup.ts'; +import { signWithMnemonic } from './sign-helper.ts'; // Full non-custodial pipeline against the live testnet: // keyless build (hosted tool) -> inline CML sign (this process, standing in @@ -31,7 +32,8 @@ describe('keyless E2E: build -> sign -> submit -> await', { skip: !RUN }, () => 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 }); + const mnemonic = getMnemonic(); + const { address } = walletFromSeed(mnemonic, { network: 'Mainnet', accountIndex: 0 }); // 1. keyless build (hosted) const buildText = await callTool(ctx.client, 'vector_build_send_apex', { @@ -40,21 +42,12 @@ describe('keyless E2E: build -> sign -> submit -> await', { skip: !RUN }, () => 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(); + const { signedCborHex: signedCbor, txHash } = signWithMnemonic(unsignedCbor, mnemonic); // 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'); + assert.equal(submittedHash, txHash, 'submitted hash differs from the locally computed body hash'); // 4. await confirmation (hosted) const awaitText = await callTool(ctx.client, 'vector_await_transaction', { diff --git a/packages/builder/test/integration/registry-e2e.test.ts b/packages/builder/test/integration/registry-e2e.test.ts new file mode 100644 index 0000000..c77b361 --- /dev/null +++ b/packages/builder/test/integration/registry-e2e.test.ts @@ -0,0 +1,83 @@ +import { describe, test, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { startServer, stopServer, callTool, getMnemonic, wait, ServerContext } from '../setup.ts'; +import { signWithMnemonic } from './sign-helper.ts'; +import { walletFromSeed } from '@lucid-evolution/lucid'; + +// FULL REGISTRY LIFECYCLE ON LIVE TESTNET - gated. Registers a real agent, +// updates it, deregisters it. Net cost ~= 3 tx fees (~0.6 AP3X); the 10 AP3X +// deposit round-trips. Requires packages/builder/mnemonic.txt (never committed). +// VECTOR_E2E_SUBMIT=1 node --import tsx --test packages/builder/test/integration/registry-e2e.test.ts +const GATE = process.env.VECTOR_E2E_SUBMIT === '1'; + +function extractCbor(text: string): string { + const m = text.match(/```\n([0-9a-f]+)\n```/); + assert.ok(m, `no CBOR block:\n${text.slice(0, 500)}`); + return m![1]; +} +function extractTxHash(text: string): string { + const m = text.match(/Transaction Hash:\s*([0-9a-f]{64})/); + assert.ok(m, `no tx hash:\n${text.slice(0, 500)}`); + return m![1]; +} + +(GATE ? describe : describe.skip)('registry lifecycle E2E (live testnet, SUBMITS)', () => { + let ctx: ServerContext; + let mnemonic: string; + let walletAddress: string; + let agentDid: string; + + before(async () => { + ctx = await startServer(); + mnemonic = getMnemonic(); + walletAddress = walletFromSeed(mnemonic, { network: 'Mainnet', accountIndex: 0 }).address; + }); + after(async () => { await stopServer(ctx); }); + + async function signSubmitAwait(buildText: string): Promise { + const { signedCborHex, txHash } = signWithMnemonic(extractCbor(buildText), mnemonic); + const submitText = await callTool(ctx.client, 'vector_submit_transaction', { signedTxCbor: signedCborHex }); + const submittedHash = extractTxHash(submitText); + assert.equal(submittedHash, txHash, 'submitted hash != locally computed hash'); + const awaitText = await callTool(ctx.client, 'vector_await_transaction', { txHash: submittedHash, timeoutSeconds: 240 }); + assert.match(awaitText, /Transaction Confirmed/); + return submittedHash; + } + + test('register -> confirm -> profile readable', { timeout: 420_000 }, async () => { + const buildText = await callTool(ctx.client, 'vector_build_register_agent', { + changeAddress: walletAddress, name: `E2EAgent-${Date.now()}`, + description: 'registry e2e lifecycle agent', capabilities: ['testing', 'e2e'], framework: 'custom', endpoint: '', + }); + const didMatch = buildText.match(/(did:vector:agent:[a-f0-9]+:[a-f0-9]+)/); + assert.ok(didMatch, 'no DID in register build'); + agentDid = didMatch![1]; + await signSubmitAwait(buildText); + await wait(5); + const profile = await callTool(ctx.client, 'vector_get_agent_profile', { agent_id: agentDid }); + assert.match(profile, /registry e2e lifecycle agent/); + }); + + test('update -> confirm -> profile shows the change', { timeout: 420_000 }, async () => { + await wait(10); // let register's UTxOs settle + const buildText = await callTool(ctx.client, 'vector_build_update_agent', { + changeAddress: walletAddress, agent_id: agentDid, description: 'updated by registry e2e', + }); + await signSubmitAwait(buildText); + await wait(5); + const profile = await callTool(ctx.client, 'vector_get_agent_profile', { agent_id: agentDid }); + assert.match(profile, /updated by registry e2e/); + }); + + test('deregister -> confirm -> agent gone', { timeout: 420_000 }, async () => { + await wait(10); + const buildText = await callTool(ctx.client, 'vector_build_deregister_agent', { + changeAddress: walletAddress, agent_id: agentDid, + }); + assert.match(buildText, /Deposit returned on confirmation/); + await signSubmitAwait(buildText); + await wait(5); + const profile = await callTool(ctx.client, 'vector_get_agent_profile', { agent_id: agentDid }); + assert.match(profile, /Agent not found|Failed to get agent profile/); + }); +}); diff --git a/packages/builder/test/integration/registry-keyless.test.ts b/packages/builder/test/integration/registry-keyless.test.ts new file mode 100644 index 0000000..bb56908 --- /dev/null +++ b/packages/builder/test/integration/registry-keyless.test.ts @@ -0,0 +1,95 @@ +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'; + +// Tier-1 registry integration: LIVE testnet, ZERO secrets. Build-only - nothing +// is signed, nothing is submitted, no funds move. The register build runs the +// REAL registry validator server-side during .complete(). +const OWN_ADDRESS = 'addr1qylasu4y34ccwt8hv55tkswa9fthtck9xtppvc4y03kwhaztd0jlxdzk72zj8mgy6x4269v9gytkgffh3k8d3l8987yqu5c35z'; + +let ctx: ServerContext; +before(async () => { ctx = await startServer(); }); +after(async () => { await stopServer(ctx); }); + +function extractCbor(text: string): string { + const m = text.match(/```\n([0-9a-f]+)\n```/); + assert.ok(m, `no CBOR block in response:\n${text.slice(0, 500)}`); + return m![1]; +} +function assertBuildNotSubmitted(text: string) { + assert.doesNotMatch(text, /(? { + let liveDid: string | null = null; + + test('discover returns live agents and yields a DID to work with', { timeout: 120_000 }, async () => { + const text = await callTool(ctx.client, 'vector_discover_agents', { limit: 3 }); + const m = text.match(/(did:vector:agent:[a-f0-9]+:[a-f0-9]+)/); + assert.ok(m, 'no DID found in discovery output - is the testnet registry empty?'); + liveDid = m![1]; + }); + + test('build_register_agent builds an evaluable unsigned registration', { timeout: 120_000 }, async () => { + const text = await callTool(ctx.client, 'vector_build_register_agent', { + changeAddress: OWN_ADDRESS, name: `Tier1Agent-${process.pid}`, + description: 'tier-1 build-only agent', capabilities: ['testing'], framework: 'custom', endpoint: '', + }); + assertBuildNotSubmitted(text); + // Brief specified /Agent DID:\s*did:vector:agent:/ - the live response is + // Markdown ("**Agent DID:** did:vector:agent:..."), so two literal '*' + // sit between the label and the value that \s* alone doesn't cover. + // Verified against the actual tier-1 live response (register-agent build + // succeeded; only this assertion's regex was too strict) before widening + // it, per Task 5's task-5-report.md self-review. + assert.match(text, /Agent DID:\*{0,2}\s*did:vector:agent:/); + const tx = CML.Transaction.from_cbor_hex(extractCbor(text)); + const vkeys = tx.witness_set().vkeywitnesses(); + assert.ok(vkeys === undefined || vkeys.len() === 0, 'register build must be unsigned'); + assert.ok(tx.body().mint(), 'register tx has no mint field'); + }); + + test('build_message_agent builds against a live agent', { timeout: 120_000 }, async () => { + assert.ok(liveDid, 'needs the DID from discovery'); + const text = await callTool(ctx.client, 'vector_build_message_agent', { + changeAddress: OWN_ADDRESS, agent_id: liveDid!, message_type: 'inquiry', payload: 'tier-1 build-only ping', + }); + assertBuildNotSubmitted(text); + const tx = CML.Transaction.from_cbor_hex(extractCbor(text)); + const aux = tx.auxiliary_data(); + assert.ok(aux && aux.metadata() && aux.metadata()!.get(674n), 'label-674 metadata missing'); + }); + + test('get_agent_profile resolves the live agent', { timeout: 120_000 }, async () => { + assert.ok(liveDid); + const text = await callTool(ctx.client, 'vector_get_agent_profile', { agent_id: liveDid! }); + assert.match(text, /Agent Profile/); + assert.match(text, /Registry UTxO:/); + }); + + test('a bogus changeAddress fails fast with a clear validation error', { timeout: 30_000 }, async () => { + const t0 = Date.now(); + const text = await callTool(ctx.client, 'vector_build_register_agent', { + changeAddress: 'addr1garbage', name: 'X', description: '', capabilities: [], framework: 'c', endpoint: '', + }); + assert.match(text, /Invalid change address/); + assert.ok(Date.now() - t0 < 5_000, 'validation should not have hit the network'); + }); + + test('non-owner update is refused at build time', { timeout: 120_000 }, async () => { + assert.ok(liveDid); + const text = await callTool(ctx.client, 'vector_build_update_agent', { + changeAddress: OWN_ADDRESS, agent_id: liveDid!, description: 'not mine', + }); + // The discovered agent belongs to another wallet (verified during planning: + // live datum owners differ from OWN_ADDRESS's key). Expect the ownership + // fail-fast specifically, not the broader "Failed to build agent update" + // wrapper it's nested inside - that second alternative would also match + // any OTHER build failure (a real regression), so it isn't a safe + // stand-in for "ownership was checked and correctly refused." Reviewer- + // confirmed against today's live response; not re-run live for this change. + assert.match(text, /Ownership check failed/); + }); +}); diff --git a/packages/builder/test/integration/run.test.ts b/packages/builder/test/integration/run.test.ts index 89bb122..2ba954c 100644 --- a/packages/builder/test/integration/run.test.ts +++ b/packages/builder/test/integration/run.test.ts @@ -2,6 +2,7 @@ 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'; +import { signWithMnemonic } from './sign-helper.ts'; // Always-succeeds PlutusV2 validator (accepts any datum/redeemer/context, returns True) const ALWAYS_SUCCEEDS_V2 = '49480100002221200101'; @@ -222,27 +223,85 @@ describe('Smart Contract Tools', () => { }); // ─── Agent Network Tools ──────────────────────────────────────────────────── +// Keyless since spec PR 7: each old custodial call (mnemonic in, submitted +// on-chain out, all inside one tool call) is now build_* (hosted, unsigned) +// -> signWithMnemonic (local, stands in for the local signer) -> +// vector_submit_transaction (hosted) - the same non-custodial flow the +// Transaction Tools tests above and keyless-e2e.test.ts already exercise. +// vector_discover_agents/vector_get_agent_profile are read-only and were +// never custodial - both unchanged (tool name and call shape). walletHasAda +// gating, the settle waits, and the assertSuccessOrKnownError convention are +// unchanged from the pre-keyless version of this section. +// +// Known/accepted risk, same shape as the pre-keyless suite: if register +// submits but a later step in this chain fails before deregister runs, the +// 10 AP3X deposit stays locked on-chain under this test agent - nothing in +// this file reclaims it automatically. + +// Extracts the CBOR block a build_* tool response embeds ("```\n\n```"), +// or null if the build didn't produce one (a known-error response, e.g. an +// unfunded wallet) - lets callers skip signing/submitting instead of crashing +// on a missing match. +function extractCborOrNull(text: string): string | null { + const m = text.match(/```\n([0-9a-f]+)\n```/); + return m ? m[1] : null; +} + +// build_* (hosted, unsigned) -> signWithMnemonic (local) -> +// vector_submit_transaction (hosted). Reuses assertSuccessOrKnownError at +// both legs: the build may legitimately fail a known way on an unfunded +// wallet (no sign/submit attempted then), and submit is only attempted once +// the build actually produced CBOR to sign. Returns the build response text +// (callers that need e.g. the registered DID parse it from there) plus +// whether the transaction was actually submitted, so callers only chain +// follow-on state (agentDid) off a real submission, not just a successful build. +async function buildSignSubmit( + toolName: string, args: Record, buildSuccessPattern: RegExp, +): Promise<{ buildText: string; submitted: boolean }> { + const buildText = await callTool(ctx.client, toolName, args); + console.log(buildText); + assertSuccessOrKnownError(buildText, buildSuccessPattern, toolName); + const cbor = extractCborOrNull(buildText); + if (!buildSuccessPattern.test(buildText) || !cbor) { + console.log(` ⚠ ${toolName}: build did not succeed - skipping sign/submit`); + return { buildText, submitted: false }; + } + const { signedCborHex } = signWithMnemonic(cbor, mnemonic); + const submitText = await callTool(ctx.client, 'vector_submit_transaction', { signedTxCbor: signedCborHex }); + console.log(submitText); + // A successful build against a FUNDED wallet is expected to submit + // successfully too - hard-assert here instead of the tolerant + // assertSuccessOrKnownError, which would otherwise treat a real submit + // regression (e.g. a signing defect - see task-5-report.md's Bug #1) as + // an indistinguishable "known error, wallet may be unfunded" and let it + // pass silently. Falls back to the graceful known-error path when the + // wallet isn't funded (mirrors every other assertion in this file). + if (walletHasAda) { + assert.match(submitText, /Transaction Submitted/, `${toolName} (submit): expected success on a funded wallet, got: ${submitText.slice(0, 300)}`); + } else { + assertSuccessOrKnownError(submitText, /Transaction Submitted/, `${toolName} (submit)`); + } + return { buildText, submitted: /Transaction Submitted/.test(submitText) }; +} describe('Agent Network Tools', () => { - test('vector_register_agent', { timeout: 120_000 }, async () => { + test('vector_build_register_agent -> sign -> submit', { timeout: 120_000 }, async () => { if (walletHasAda) { console.log('Waiting 10s for UTxOs to settle before register...'); await wait(10); } const timestamp = Date.now(); - const text = await callTool(ctx.client, 'vector_register_agent', { - mnemonic, + const { buildText, submitted } = await buildSignSubmit('vector_build_register_agent', { + changeAddress: walletAddress, name: `TestAgent-${timestamp}`, description: 'Integration test agent', capabilities: ['testing'], framework: 'custom', endpoint: '', - }); - console.log(text); - assertSuccessOrKnownError(text, /Agent DID:/, 'vector_register_agent'); + }, /Agent DID:/); - const didMatch = text.match(/(did:vector:agent:[a-f0-9]+:[a-f0-9]+)/); - if (didMatch) { + const didMatch = buildText.match(/(did:vector:agent:[a-f0-9]+:[a-f0-9]+)/); + if (submitted && didMatch) { agentDid = didMatch[1]; console.log(`Registered agent DID: ${agentDid}`); } @@ -266,7 +325,7 @@ describe('Agent Network Tools', () => { assertSuccessOrKnownError(text, /Agent Profile/, 'vector_get_agent_profile'); }); - test('vector_update_agent', { timeout: 120_000 }, async () => { + test('vector_build_update_agent -> sign -> submit', { timeout: 120_000 }, async () => { if (!agentDid) { console.log('Skipping update — no agent registered'); return; @@ -275,16 +334,14 @@ describe('Agent Network Tools', () => { console.log('Waiting 10s for UTxOs to settle before update...'); await wait(10); } - const text = await callTool(ctx.client, 'vector_update_agent', { - mnemonic, + await buildSignSubmit('vector_build_update_agent', { + changeAddress: walletAddress, agent_id: agentDid, description: 'Updated integration test agent', - }); - console.log(text); - assertSuccessOrKnownError(text, /Agent Updated|Updated fields/, 'vector_update_agent'); + }, /Agent DID:/); }); - test('vector_transfer_agent (to self)', { timeout: 120_000 }, async () => { + test('vector_build_transfer_agent (to self) -> sign -> submit', { timeout: 120_000 }, async () => { if (!agentDid || !walletAddress) { console.log('Skipping transfer — no agent registered or no wallet address'); return; @@ -293,33 +350,29 @@ describe('Agent Network Tools', () => { console.log('Waiting 10s for UTxOs to settle before transfer...'); await wait(10); } - const text = await callTool(ctx.client, 'vector_transfer_agent', { - mnemonic, + await buildSignSubmit('vector_build_transfer_agent', { + changeAddress: walletAddress, agent_id: agentDid, new_owner_address: walletAddress, - }); - console.log(text); - assertSuccessOrKnownError(text, /Agent Transferred|Transfer/, 'vector_transfer_agent'); + }, /Agent DID:/); }); - test('vector_message_agent', { timeout: 120_000 }, async () => { + test('vector_build_message_agent -> sign -> submit', { timeout: 120_000 }, async () => { // Use a known DID format even if registration failed, to test the tool responds const testDid = agentDid || `did:vector:agent:${ALWAYS_SUCCEEDS_V2}:${'a'.repeat(64)}`; if (walletHasAda && agentDid) { console.log('Waiting 10s for UTxOs to settle before message...'); await wait(10); } - const text = await callTool(ctx.client, 'vector_message_agent', { + await buildSignSubmit('vector_build_message_agent', { + changeAddress: walletAddress, agent_id: testDid, message_type: 'inquiry', payload: 'integration test ping', - mnemonic, - }); - console.log(text); - assertSuccessOrKnownError(text, /Message Sent/, 'vector_message_agent'); + }, /Unsigned Message Built/); }); - test('vector_deregister_agent', { timeout: 120_000 }, async () => { + test('vector_build_deregister_agent -> sign -> submit', { timeout: 120_000 }, async () => { if (!agentDid) { console.log('Skipping deregister — no agent registered'); return; @@ -328,11 +381,9 @@ describe('Agent Network Tools', () => { console.log('Waiting 10s for UTxOs to settle before deregister...'); await wait(10); } - const text = await callTool(ctx.client, 'vector_deregister_agent', { - mnemonic, + await buildSignSubmit('vector_build_deregister_agent', { + changeAddress: walletAddress, agent_id: agentDid, - }); - console.log(text); - assertSuccessOrKnownError(text, /Agent Deregistered|deposit returned/, 'vector_deregister_agent'); + }, /Deposit returned on confirmation/); }); }); diff --git a/packages/builder/test/integration/sign-helper.ts b/packages/builder/test/integration/sign-helper.ts new file mode 100644 index 0000000..31e9464 --- /dev/null +++ b/packages/builder/test/integration/sign-helper.ts @@ -0,0 +1,54 @@ +// packages/builder/test/integration/sign-helper.ts +// +// Shared local-signing helper for the integration suites. Stands in for the +// in-tree local signer (packages/signer) — same CML primitives (hash -> +// make_vkey_witness -> witness-set attach), without the signer's own +// decode/policy/audit path. Extracted verbatim (Task 5, spec PR 7) from the +// inline signing block keyless-e2e.test.ts proved against the live testnet in +// PR #9's E2E, so registry-e2e.test.ts and run.test.ts's registry section can +// share it instead of re-inlining the same sequence. +import { CML, walletFromSeed } from '@lucid-evolution/lucid'; + +/** + * Sign an unsigned transaction (CBOR hex) with the payment key derived from a + * mnemonic. Takes the mnemonic directly (not a pre-derived key) so every + * caller can go straight from `getMnemonic()` to a signed transaction in one + * call. + * + * CORRECTION vs. the original inline block this was extracted from: that + * block built the signed tx's witness set from scratch + * (`CML.TransactionWitnessSet.new()`, only ever given the new vkey witness), + * which is lossless only when the unsigned tx's own witness set is empty - + * true for a plain payment (keyless-e2e.test.ts's proven case: no script, no + * redeemer). The agent-registry build_* tools attach a minting policy and/or + * spending validator, so their unsigned CBOR already carries + * plutus_v3_scripts/redeemers in `tx.witness_set()`, matched to a + * script_data_hash baked into the tx BODY at build time. Replacing the + * witness set outright silently drops that content; the ledger then rejects + * the resigned tx with `MissingScriptWitnessesUTXOW` (script witness gone) + * and `PPViewHashesDontMatch` (the body's script_data_hash no longer matches + * a witness set the ledger now sees as script-free) - reproduced live via + * registry-e2e.test.ts's register step, see task-5-report.md. Fixed by + * merging the new vkey witness into the tx's EXISTING witness set with + * `add_all_witnesses` (CML's documented primitive for exactly this "add a + * signature to an already-witnessed tx" composition) instead of discarding + * it. Strictly more correct and a no-op behavior change for the script-less + * case, since there's nothing else to preserve there. + */ +export function signWithMnemonic(unsignedCborHex: string, mnemonic: string): { signedCborHex: string; txHash: string } { + const { paymentKey } = walletFromSeed(mnemonic, { network: 'Mainnet', accountIndex: 0 }); + const tx = CML.Transaction.from_cbor_hex(unsignedCborHex); + const txHash = CML.hash_transaction(tx.body()); + const sk = CML.PrivateKey.from_bech32(paymentKey); + const vkeyWitness = CML.make_vkey_witness(txHash, sk); + + const witnessSet = tx.witness_set(); + const newWitnesses = CML.TransactionWitnessSet.new(); + const vkeys = CML.VkeywitnessList.new(); + vkeys.add(vkeyWitness); + newWitnesses.set_vkeywitnesses(vkeys); + witnessSet.add_all_witnesses(newWitnesses); + + const signed = CML.Transaction.new(tx.body(), witnessSet, tx.is_valid(), tx.auxiliary_data()); + return { signedCborHex: signed.to_cbor_hex(), txHash: txHash.to_hex() }; +} diff --git a/packages/builder/test/registry-build.test.ts b/packages/builder/test/registry-build.test.ts new file mode 100644 index 0000000..cc969eb --- /dev/null +++ b/packages/builder/test/registry-build.test.ts @@ -0,0 +1,439 @@ +import { describe, test, before } from 'node:test'; +import assert from 'node:assert/strict'; +import { + CML, getAddressDetails, Data, Constr, toText, fromText, credentialToAddress, +} 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 } from '../src/vector/build.ts'; +import { + REGISTRY_POLICY_ID, MIN_AP3X_DEPOSIT, AGENT_MESSAGE_LABEL, + getRegistryAddress, parseDid, buildAgentDatum, parseAgentDatum, resolveAgentUtxo, + buildRegisterAgent, buildMessageAgent, + buildUpdateAgent, buildTransferAgent, buildDeregisterAgent, registryScript, +} from '../src/vector/registry-build.ts'; + +// The REAL registry validator evaluates during .complete() (native UPLC, offline). +// A passing build here is ground-truth phase-2 validity, minus signatures. + +const OWN_VKEY_HASH = getAddressDetails(OWN_ADDRESS).paymentCredential!.hash; +// A registry UTxO owned by OWN_ADDRESS, for message-resolution tests. +const AGENT_NFT_NAME = 'ab'.repeat(32); // 64 hex chars +const AGENT_UNIT = `${REGISTRY_POLICY_ID}${AGENT_NFT_NAME}`; +const AGENT_DID = `did:vector:agent:${REGISTRY_POLICY_ID}:${AGENT_NFT_NAME}`; +let REGISTRY_UTXO: any; + +before(() => { + REGISTRY_UTXO = { + txHash: 'cd'.repeat(32), outputIndex: 0, + address: getRegistryAddress(), + assets: { lovelace: MIN_AP3X_DEPOSIT, [AGENT_UNIT]: 1n }, + datum: buildAgentDatum(OWN_VKEY_HASH, 'FixtureAgent', 'offline fixture agent', ['testing'], 'custom', '', 1750000000000), + }; +}); + +function decodeTx(cborHex: string) { + return CML.Transaction.from_cbor_hex(cborHex); +} + +// Shared by the mint-content and registry-output-asset checks below: Mint's +// as_positive_multiasset() and Value's multi_asset() both return the same +// MultiAsset type (policy ScriptHash -> AssetName -> quantity), so one +// traversal serves both call sites. +function findAssetQuantity(ma: any, policyIdHex: string, assetNameHex: string): bigint | undefined { + const policies = ma.keys(); + for (let i = 0; i < policies.len(); i++) { + const policy = policies.get(i); + if (policy.to_hex() !== policyIdHex) continue; + 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); + if (name.to_hex() === assetNameHex) return assets.get(name); + } + } + return undefined; +} + +// Decodes a TransactionMetadatum leaf as text, joining metadataStr()'s +// chunked-list form (used for values over 64 chars) back into one string. +function metadatumText(m: any): string | undefined { + const text = m.as_text(); + if (text !== undefined) return text; + const list = m.as_list(); + if (list) { + let joined = ''; + for (let i = 0; i < list.len(); i++) joined += list.get(i).as_text() ?? ''; + return joined; + } + return undefined; +} + +describe('registry constants and helpers', () => { + test('registry address derives to the live testnet address', () => { + assert.equal(getRegistryAddress(), 'addr1wxlp5z3fztdpsp6ha57dvx6khw82kqvgcxwu8s8rjykjcqghprf42'); + }); + + test('parseDid round-trips and rejects malformed DIDs', () => { + const d = parseDid(AGENT_DID); + assert.equal(d.policyId, REGISTRY_POLICY_ID); + assert.equal(d.assetName, AGENT_NFT_NAME); + assert.equal(d.unit, AGENT_UNIT); + assert.throws(() => parseDid('did:vector:wrong:x:y'), /Invalid agent DID/); + assert.throws(() => parseDid(`did:vector:agent:${REGISTRY_POLICY_ID}:NOTHEX`), /hex/); + }); + + test('parseAgentDatum inverts buildAgentDatum exactly', () => { + const datum = buildAgentDatum(OWN_VKEY_HASH, 'A name', 'desc', ['a', 'b'], 'fw', 'https://x.example', 1234); + const p = parseAgentDatum(datum, 'ref#0', { lovelace: 1n, [AGENT_UNIT]: 1n })!; + assert.equal(p.name, 'A name'); + assert.deepEqual(p.capabilities, ['a', 'b']); + assert.equal(p.ownerVkeyHash, OWN_VKEY_HASH); + assert.equal(p.registeredAt, 1234); + assert.equal(p.agentId, AGENT_DID); + }); + + test('parseAgentDatum returns null on garbage, never throws', () => { + assert.equal(parseAgentDatum('deadbeef', 'ref#0', {}), null); + assert.equal(parseAgentDatum(Data.to(new Constr(1, [])), 'ref#0', {}), null); + }); + + test('parseAgentDatum returns null when the datum has the wrong field count (missing registeredAt)', () => { + const sixFieldDatum = Data.to(new Constr(0, [ + new Constr(0, ['00'.repeat(28)]), fromText('x'), fromText('x'), [], fromText('x'), fromText('x'), + ])); + assert.equal(parseAgentDatum(sixFieldDatum, 'ref#0', {}), null); + }); + + test('MIN_AP3X_DEPOSIT matches the protocol deposit floor exactly', () => { + assert.equal(MIN_AP3X_DEPOSIT, 10_000_000n, 'protocol deposit constant drifted'); + }); +}); + +describe('buildRegisterAgent (real validator, offline)', () => { + let lucid: LucidEvolution; + before(async () => { + lucid = await lucidForAddress(new FixtureProvider(FIXTURE_UTXOS), OWN_ADDRESS); + }); + + test('builds an evaluable register tx: mint +1, deposit output, inline datum, owner signer', async () => { + const r = await buildRegisterAgent(lucid, { + changeAddress: OWN_ADDRESS, name: 'UnitAgent', description: 'unit test agent', + capabilities: ['testing', 'ci'], framework: 'custom', endpoint: '', + }); + const tx = decodeTx(r.txCbor); + // unsigned + const vkeys = tx.witness_set().vkeywitnesses(); + assert.ok(vkeys === undefined || vkeys.len() === 0, 'register build must be unsigned'); + // mint exactly +1 of the derived unit + const mint = tx.body().mint()!; + assert.ok(mint, 'no mint field'); + // DID binds to the consumed one-shot input + assert.match(r.agentId, new RegExp(`^did:vector:agent:${REGISTRY_POLICY_ID}:[0-9a-f]{64}$`)); + assert.equal(r.nftAssetName.length, 64); + assert.equal( + findAssetQuantity(mint.as_positive_multiasset(), REGISTRY_POLICY_ID, r.nftAssetName), 1n, + 'minted unit must be exactly +1 of policy:nftAssetName, not just any positive mint', + ); + // one output to the registry address carrying deposit + NFT + const outs = tx.body().outputs(); + let registryOut: any = null; + for (let i = 0; i < outs.len(); i++) { + const o = outs.get(i); + if (o.address().to_bech32() === getRegistryAddress()) registryOut = o; + } + assert.ok(registryOut, 'no output to the registry address'); + // literal is the load-bearing assertion: comparing only to the constant + // under test is tautological (it would pass even if MIN_AP3X_DEPOSIT drifted + // above the real on-chain floor - see the dedicated pin test above). + assert.equal(registryOut.amount().coin(), 10_000_000n, 'registry deposit must equal the literal on-chain floor'); + assert.equal(registryOut.amount().coin(), MIN_AP3X_DEPOSIT, 'MIN_AP3X_DEPOSIT should still equal the literal floor pinned above'); + assert.ok(registryOut.datum() && registryOut.datum()!.kind() === 1, 'registry output datum must be inline'); + // registry output must actually carry the NFT, not just the deposit + const registryAssets = registryOut.amount().multi_asset(); + assert.ok(registryAssets, 'registry output carries no native assets'); + assert.equal( + findAssetQuantity(registryAssets, REGISTRY_POLICY_ID, r.nftAssetName), 1n, + 'registry output must hold exactly 1 of the derived NFT unit', + ); + // required signer = owner + const signers = tx.body().required_signers(); + assert.ok(signers && signers.len() >= 1, 'no required_signers'); + let found = false; + for (let i = 0; i < signers!.len(); i++) { + if (signers!.get(i).to_hex() === OWN_VKEY_HASH) found = true; + } + assert.ok(found, 'owner vkey hash missing from required_signers'); + assert.equal(r.depositLovelace, MIN_AP3X_DEPOSIT.toString()); + }); + + test('rejects invalid endpoint and empty capability before building', async () => { + await assert.rejects(buildRegisterAgent(lucid, { + changeAddress: OWN_ADDRESS, name: 'X', description: '', capabilities: [], framework: 'c', endpoint: 'not a url', + }), /Invalid endpoint URL/); + await assert.rejects(buildRegisterAgent(lucid, { + changeAddress: OWN_ADDRESS, name: 'X', description: '', capabilities: ['ok', ' '], framework: 'c', endpoint: '', + }), /capability/i); + }); + + test('rejects a script-credential changeAddress with a clear error', async () => { + await assert.rejects(buildRegisterAgent(lucid, { + changeAddress: getRegistryAddress(), name: 'X', description: '', capabilities: [], framework: 'c', endpoint: '', + }), /verification key|payment key/i); + }); +}); + +describe('buildMessageAgent (resolves via fixture registry UTxO)', () => { + test('pays 2 AP3X to the owner-derived address with label-674 metadata', async () => { + const provider = new FixtureProvider([...FIXTURE_UTXOS, REGISTRY_UTXO]); + const lucid = await lucidForAddress(provider, OWN_ADDRESS); + const r = await buildMessageAgent(lucid, provider, { + changeAddress: OWN_ADDRESS, agentId: AGENT_DID, messageType: 'inquiry', payload: 'unit ping', + }); + assert.equal(r.agentName, 'FixtureAgent'); + const tx = decodeTx(r.txCbor); + // 2 AP3X output to the recipient (owner == OWN_ADDRESS's key, enterprise-derived) + const outs = tx.body().outputs(); + let paid = 0; + for (let i = 0; i < outs.len(); i++) { + if (outs.get(i).address().to_bech32() === r.recipientAddress && outs.get(i).amount().coin() === 2_000_000n) paid++; + } + assert.ok(paid >= 1, 'no 2 AP3X output to the recipient address'); + // metadata label present + const aux = tx.auxiliary_data(); + assert.ok(aux && aux.metadata() && aux.metadata()!.get(BigInt(AGENT_MESSAGE_LABEL)), 'label-674 metadata missing'); + // metadata content: from/to must be the real sender/recipient, not swapped + const metaMap = aux!.metadata()!.get(BigInt(AGENT_MESSAGE_LABEL))!.as_map()!; + const fromVal = metadatumText(metaMap.get(CML.TransactionMetadatum.new_text('from'))!); + const toVal = metadatumText(metaMap.get(CML.TransactionMetadatum.new_text('to'))!); + assert.equal(fromVal, OWN_ADDRESS, 'metadata "from" must be the sending changeAddress'); + assert.equal(toVal, AGENT_DID, 'metadata "to" must be the recipient agent DID'); + const vkeys = tx.witness_set().vkeywitnesses(); + assert.ok(vkeys === undefined || vkeys.len() === 0, 'message build must be unsigned'); + }); + + test('unknown agent DID fails with agent-not-found, not a network error', async () => { + const provider = new FixtureProvider(FIXTURE_UTXOS); // no registry UTxO + const lucid = await lucidForAddress(provider, OWN_ADDRESS); + await assert.rejects(buildMessageAgent(lucid, provider, { + changeAddress: OWN_ADDRESS, agentId: AGENT_DID, messageType: 'inquiry', payload: 'x', + }), /Agent not found/); + }); +}); + +describe('resolveAgentUtxo (Koios/address-scan fallback)', () => { + test('falls through to the address re-scan when getUtxoByUnit resolves but with no datum', async () => { + // getUtxoByUnit "succeeding" with a datum-less UTxO (e.g. a Koios hit that + // only carries a datum hash, not the inline datum) must not be treated as + // resolved - resolveAgentUtxo has to notice the missing datum and fall + // back to scanning the registry address, same as when getUtxoByUnit + // throws outright. Under plain FixtureProvider this branch is dead + // (getUtxoByUnit always throws), so it needs its own provider double. + const base = new FixtureProvider([...FIXTURE_UTXOS, REGISTRY_UTXO]); + const wrapped = { + getUtxos: (a: string) => base.getUtxos(a), + getUtxoByUnit: async () => ({ ...REGISTRY_UTXO, datum: null }), + }; + const { profile, utxo, nftUnit } = await resolveAgentUtxo(wrapped as any, AGENT_DID); + assert.equal(nftUnit, AGENT_UNIT); + assert.equal(utxo.datum, REGISTRY_UTXO.datum, 're-scanned UTxO must carry the real inline datum, not the datum-less stand-in'); + assert.equal(profile.name, 'FixtureAgent'); + assert.equal(profile.agentId, AGENT_DID); + }); +}); + +describe('registry owner ops (real validator, offline)', () => { + function ownedProvider() { + return new FixtureProvider([...FIXTURE_UTXOS, REGISTRY_UTXO]); + } + + test('update merges fields, preserves registeredAt, keeps NFT + deposit at the registry', async () => { + const provider = ownedProvider(); + const lucid = await lucidForAddress(provider, OWN_ADDRESS); + const r = await buildUpdateAgent(lucid, provider, { + changeAddress: OWN_ADDRESS, agentId: AGENT_DID, description: 'updated by unit test', + }); + assert.equal(r.op, 'update'); + assert.equal(r.detail, 'description'); + const tx = decodeTx(r.txCbor); + const outs = tx.body().outputs(); + let registryOut: any = null; + for (let i = 0; i < outs.len(); i++) { + if (outs.get(i).address().to_bech32() === getRegistryAddress()) registryOut = outs.get(i); + } + assert.ok(registryOut, 'no continuing registry output'); + assert.equal(registryOut.amount().coin(), MIN_AP3X_DEPOSIT); + assert.ok(registryOut.datum() && registryOut.datum()!.kind() === 1, 'continuing datum must be inline'); + // decode the continuing inline datum and verify the merge + const datumCbor = registryOut.datum()!.as_datum()!.to_cbor_hex(); + const p = parseAgentDatum(datumCbor, 'x#0', { [AGENT_UNIT]: 1n })!; + assert.equal(p.description, 'updated by unit test'); + assert.equal(p.name, 'FixtureAgent'); // preserved + assert.equal(p.registeredAt, 1750000000000); // preserved + const vkeys = tx.witness_set().vkeywitnesses(); + assert.ok(vkeys === undefined || vkeys.len() === 0, 'must be unsigned'); + }); + + test('update with no fields throws before any resolution', async () => { + const provider = ownedProvider(); + const lucid = await lucidForAddress(provider, OWN_ADDRESS); + await assert.rejects(buildUpdateAgent(lucid, provider, { + changeAddress: OWN_ADDRESS, agentId: AGENT_DID, + }), /At least one field/); + }); + + test('ownership fail-fast: a non-owner changeAddress is refused before building', async () => { + const provider = ownedProvider(); + const lucid = await lucidForAddress(provider, OWN_ADDRESS); + // FOREIGN_ADDRESS is a script address in the fixture set; use a foreign KEY address instead: + // derive one from a different fixture or construct via credentialToAddress with a random hash. + const strangerHash = '00'.repeat(28); + const stranger = credentialToAddress('Mainnet', { type: 'Key', hash: strangerHash }); + // build a lucid for the stranger (their own address-only wallet, holding nothing at the registry) + const strangerProvider = new FixtureProvider([ + { txHash: 'ee'.repeat(32), outputIndex: 0, address: stranger, assets: { lovelace: 50_000_000n } }, + REGISTRY_UTXO, + ]); + const strangerLucid = await lucidForAddress(strangerProvider, stranger); + await assert.rejects(buildUpdateAgent(strangerLucid, strangerProvider, { + changeAddress: stranger, agentId: AGENT_DID, description: 'hijack attempt', + }), /Ownership check failed/); + }); + + test('transfer swaps the datum owner to the new key and rejects script-address owners', async () => { + const provider = ownedProvider(); + const lucid = await lucidForAddress(provider, OWN_ADDRESS); + const newOwnerHash = '11'.repeat(28); + const newOwner = credentialToAddress('Mainnet', { type: 'Key', hash: newOwnerHash }); + const r = await buildTransferAgent(lucid, provider, { + changeAddress: OWN_ADDRESS, agentId: AGENT_DID, newOwnerAddress: newOwner, + }); + assert.equal(r.op, 'transfer'); + const tx = decodeTx(r.txCbor); + const outs = tx.body().outputs(); + let registryOut: any = null; + for (let i = 0; i < outs.len(); i++) { + if (outs.get(i).address().to_bech32() === getRegistryAddress()) registryOut = outs.get(i); + } + const datumCbor = registryOut.datum()!.as_datum()!.to_cbor_hex(); + const p = parseAgentDatum(datumCbor, 'x#0', { [AGENT_UNIT]: 1n })!; + assert.equal(p.ownerVkeyHash, newOwnerHash); + assert.equal(p.name, 'FixtureAgent'); // everything else preserved + // script-credential new owner refused + await assert.rejects(buildTransferAgent(lucid, provider, { + changeAddress: OWN_ADDRESS, agentId: AGENT_DID, newOwnerAddress: getRegistryAddress(), + }), /verification key/i); + }); + + test('deregister burns the NFT and leaves no registry output (deposit comes back as change)', async () => { + const provider = ownedProvider(); + const lucid = await lucidForAddress(provider, OWN_ADDRESS); + const r = await buildDeregisterAgent(lucid, provider, { + changeAddress: OWN_ADDRESS, agentId: AGENT_DID, + }); + assert.equal(r.op, 'deregister'); + assert.equal(r.agentName, 'FixtureAgent'); + const tx = decodeTx(r.txCbor); + const outs = tx.body().outputs(); + for (let i = 0; i < outs.len(); i++) { + assert.notEqual(outs.get(i).address().to_bech32(), getRegistryAddress(), + 'deregister must not leave an output at the registry'); + // every output is change to the owner + assert.equal(outs.get(i).address().to_bech32(), OWN_ADDRESS); + } + assert.ok(tx.body().mint(), 'no mint (burn) field on the deregister tx'); + }); +}); + +// Correction round (see task-3-report.md): differential testing against the +// REAL deployed validator (not the in-workspace Aiken source, which turned +// out to compile to an unrelated policy - see the report) found the deployed +// script enforces value-preservation on Update/Transfer and a returned- +// change floor on Deregister. These tests pin that corrected understanding. +describe('registry owner ops - value preservation & deregister floor (correction, real validator)', () => { + function overfundedRegistryUtxo(lovelace: bigint) { + return { + txHash: 'ce'.repeat(32), outputIndex: 0, + address: getRegistryAddress(), + assets: { lovelace, [AGENT_UNIT]: 1n }, + datum: buildAgentDatum(OWN_VKEY_HASH, 'FixtureAgent', 'offline fixture agent', ['testing'], 'custom', '', 1750000000000), + }; + } + + test('update preserves the FULL input value on an over-funded registry UTxO, not just the deposit floor', async () => { + const overfunded = overfundedRegistryUtxo(25_000_000n); + const provider = new FixtureProvider([...FIXTURE_UTXOS, overfunded]); + const lucid = await lucidForAddress(provider, OWN_ADDRESS); + const r = await buildUpdateAgent(lucid, provider, { + changeAddress: OWN_ADDRESS, agentId: AGENT_DID, description: 'overfunded update', + }); + assert.equal(r.op, 'update'); + const tx = decodeTx(r.txCbor); + const outs = tx.body().outputs(); + let registryOut: any = null; + for (let i = 0; i < outs.len(); i++) { + if (outs.get(i).address().to_bech32() === getRegistryAddress()) registryOut = outs.get(i); + } + assert.ok(registryOut, 'no continuing registry output'); + assert.equal(registryOut.amount().coin(), 25_000_000n, 'continuing output must preserve the FULL input value, not the deposit floor'); + }); + + test('transfer preserves the FULL input value on an over-funded registry UTxO', async () => { + const overfunded = overfundedRegistryUtxo(25_000_000n); + const provider = new FixtureProvider([...FIXTURE_UTXOS, overfunded]); + const lucid = await lucidForAddress(provider, OWN_ADDRESS); + const newOwnerHash = '22'.repeat(28); + const newOwner = credentialToAddress('Mainnet', { type: 'Key', hash: newOwnerHash }); + const r = await buildTransferAgent(lucid, provider, { + changeAddress: OWN_ADDRESS, agentId: AGENT_DID, newOwnerAddress: newOwner, + }); + assert.equal(r.op, 'transfer'); + const tx = decodeTx(r.txCbor); + const outs = tx.body().outputs(); + let registryOut: any = null; + for (let i = 0; i < outs.len(); i++) { + if (outs.get(i).address().to_bech32() === getRegistryAddress()) registryOut = outs.get(i); + } + assert.ok(registryOut, 'no continuing registry output'); + assert.equal(registryOut.amount().coin(), 25_000_000n, 'continuing output must preserve the FULL input value on transfer too'); + }); + + test('validator floor pin: deregister of an over-funded (25 AP3X) registry UTxO succeeds with no extra input pulled in', async () => { + // Direct build (not through buildDeregisterAgent, which now + // unconditionally requires a fee UTxO whenever the wallet has one) - + // pins the underlying chain fact that motivates that requirement: the + // deployed validator's returned-change floor is cleared by the registry + // deposit alone once it's well above MIN_AP3X_DEPOSIT. Confirms this is + // a genuine value floor on the validator side, not "any sole-input + // deregister fails" (which was the earlier, wrong diagnosis). + const overfunded = overfundedRegistryUtxo(25_000_000n); + const provider = new FixtureProvider([overfunded, ...FIXTURE_UTXOS]); + const lucid = await lucidForAddress(provider, OWN_ADDRESS); + const spendRedeemer = Data.to(new Constr(1, [])); + const mintRedeemer = Data.to(new Constr(1, [])); + const completed = await lucid.newTx() + .collectFrom([overfunded], spendRedeemer) + .attach.SpendingValidator(registryScript()) + .mintAssets({ [AGENT_UNIT]: -1n }, mintRedeemer) + .attach.MintingPolicy(registryScript()) + .addSigner(OWN_ADDRESS) + .complete(); + const tx = decodeTx(completed.toCBOR()); + assert.equal(tx.body().inputs().len(), 1, 'sole input - Lucid should not need to pull in any extra wallet UTxO here'); + }); + + test('deregister throws an actionable error (not a validator crash) when the wallet has no plain AP3X-only UTxO', async () => { + const tokenOnlyUtxo = { + txHash: 'df'.repeat(32), outputIndex: 0, address: OWN_ADDRESS, + assets: { lovelace: 1_000_000_000n, [FIXTURE_TOKEN_UNIT]: 500n }, + }; + const provider = new FixtureProvider([tokenOnlyUtxo, REGISTRY_UTXO]); + const lucid = await lucidForAddress(provider, OWN_ADDRESS); + await assert.rejects(buildDeregisterAgent(lucid, provider, { + changeAddress: OWN_ADDRESS, agentId: AGENT_DID, + }), /AP3X-only UTxO/); + }); +}); diff --git a/packages/builder/test/sign-helper.test.ts b/packages/builder/test/sign-helper.test.ts new file mode 100644 index 0000000..2174a53 --- /dev/null +++ b/packages/builder/test/sign-helper.test.ts @@ -0,0 +1,113 @@ +// packages/builder/test/sign-helper.test.ts +// +// Hermetic pin for signWithMnemonic's witness-preservation fix +// (test/integration/sign-helper.ts). Proves offline, with zero network +// access and zero real secrets, that signing a script-bearing transaction +// preserves its existing plutus_v3_scripts/redeemers/plutus_datums instead +// of discarding them - the defect found live during Task 5's gated E2E run +// (see task-5-report.md, "Bug #1"). +// +// Why this needs its own test: none of the existing gates catch this +// mutant. sign-helper.ts lives under test/integration/, outside every unit +// test glob (packages/*/test/*.test.ts is non-recursive). run.test.ts's +// assertSuccessOrKnownError treats the resulting "Failed to submit +// transaction" as a tolerated known error (indistinguishable from an +// unfunded wallet) rather than a hard failure. And registry-e2e.test.ts's +// hash-equality check (`assert.equal(submittedHash, txHash, ...)`) cannot +// catch it either: dropping witnesses never changes the transaction BODY, +// so the mutant computes the exact same body hash as the fix - only the +// ledger, which this test never talks to, rejects the mutant's CBOR. This +// file is the only gate that would fail if the fix regressed. +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { CML, walletFromSeed } from '@lucid-evolution/lucid'; +import { signWithMnemonic } from './integration/sign-helper.ts'; + +// Public BIP39 test vector: 23x "abandon" + the checksum word "art" (the +// 24-word encoding of all-zero, 256-bit entropy - verified directly against +// the `bip39` package's own entropyToMnemonic(), not transcribed from +// memory: `bip39.entropyToMnemonic('00'.repeat(32))`). This is the +// canonical example mnemonic used throughout the crypto tooling ecosystem +// specifically because it is universally known, was never funded, and +// holds nothing on any network. TEST-ONLY - used here purely to exercise +// signing logic offline; never use it, or any mnemonic like it, for +// anything that touches real funds. +const TEST_ONLY_PUBLIC_MNEMONIC = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon ' + + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art'; + +/** + * Builds a synthetic, offline, script-bearing unsigned transaction: one + * input, one output, and a witness set carrying one PlutusV3 script, one + * redeemer, and one datum - enough to exercise every witness-set category + * signWithMnemonic must preserve, without a live network, a real validator, + * or Lucid's TransactionBuilder (whose min-UTxO/fee rules this probe has no + * reason to satisfy: these are raw CML data containers, hashed and + * re-serialized, never evaluated or submitted anywhere). + */ +function buildSyntheticScriptBearingTx(): string { + const { address } = walletFromSeed(TEST_ONLY_PUBLIC_MNEMONIC, { network: 'Mainnet', accountIndex: 0 }); + const addr = CML.Address.from_bech32(address); + + const inputs = CML.TransactionInputList.new(); + inputs.add(CML.TransactionInput.new(CML.TransactionHash.from_hex('00'.repeat(32)), 0n)); + + const outputs = CML.TransactionOutputList.new(); + outputs.add(CML.TransactionOutput.new(addr, CML.Value.from_coin(2_000_000n))); + + const body = CML.TransactionBody.new(inputs, outputs, 200_000n); + + const witnessSet = CML.TransactionWitnessSet.new(); + + const scripts = CML.PlutusV3ScriptList.new(); + scripts.add(CML.PlutusV3Script.from_hex('4e4d01000033222220051200120011')); + witnessSet.set_plutus_v3_scripts(scripts); + + const redeemerMap = CML.MapRedeemerKeyToRedeemerVal.new(); + redeemerMap.insert( + CML.RedeemerKey.new(CML.RedeemerTag.Mint, 0n), + CML.RedeemerVal.new(CML.PlutusData.new_bytes(new Uint8Array([1, 2, 3])), CML.ExUnits.new(1_000n, 1_000n)), + ); + witnessSet.set_redeemers(CML.Redeemers.new_map_redeemer_key_to_redeemer_val(redeemerMap)); + + const datums = CML.PlutusDataList.new(); + datums.add(CML.PlutusData.new_bytes(new Uint8Array([9, 9, 9]))); + witnessSet.set_plutus_datums(datums); + + return CML.Transaction.new(body, witnessSet, true).to_cbor_hex(); +} + +describe('signWithMnemonic preserves an existing script witness set (hermetic pin, task-5-report.md Bug #1)', () => { + test('signing a script-bearing tx keeps the vkey witness AND the original scripts/redeemers/datums, and leaves the body hash unchanged', () => { + const unsignedCborHex = buildSyntheticScriptBearingTx(); + const unsignedBodyHashHex = CML.hash_transaction(CML.Transaction.from_cbor_hex(unsignedCborHex).body()).to_hex(); + + const { signedCborHex, txHash } = signWithMnemonic(unsignedCborHex, TEST_ONLY_PUBLIC_MNEMONIC); + + assert.equal(txHash, unsignedBodyHashHex, 'signing must not change the transaction body hash'); + + const signed = CML.Transaction.from_cbor_hex(signedCborHex); + assert.equal( + CML.hash_transaction(signed.body()).to_hex(), unsignedBodyHashHex, + 'the signed tx body hash must still match the unsigned one', + ); + + const ws = signed.witness_set(); + + const vkeys = ws.vkeywitnesses(); + assert.ok(vkeys && vkeys.len() === 1, `expected exactly 1 vkey witness after signing, got ${vkeys?.len() ?? 0}`); + + const scripts = ws.plutus_v3_scripts(); + assert.ok(scripts && scripts.len() === 1, `expected the original plutus_v3 script to survive signing, got ${scripts?.len() ?? 0}`); + + const redeemers = ws.redeemers(); + assert.ok(redeemers, 'expected the original redeemers to survive signing (got none)'); + assert.equal( + redeemers!.as_map_redeemer_key_to_redeemer_val()?.len(), 1, + 'expected exactly 1 redeemer to survive signing', + ); + + const datums = ws.plutus_datums(); + assert.ok(datums && datums.len() === 1, `expected the original plutus datum to survive signing, got ${datums?.len() ?? 0}`); + }); +}); diff --git a/packages/builder/test/smoke/tool-inventory.test.ts b/packages/builder/test/smoke/tool-inventory.test.ts index 78e5099..ee24358 100644 --- a/packages/builder/test/smoke/tool-inventory.test.ts +++ b/packages/builder/test/smoke/tool-inventory.test.ts @@ -21,27 +21,27 @@ import { startServer, stopServer, ServerContext } from '../setup.ts'; const EXPECTED_TOOLS = [ 'vector_await_transaction', 'vector_build_deploy_contract', + 'vector_build_deregister_agent', 'vector_build_interact_contract', + 'vector_build_message_agent', + 'vector_build_register_agent', 'vector_build_send_apex', 'vector_build_send_tokens', 'vector_build_transaction', - 'vector_deregister_agent', + 'vector_build_transfer_agent', + 'vector_build_update_agent', 'vector_discover_agents', 'vector_dry_run', 'vector_get_agent_profile', 'vector_get_balance', 'vector_get_transaction_history', 'vector_get_utxos', - 'vector_message_agent', - 'vector_register_agent', 'vector_self_improvement_analyze_metrics', 'vector_self_improvement_browse', 'vector_self_improvement_critique', 'vector_self_improvement_endorse', 'vector_self_improvement_submit_proposal', 'vector_submit_transaction', - 'vector_transfer_agent', - 'vector_update_agent', ]; /** diff --git a/packages/builder/test/smoke/tool-schemas.snapshot.json b/packages/builder/test/smoke/tool-schemas.snapshot.json index fa1871c..7d30593 100644 --- a/packages/builder/test/smoke/tool-schemas.snapshot.json +++ b/packages/builder/test/smoke/tool-schemas.snapshot.json @@ -60,6 +60,27 @@ "$schema": "http://json-schema.org/draft-07/schema#" } }, + "vector_build_deregister_agent": { + "description": "Build an UNSIGNED transaction that deregisters an agent (burns the identity NFT, returns the 10 AP3X deposit to the owner wallet). Takes no key material. Sign locally with vector_signer_sign, broadcast with vector_submit_transaction, then confirm with vector_await_transaction.", + "inputSchema": { + "type": "object", + "properties": { + "changeAddress": { + "description": "The agent OWNER's wallet address (receives the returned deposit). Get it from vector_signer_get_address.", + "type": "string" + }, + "agent_id": { + "description": "Agent DID to deregister: did:vector:agent:{policyId}:{nftAssetName}", + "type": "string" + } + }, + "required": [ + "changeAddress", + "agent_id" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, "vector_build_interact_contract": { "description": "Build an UNSIGNED transaction interacting with a deployed Plutus/Aiken contract - lock AP3X at it or spend from it. Takes no key material - sign with your local signer, then broadcast with vector_submit_transaction and confirm with vector_await_transaction.", "inputSchema": { @@ -138,6 +159,90 @@ "$schema": "http://json-schema.org/draft-07/schema#" } }, + "vector_build_message_agent": { + "description": "Build an UNSIGNED transaction that sends an on-chain message to a registered agent via TX metadata (label 674), delivering 2 AP3X to the agent's owner address. Takes no key material. Sign locally with vector_signer_sign, broadcast with vector_submit_transaction, then confirm with vector_await_transaction.", + "inputSchema": { + "type": "object", + "properties": { + "changeAddress": { + "description": "Your wallet address (pays the 2 AP3X delivery and fee; recorded as the sender). Get it from vector_signer_get_address.", + "type": "string" + }, + "agent_id": { + "description": "Recipient agent DID: did:vector:agent:{policyId}:{nftAssetName}", + "type": "string" + }, + "message_type": { + "description": "Type of message", + "type": "string", + "enum": [ + "inquiry", + "proposal", + "result" + ] + }, + "payload": { + "description": "Message payload (string, max 512 chars)", + "type": "string", + "maxLength": 512 + } + }, + "required": [ + "changeAddress", + "agent_id", + "message_type", + "payload" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + "vector_build_register_agent": { + "description": "Build an UNSIGNED transaction that registers an agent in the Vector on-chain registry (mints a soulbound identity NFT, locks a 10 AP3X deposit). Takes no key material. Sign locally with vector_signer_sign, broadcast with vector_submit_transaction, then confirm with vector_await_transaction.", + "inputSchema": { + "type": "object", + "properties": { + "changeAddress": { + "description": "Your wallet address (source of the deposit and change). Get it from vector_signer_get_address.", + "type": "string" + }, + "name": { + "description": "Agent name (e.g. 'TradingBot', 'ResearchAgent')", + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "description": { + "description": "Short description of the agent's purpose", + "type": "string", + "maxLength": 256 + }, + "capabilities": { + "description": "List of capability tags (e.g. ['investing', 'research', 'environmental'])", + "type": "array", + "items": { + "type": "string" + } + }, + "framework": { + "description": "Framework used (e.g. 'OpenClaw', 'LangChain', 'CrewAI', 'custom')", + "type": "string" + }, + "endpoint": { + "description": "A2A communication endpoint URL (or empty string if not applicable)", + "type": "string" + } + }, + "required": [ + "changeAddress", + "name", + "description", + "capabilities", + "framework", + "endpoint" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, "vector_build_send_apex": { "description": "Build an UNSIGNED transaction sending AP3X to a recipient. Takes no key material - sign the returned CBOR with your local signer (vector_signer_sign), then broadcast with vector_submit_transaction and confirm with vector_await_transaction.", "inputSchema": { @@ -262,22 +367,74 @@ "$schema": "http://json-schema.org/draft-07/schema#" } }, - "vector_deregister_agent": { - "description": "Deregister an agent from the Vector registry. Burns the identity NFT and returns the 10 AP3X deposit to your wallet.", + "vector_build_transfer_agent": { + "description": "Build an UNSIGNED transaction that transfers agent ownership to a new address (must be a verification-key address, not a script). Takes no key material. Sign locally with vector_signer_sign, broadcast with vector_submit_transaction, then confirm with vector_await_transaction.", "inputSchema": { "type": "object", "properties": { - "mnemonic": { - "description": "15 or 24-word BIP39 mnemonic for the agent's owner wallet", + "changeAddress": { + "description": "The CURRENT owner's wallet address. Get it from vector_signer_get_address.", "type": "string" }, "agent_id": { - "description": "Agent DID to deregister: did:vector:agent:{policyId}:{nftAssetName}", + "description": "Agent DID to transfer: did:vector:agent:{policyId}:{nftAssetName}", + "type": "string" + }, + "new_owner_address": { + "description": "Bech32 address of the new owner (must be a verification key address, not a script address)", "type": "string" } }, "required": [ - "mnemonic", + "changeAddress", + "agent_id", + "new_owner_address" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + "vector_build_update_agent": { + "description": "Build an UNSIGNED transaction that updates a registered agent's profile fields. Only the specified fields change; others are preserved. Takes no key material - the on-chain validator requires the owner's signature, which you add locally with vector_signer_sign before vector_submit_transaction, then confirm with vector_await_transaction.", + "inputSchema": { + "type": "object", + "properties": { + "changeAddress": { + "description": "The agent OWNER's wallet address. Get it from vector_signer_get_address.", + "type": "string" + }, + "agent_id": { + "description": "Agent DID to update: did:vector:agent:{policyId}:{nftAssetName}", + "type": "string" + }, + "name": { + "description": "New agent name", + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "description": { + "description": "New description", + "type": "string", + "maxLength": 256 + }, + "capabilities": { + "description": "New capability tags (replaces existing list)", + "type": "array", + "items": { + "type": "string" + } + }, + "framework": { + "description": "New framework identifier", + "type": "string" + }, + "endpoint": { + "description": "New A2A endpoint URL (or empty string to clear)", + "type": "string" + } + }, + "required": [ + "changeAddress", "agent_id" ], "$schema": "http://json-schema.org/draft-07/schema#" @@ -431,90 +588,6 @@ "$schema": "http://json-schema.org/draft-07/schema#" } }, - "vector_message_agent": { - "description": "Send an on-chain message to a registered Vector agent via TX metadata (label 674). Delivers 2 AP3X to the agent's owner address.", - "inputSchema": { - "type": "object", - "properties": { - "agent_id": { - "description": "Recipient agent DID: did:vector:agent:{policyId}:{nftAssetName}", - "type": "string" - }, - "message_type": { - "description": "Type of message", - "type": "string", - "enum": [ - "inquiry", - "proposal", - "result" - ] - }, - "payload": { - "description": "Message payload (string, max 512 chars)", - "type": "string", - "maxLength": 512 - }, - "mnemonic": { - "description": "15 or 24-word BIP39 mnemonic for the sending wallet", - "type": "string" - } - }, - "required": [ - "agent_id", - "message_type", - "payload", - "mnemonic" - ], - "$schema": "http://json-schema.org/draft-07/schema#" - } - }, - "vector_register_agent": { - "description": "Register an agent in the Vector on-chain agent registry. Mints a soulbound identity NFT and locks a 10 AP3X deposit.", - "inputSchema": { - "type": "object", - "properties": { - "mnemonic": { - "description": "15 or 24-word BIP39 mnemonic for the registering wallet", - "type": "string" - }, - "name": { - "description": "Agent name (e.g. 'TradingBot', 'ResearchAgent')", - "type": "string", - "minLength": 1, - "maxLength": 64 - }, - "description": { - "description": "Short description of the agent's purpose", - "type": "string", - "maxLength": 256 - }, - "capabilities": { - "description": "List of capability tags (e.g. ['investing', 'research', 'environmental'])", - "type": "array", - "items": { - "type": "string" - } - }, - "framework": { - "description": "Framework used (e.g. 'OpenClaw', 'LangChain', 'CrewAI', 'custom')", - "type": "string" - }, - "endpoint": { - "description": "A2A communication endpoint URL (or empty string if not applicable)", - "type": "string" - } - }, - "required": [ - "mnemonic", - "name", - "description", - "capabilities", - "framework", - "endpoint" - ], - "$schema": "http://json-schema.org/draft-07/schema#" - } - }, "vector_self_improvement_analyze_metrics": { "description": "Analyze proposal metrics: proposal activity, adoption rate, treasury health, and engagement statistics. Read-only - no mnemonic needed.", "inputSchema": { @@ -771,78 +844,5 @@ ], "$schema": "http://json-schema.org/draft-07/schema#" } - }, - "vector_transfer_agent": { - "description": "Transfer agent ownership to a new address. The new owner must have a verification key credential (not a script address).", - "inputSchema": { - "type": "object", - "properties": { - "mnemonic": { - "description": "15 or 24-word BIP39 mnemonic for the current owner's wallet", - "type": "string" - }, - "agent_id": { - "description": "Agent DID to transfer: did:vector:agent:{policyId}:{nftAssetName}", - "type": "string" - }, - "new_owner_address": { - "description": "Bech32 address of the new owner (must be a verification key address, not a script address)", - "type": "string" - } - }, - "required": [ - "mnemonic", - "agent_id", - "new_owner_address" - ], - "$schema": "http://json-schema.org/draft-07/schema#" - } - }, - "vector_update_agent": { - "description": "Update a registered agent's profile fields (name, description, capabilities, framework, endpoint). Only the specified fields are changed; others are preserved.", - "inputSchema": { - "type": "object", - "properties": { - "mnemonic": { - "description": "15 or 24-word BIP39 mnemonic for the agent's owner wallet", - "type": "string" - }, - "agent_id": { - "description": "Agent DID to update: did:vector:agent:{policyId}:{nftAssetName}", - "type": "string" - }, - "name": { - "description": "New agent name", - "type": "string", - "minLength": 1, - "maxLength": 64 - }, - "description": { - "description": "New description", - "type": "string", - "maxLength": 256 - }, - "capabilities": { - "description": "New capability tags (replaces existing list)", - "type": "array", - "items": { - "type": "string" - } - }, - "framework": { - "description": "New framework identifier", - "type": "string" - }, - "endpoint": { - "description": "New A2A endpoint URL (or empty string to clear)", - "type": "string" - } - }, - "required": [ - "mnemonic", - "agent_id" - ], - "$schema": "http://json-schema.org/draft-07/schema#" - } } } diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 08cd075..037f60f 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -76,6 +76,28 @@ export interface VectorBuildInteractResult extends VectorUnsignedBuildResult { scriptAddress: string; action: 'lock' | 'spend'; } +// --- Keyless registry builds (spec PR 7) --- + +export interface VectorBuildRegisterResult extends VectorUnsignedBuildResult { + agentId: string; + nftAssetName: string; + registryAddress: string; + depositLovelace: string; +} + +export interface VectorBuildAgentOpResult extends VectorUnsignedBuildResult { + agentId: string; + op: 'update' | 'transfer' | 'deregister'; + agentName: string; + detail: string; +} + +export interface VectorBuildMessageResult extends VectorUnsignedBuildResult { + agentId: string; + agentName: string; + recipientAddress: string; +} + // --- Agent Network --- export interface AgentProfile { diff --git a/packages/signer/README.md b/packages/signer/README.md index 4dacad8..2ec1fb6 100644 --- a/packages/signer/README.md +++ b/packages/signer/README.md @@ -145,27 +145,29 @@ over-counts, which refuses too much rather than too little. 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 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 + designed to fix — for the wallet, transaction, smart-contract, and agent-registry 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 these + families are keyless: `vector_build_send_apex`, `vector_build_send_tokens`, + `vector_build_transaction`, `vector_build_deploy_contract`, `vector_build_interact_contract`, + `vector_build_register_agent`, `vector_build_update_agent`, `vector_build_transfer_agent`, + `vector_build_deregister_agent`, and `vector_build_message_agent` 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 for both: a payment self-send + (`packages/builder/test/integration/keyless-e2e.test.ts`) and a full register → update → + deregister agent lifecycle with the 10 AP3X deposit round-tripped exactly + (`registry-e2e.test.ts`). Both sign 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 submit and confirm + 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 Self-Improvement Module.** Its 5 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) - and has not shipped. Until it does, the root README's custody notice still describes those two - families accurately. + and has not shipped. Until it does, the root README's custody notice still describes that one + family accurately. ## Running it @@ -192,9 +194,9 @@ example): ``` 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. +wallet, transaction, smart-contract, and agent-registry 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 Self-Improvement Module. ## Testing