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:
@@ -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
|
||||
|
||||
|
||||
73
README.md
73
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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
357
packages/builder/src/vector/registry-build.ts
Normal file
357
packages/builder/src/vector/registry-build.ts
Normal file
File diff suppressed because one or more lines are too long
@@ -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.`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '',
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', {
|
||||
|
||||
83
packages/builder/test/integration/registry-e2e.test.ts
Normal file
83
packages/builder/test/integration/registry-e2e.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
95
packages/builder/test/integration/registry-keyless.test.ts
Normal file
95
packages/builder/test/integration/registry-keyless.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
54
packages/builder/test/integration/sign-helper.ts
Normal file
54
packages/builder/test/integration/sign-helper.ts
Normal 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() };
|
||||
}
|
||||
439
packages/builder/test/registry-build.test.ts
Normal file
439
packages/builder/test/registry-build.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
113
packages/builder/test/sign-helper.test.ts
Normal file
113
packages/builder/test/sign-helper.test.ts
Normal 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}`);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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#"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user