Merge pull request #10 from FilipBl4gojevic/feat/registry-keyless

Agent-registry family keyless: build_* tools, @ts-nocheck dropped, custody allowlist down to one file (non-custodial split, family 2)
This commit is contained in:
dcejtlin-apex
2026-07-31 12:39:42 +02:00
committed by GitHub
21 changed files with 1782 additions and 733 deletions

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

@@ -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', {

View File

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

View File

@@ -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, /(?<!Not )Submitted\b|Transaction Submitted/);
assert.match(text, /Unsigned|NOT been submitted|Not Submitted/i);
}
describe('registry keyless tier-1 (live testnet, no secrets)', () => {
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/);
});
});

View File

@@ -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<hex>\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<string, unknown>, 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/);
});
});

View File

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

View File

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

View File

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

View File

@@ -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',
];
/**

View File

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

View File

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

View File

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