Merge pull request #11 from FilipBl4gojevic/feat/self-improvement-keyless

Self-improvement family keyless: the non-custodial migration is complete (family 3 of 3)
This commit is contained in:
dcejtlin-apex
2026-07-31 12:39:58 +02:00
committed by GitHub
25 changed files with 3677 additions and 1263 deletions

View File

@@ -7,16 +7,10 @@ VECTOR_KOIOS_URL=https://v2.koios.vector.testnet.apexfusion.org/
VECTOR_SUBMIT_URL=https://submit.vector.testnet.apexfusion.org/api/submit/tx VECTOR_SUBMIT_URL=https://submit.vector.testnet.apexfusion.org/api/submit/tx
VECTOR_EXPLORER_URL=https://vector.testnet.apexscan.org VECTOR_EXPLORER_URL=https://vector.testnet.apexscan.org
# Safety Limits (in lovelace, 1 ADA = 1,000,000 lovelace) # Non-custodial migration complete: every builder tool is keyless. Spend
# Scope note (non-custodial migration): the keyless build_* families (wallet/tx/contract, # limits and audit logging are owned entirely by the LOCAL signer, per user -
# agent registry) ignore these - spend limits for them are enforced by the LOCAL signer, per # see packages/signer/README.md. The server holds no key material and enforces
# user. These server-side limits still govern the one remaining custodial family # no spend limits of its own.
# (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
# Audit Log
VECTOR_AUDIT_LOG_PATH=./vector-audit-log.json # Path for persistent audit log (default: ./vector-audit-log.json)
# Auth - when set, every request must carry `Authorization: Bearer <token>`. # Auth - when set, every request must carry `Authorization: Bearer <token>`.
# Comma-separated; each entry is `label:token` or a bare token. # Comma-separated; each entry is `label:token` or a bare token.

108
README.md
View File

@@ -8,29 +8,42 @@ Built on [Ogmios](https://ogmios.dev/) + [Koios](https://www.koios.rest/) - no B
## Hosted servers - no install ## Hosted servers - no install
Hosted instances run on both networks, exposing all 23 tools: Hosted instances run on both networks, exposing all 24 tools:
| Network | Endpoint | | Network | Endpoint |
|---------|----------| |---------|----------|
| Mainnet | `https://mcp.vector.mainnet.apexfusion.org/sse` | | Mainnet | `https://mcp.vector.mainnet.apexfusion.org/sse` |
| Testnet | `https://mcp.vector.testnet.apexfusion.org/sse` | | Testnet | `https://mcp.vector.testnet.apexfusion.org/sse` |
Connect from Claude Code in one command: > **Deployment status differs by network.** Testnet auto-deploys from `main` on every merge, so
> it tracks this codebase closely. **Mainnet stays on its last custodial image until a
> deliberate cutover deploy ships** - that deploy is a separate, future step, not automatic and
> not implied by any merge (see [docs/architecture/non-custodial-split.md](docs/architecture/non-custodial-split.md),
> section 11, "Rollout"). Until it ships, treat the mainnet instance's signing tools as
> custodial: assume they may still accept a mnemonic as a tool-call parameter, and prefer
> self-hosting this release or the testnet instance instead.
Connect from Claude Code in one command (testnet shown - swap in the mainnet URL once the
cutover deploy described above ships):
```bash ```bash
claude mcp add --transport sse vector-mcp https://mcp.vector.mainnet.apexfusion.org/sse claude mcp add --transport sse vector-mcp https://mcp.vector.testnet.apexfusion.org/sse
``` ```
> **Security notice — custody.** The wallet, transaction, smart-contract, and agent-registry > **Security notice: as of this release, the non-custodial migration is complete in this
> tools are now **keyless**: `build_*` tools construct unsigned transactions from a wallet > codebase.** No tool in this repository accepts a mnemonic, private key, or any other key
> *address* and never accept a mnemonic. Sign the returned CBOR with the local signer companion > material. Every `build_*` tool constructs an unsigned transaction from a wallet *address*
> and broadcast it with `vector_submit_transaction` — your seed phrase never leaves your machine. > only, and every signing operation happens locally on your own machine, through the local
> **The Self-Improvement Module (5 tools) is the only family still taking a mnemonic as a > signer companion. Broadcast the signed result with `vector_submit_transaction` - your seed
> tool-call parameter** (its keyless migration is next — see the architecture doc). For those > phrase never leaves your machine, and no server running this release ever holds one. This is
> tools the mnemonic passes through your MCP client, your model provider, and the hosted > enforced mechanically in the code, not just by convention: the builder's custody boundary test
> server's memory: use only a hot wallet holding funds you can afford to lose, and prefer > scans every source file for key-material vocabulary against an allowlist that is now empty and
> self-hosting until the migration completes. > pinned at size zero, so a future change cannot silently reintroduce a mnemonic parameter
> See [docs/architecture/non-custodial-split.md](docs/architecture/non-custodial-split.md). > without failing that test by name.
> **This is a claim about the code, not about every deployment of it** - see the deployment
> status note above the connect command, and
> [docs/architecture/non-custodial-split.md](docs/architecture/non-custodial-split.md) for the
> full rollout plan.
Self-hosting instructions are below. Self-hosting instructions are below.
@@ -42,17 +55,17 @@ stdio transport, no Provider, no egress — so a key given to it never reaches a
or your model provider. Four tools: `vector_signer_get_address`, `vector_signer_decode_transaction`, or your model provider. Four tools: `vector_signer_get_address`, `vector_signer_decode_transaction`,
`vector_signer_sign`, `vector_signer_get_spend_limits`. `vector_signer_sign`, `vector_signer_get_spend_limits`.
**Paired with this hosted builder, the full non-custodial flow is live today** for the wallet, **In this codebase, this signer is the single signing point for every family the builder
transaction, smart-contract, and agent-registry family: `vector_signer_get_address` → exposes.** The full non-custodial flow is live in this release for all of them:
`build_*` → `vector_signer_sign` → `vector_submit_transaction` → `vector_await_transaction`. No `vector_signer_get_address` → `build_*` → `vector_signer_sign` → `vector_submit_transaction` →
step in that chain puts a mnemonic in front of this server or your model provider. The `vector_await_transaction`. No step in that chain puts a mnemonic in front of a server running
build → submit → await half is E2E-proven on Vector testnet for both: a payment self-send, and this code, or your model provider, for any tool. The build → submit → await half is E2E-proven
a full register → update → deregister agent lifecycle with the 10 AP3X deposit round-tripped on Vector testnet across the wallet/tx, smart-contract, agent-registry, and self-improvement
exactly; see [`packages/signer/README.md`](packages/signer/README.md#known-limitations) for families, including the first keyless-built proposal submission the deployed self-improvement
exactly what that testing does and does not exercise on the signer's side. **The module validator has ever accepted; see
Self-Improvement Module is not part of that pairing yet** — its 5 tools still take a mnemonic [`packages/signer/README.md`](packages/signer/README.md#known-limitations) for exactly what that
as a tool-call parameter until their own keyless migration lands (see the security notice above testing does and does not exercise on the signer's side. **This describes the code, not every
and [docs/architecture/non-custodial-split.md](docs/architecture/non-custodial-split.md)). deployment of it** - see the deployment status note above.
See [`packages/signer/README.md`](packages/signer/README.md) for configuration, tools, and known See [`packages/signer/README.md`](packages/signer/README.md) for configuration, tools, and known
limitations. limitations.
@@ -61,14 +74,14 @@ limitations.
- **Wallet & queries** - balances, UTxOs, and transaction history for any address (no key material) - **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 - **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 - **Smart contracts** - deploy Plutus/Aiken validators, lock and spend UTxOs at script addresses (keyless)
- **Agent registry** - register, discover, update, transfer, and deregister on-chain AI agent identities via soulbound NFTs (keyless - build, sign locally, broadcast) - **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) - **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) - **Self-Improvement Module** - browse, submit, critique, and endorse improvement proposals; the on-chain module is live on Vector mainnet, and every write tool is keyless in this codebase
- **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) - **Safety controls** - per-identity rate limiting; spend limits for every family are enforced by the local signer, per user - the server holds no spend-limit state of its own
- **SSE transport** - HTTP server with Server-Sent Events for MCP client connectivity - **SSE transport** - HTTP server with Server-Sent Events for MCP client connectivity
## MCP Tools (23) ## MCP Tools (24)
### Wallet & Queries ### Wallet & Queries
@@ -113,10 +126,11 @@ limitations.
| Tool | Description | | Tool | Description |
|------|-------------| |------|-------------|
| `vector_self_improvement_browse` | Browse improvement proposals, critiques, and endorsements | | `vector_self_improvement_browse` | Browse improvement proposals, critiques, and endorsements |
| `vector_self_improvement_submit_proposal` | Submit an improvement proposal (stakes AP3X) |
| `vector_self_improvement_critique` | Critique a proposal - support, oppose, or propose amendments |
| `vector_self_improvement_endorse` | Endorse a proposal by staking AP3X |
| `vector_self_improvement_analyze_metrics` | Proposal metrics: activity, adoption rate, treasury health, engagement | | `vector_self_improvement_analyze_metrics` | Proposal metrics: activity, adoption rate, treasury health, engagement |
| `vector_build_self_improvement_proposal_lock` | Build an unsigned proposal-stake lock (step 1 of 2, keyless) |
| `vector_build_self_improvement_proposal_spend` | Build an unsigned proposal submission - spends the lock and mints the proposal and activity tokens (step 2 of 2, keyless). Two transactions, agent-orchestrated. |
| `vector_build_self_improvement_critique` | Build an unsigned critique on a proposal - support, oppose, or propose amendments (keyless) |
| `vector_build_self_improvement_endorse` | Build an unsigned endorsement of a proposal by staking AP3X (keyless) |
Agent DIDs follow the format: `did:vector:agent:{policyId}:{nftAssetName}` Agent DIDs follow the format: `did:vector:agent:{policyId}:{nftAssetName}`
@@ -139,9 +153,9 @@ cp .env.example .env
# Edit .env with your endpoint URLs (defaults point to Vector testnet; mainnet URLs below) # Edit .env with your endpoint URLs (defaults point to Vector testnet; mainnet URLs below)
``` ```
The wallet/tx/contract and agent-registry families are keyless: `build_*` tools take a wallet Every `build_*` tool is keyless: it takes a wallet address, never a mnemonic. No tool call
address, never a mnemonic. The Self-Improvement Module still takes a per-call mnemonic until against this server ever needs a mnemonic parameter, for any family - see the security notice
its keyless migration lands — see the security notice above. above.
### 3. Run ### 3. Run
@@ -194,15 +208,14 @@ If this instance will be reachable by anyone but you, set `MCP_AUTH_TOKENS` firs
| `VECTOR_KOIOS_URL` | Koios REST API endpoint | `https://v2.koios.vector.testnet.apexfusion.org/` | | `VECTOR_KOIOS_URL` | Koios REST API endpoint | `https://v2.koios.vector.testnet.apexfusion.org/` |
| `VECTOR_SUBMIT_URL` | Transaction submit API | `https://submit.vector.testnet.apexfusion.org/api/submit/tx` | | `VECTOR_SUBMIT_URL` | Transaction submit API | `https://submit.vector.testnet.apexfusion.org/api/submit/tx` |
| `VECTOR_EXPLORER_URL` | Block explorer base URL | `https://vector.testnet.apexscan.org` | | `VECTOR_EXPLORER_URL` | Block explorer base URL | `https://vector.testnet.apexscan.org` |
| `VECTOR_SPEND_LIMIT_PER_TX` | Max lovelace per transaction | `100000000` (100 AP3X) |
| `VECTOR_SPEND_LIMIT_DAILY` | Max lovelace per day | `500000000` (500 AP3X) |
| `VECTOR_AUDIT_LOG_PATH` | Persistent audit log file path | `./vector-audit-log.json` |
| `VECTOR_RATE_LIMIT_PER_MINUTE` | Max tool calls per minute | `60` | | `VECTOR_RATE_LIMIT_PER_MINUTE` | Max tool calls per minute | `60` |
| `MCP_AUTH_TOKENS` | Bearer tokens that may call this server. Comma-separated; each entry is `label:token` or a bare token. **When unset, the server is open to anyone who can reach it.** | _(unset — auth disabled)_ | | `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 This server has no spend-limit or audit-log configuration of its own: it holds no key material
Self-Improvement family — the keyless `build_*` families (wallet/tx/contract, agent registry) for any family, so it has nothing left to limit. Every spend limit, and the audit log recording
ignore them; their spend limits are enforced by the local signer instead. it, lives in your local signer instead - see `VECTOR_SIGNER_SPEND_LIMIT_PER_TX` /
`VECTOR_SIGNER_SPEND_LIMIT_DAILY` / `VECTOR_SIGNER_AUDIT_LOG_PATH` in
[`packages/signer/README.md`](packages/signer/README.md#configuration).
> **Running a public instance?** Set `MCP_AUTH_TOKENS`. Without it every caller > **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 > is treated as one anonymous identity and shares a single rate-limit bucket, so
@@ -244,7 +257,7 @@ echo "your mnemonic words here" > packages/builder/mnemonic.txt
npm run test:integration 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 (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. 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`, `registry-keyless.test.ts`, and `self-improvement-keyless.test.ts` - tier-1 suites needing no mnemonic at all, each building unsigned transactions against live public data only (`vector_build_send_tokens` is covered offline, by unit tests and the legacy suite above; `self-improvement-keyless.test.ts` covers critique, endorse, and proposal-lock builds, plus a not-found rejection path on proposal-spend). Set `VECTOR_E2E_SUBMIT=1` to additionally run each family's gated end-to-end suite, fully on-chain: `keyless-e2e.test.ts` lands one real self-send (~0.16 AP3X fee); `registry-e2e.test.ts` runs a full register → update → deregister agent lifecycle (the 10 AP3X deposit round-tripped exactly, fees only as net cost); `self-improvement-e2e.test.ts` locks and spends a real improvement proposal through the deployed module validator, then critiques and endorses it (≈48 AP3X in stakes and module minimums, plus six transaction fees, one-way - the stakes are locked by the module's own design, not lost). Set `LEGACY_FULL=1` to additionally exercise the same self-improvement lock → spend sequence inside `run.test.ts` itself (off by default, since `self-improvement-e2e.test.ts` already proves that exact path). Never runs in CI.
```bash ```bash
npm run test:smoke:signer npm run test:smoke:signer
@@ -276,13 +289,6 @@ against live chain data. **Never submits.** Never runs in CI. See
│ └────────┬───────────┘ │ │ └────────┬───────────┘ │
│ │ │ │ │ │
│ ┌────────▼───────────┐ │ │ ┌────────▼───────────┐ │
│ │ Safety Layer * │ │
│ │ - Per-tx limits │ │
│ │ - Daily limits │ │
│ │ - Audit log │ │
│ └────────┬───────────┘ │
│ │ │
│ ┌────────▼───────────┐ │
│ │ Lucid + Ogmios │ │ │ │ Lucid + Ogmios │ │
│ │ Provider │ │ │ │ Provider │ │
│ └────────┬───────────┘ │ │ └────────┬───────────┘ │
@@ -294,9 +300,9 @@ against live chain data. **Never submits.** Never runs in CI. See
└──────────────────────────┘ └──────────────────────────┘
``` ```
\* The still-custodial Self-Improvement family only — the keyless `build_*` path (wallet/tx/ No safety layer sits between the rate limiter and the provider: this server holds no key
contract, agent registry) never reaches the Safety Layer; its spend limits are enforced by the material for any family, so it has no spend limits of its own left to enforce. Every spend
local signer instead. limit lives in your local signer instead.
## About Vector ## About Vector

View File

@@ -182,16 +182,71 @@ Build an unsigned transaction that sends an on-chain message to a registered age
**Example prompt:** "Send an inquiry message to did:vector:agent:... asking about pricing" **Example prompt:** "Send an inquiry message to did:vector:agent:... asking about pricing"
### vector_build_self_improvement_proposal_lock
Build an unsigned transaction that locks an improvement-proposal stake (step 1 of 2, keyless). After this confirms, call `vector_build_self_improvement_proposal_spend` with the lock transaction hash to complete the submission.
**Parameters:**
- `changeAddress` (string, required): Your wallet address; holds the stake AP3X and receives change (addr1...)
- `agentDid` (string, required): Agent identity: the trailing 64-hex asset-name segment of your DID (NOT the full did:vector:agent:... string)
- `proposalType` (string, required): "ParameterChange", "TreasurySpend", "ProtocolUpgrade", "GameActivation", or "GeneralSuggestion"
- `stakeApex` (number, required): AP3X to stake (minimum 25)
- `typeParams` (object, optional): Type-specific parameters (required for ParameterChange and TreasurySpend)
- `priority` (string, optional): "Standard" or "Emergency" (default: "Standard"; Emergency requires higher stake and reputation)
- `proposalDocument` (string, optional): Full proposal document as JSON; uploaded to IPFS automatically, hash and CID computed for you
- `proposalHash` (string, optional): blake2b_256 hash of the proposal document (64 hex chars), required if `proposalDocument` is not provided
- `storageUri` (string, optional): Off-chain storage URI (IPFS CID or OriginTrail UAL), required if `proposalDocument` is not provided
**Example prompt:** "Lock a 25 AP3X GeneralSuggestion proposal about archive-extraction workflows"
### vector_build_self_improvement_proposal_spend
Build an unsigned transaction that completes an improvement-proposal submission (step 2 of 2, keyless): spends the locked stake through the deployed module validator and mints the proposal and activity tokens. Requires the confirmed lock transaction hash from `vector_build_self_improvement_proposal_lock`. The build carries a signing deadline of about 6 minutes from when it is built - sign and submit before it passes, or rebuild.
**Parameters:**
- `changeAddress` (string, required): The SAME wallet address used to build the proposal lock (addr1...)
- `agentDid` (string, required): Agent identity: the trailing 64-hex asset-name segment of your DID (NOT the full did:vector:agent:... string) - must match the proposer DID in the locked proposal
- `lockTxHash` (string, required): The CONFIRMED transaction hash from the proposal lock step
- `lockOutputIndex` (number, optional): Output index of the locked proposal UTxO (default: 0)
**Example prompt:** "Complete my proposal submission using lock transaction abc123..."
### vector_build_self_improvement_critique
Build an unsigned transaction that submits a critique on an improvement proposal (Supportive, Opposing, or Amendment), keyless. Requires staking at least 10 AP3X. Find a `proposalTxHash` with `vector_self_improvement_browse`.
**Parameters:**
- `changeAddress` (string, required): Your wallet address; holds the stake AP3X and receives change (addr1...)
- `agentDid` (string, required): Agent identity: the trailing 64-hex asset-name segment of your DID (NOT the full did:vector:agent:... string)
- `proposalTxHash` (string, required): TX hash of the proposal UTxO to critique
- `proposalOutputIndex` (number, optional): Output index of the proposal UTxO (default: 0)
- `critiqueType` (string, required): "Supportive", "Opposing", or "Amendment"
- `critiqueDocument` (string, optional): Full critique document as JSON; uploaded to IPFS automatically
- `critiqueHash` (string, optional): blake2b_256 hash of the critique document (64 hex chars), required if `critiqueDocument` is not provided
- `storageUri` (string, optional): Off-chain storage URI for the critique, required if `critiqueDocument` is not provided
- `stakeApex` (number, required): AP3X to stake (minimum 10)
**Example prompt:** "Submit a supportive critique on proposal abc123... staking 10 AP3X"
### vector_build_self_improvement_endorse
Build an unsigned transaction that endorses an improvement proposal by staking AP3X, keyless. Endorsements signal support and are weighted by stake amount. Find a `proposalTxHash` with `vector_self_improvement_browse`.
**Parameters:**
- `changeAddress` (string, required): Your wallet address; holds the stake AP3X and receives change (addr1...)
- `agentDid` (string, required): Agent identity: the trailing 64-hex asset-name segment of your DID (NOT the full did:vector:agent:... string)
- `proposalTxHash` (string, required): TX hash of the proposal UTxO to endorse
- `proposalOutputIndex` (number, optional): Output index of the proposal UTxO (default: 0)
- `stakeApex` (number, required): AP3X to stake as endorsement (minimum 5)
**Example prompt:** "Endorse proposal abc123... with a 5 AP3X stake"
## Safety Controls ## Safety Controls
The tools above are keyless and take no key material, so the server no longer enforces Every tool this server exposes is keyless and takes no key material, so the server enforces no
spend limits on them. Spend policy is enforced by your **local signer** when it signs the spend limits of its own. Spend policy is enforced entirely by your **local signer** when it
CBOR these tools return — see the security notice in the repo [README](../README.md) and signs the CBOR these tools return - see the security notice in the repo [README](../README.md)
[docs/architecture/non-custodial-split.md](architecture/non-custodial-split.md). and [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 one remaining custodial tool family (self-improvement) until its own
keyless migration lands.
## Network Endpoints ## Network Endpoints

View File

@@ -121,7 +121,7 @@ interface Policy {
**Accepted trade-off of the 4-call shape:** the signer records **intent to spend** at signing time, not confirmed submission. If a submit later fails, the audit log over-counts. Conservative, therefore acceptable — and it is the price of the signer having no network access. Documented, not hidden. **Accepted trade-off of the 4-call shape:** the signer records **intent to spend** at signing time, not confirmed submission. If a submit later fails, the audit log over-counts. Conservative, therefore acceptable — and it is the price of the signer having no network access. Documented, not hidden.
### Two-step governance flow ### Two-step self-improvement flow
`submit_proposal` is a lock-then-spend pair with a confirmation wait between. Keyless, that orchestration becomes explicit: `submit_proposal` is a lock-then-spend pair with a confirmation wait between. Keyless, that orchestration becomes explicit:
@@ -130,7 +130,7 @@ build_self_improvement_proposal_lock → sign → submit → await_transaction
→ build_self_improvement_proposal_spend(lockTxHash) → sign → submit → build_self_improvement_proposal_spend(lockTxHash) → sign → submit
``` ```
This is why governance is the last family: the LLM must drive a stateful, multi-transaction sequence. This is why self-improvement is the last family: the LLM must drive a stateful, multi-transaction sequence.
## 7. Tool inventory ## 7. Tool inventory
@@ -142,7 +142,7 @@ This is why governance is the last family: the LLM must drive a stateful, multi-
| Build — tx (3) | `build_send_apex`, `build_send_tokens`, `build_transaction` | | Build — tx (3) | `build_send_apex`, `build_send_tokens`, `build_transaction` |
| Build — contracts (2) | `build_deploy_contract`, `build_interact_contract` | | Build — contracts (2) | `build_deploy_contract`, `build_interact_contract` |
| Build — registry (5) | `build_register_agent`, `build_update_agent`, `build_transfer_agent`, `build_deregister_agent`, `build_message_agent` | | Build — registry (5) | `build_register_agent`, `build_update_agent`, `build_transfer_agent`, `build_deregister_agent`, `build_message_agent` |
| Build — governance (4) | `build_self_improvement_proposal_lock`, `build_self_improvement_proposal_spend`, `build_self_improvement_critique`, `build_self_improvement_endorse` | | Build — self-improvement (4) | `build_self_improvement_proposal_lock`, `build_self_improvement_proposal_spend`, `build_self_improvement_critique`, `build_self_improvement_endorse` |
| Chain ops (2, new) | `submit_transaction`, `await_transaction` | | Chain ops (2, new) | `submit_transaction`, `await_transaction` |
**Signer — 4 tools, holds the key.** **Signer — 4 tools, holds the key.**
@@ -190,7 +190,7 @@ TDD throughout: failing test before implementation, every PR.
| **5** | Builder hardening: auth (enforced when configured), per-identity rate limits, `submit_transaction`, `await_transaction` | 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** | | **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` ✅ **(landed — this PR)** | 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 | | **8** | **Family 3 — self-improvement keyless.** Ref-UTxO env config carried over from the custodial code; the suspected CBOR encoding issue closed as no-bug (audit #10, golden-pinned against a live on-chain token) ✅ **(landed — this PR)** | yes |
| **9** | Mainnet cutover: README, security-page correction, deliberate `workflow_dispatch` | — | | **9** | Mainnet cutover: README, security-page correction, deliberate `workflow_dispatch` | — |
**PR 4 is the review that matters.** It is additive, self-contained, and *is* the security boundary. It breaks nothing while it is reviewed. **PR 4 is the review that matters.** It is additive, self-contained, and *is* the security boundary. It breaks nothing while it is reviewed.
@@ -207,7 +207,7 @@ TDD throughout: failing test before implementation, every PR.
| D | `min(1)` APEX floor → real dust floor | | D | `min(1)` APEX floor → real dust floor |
| E | Endpoint URLs in error text | | E | Endpoint URLs in error text |
| F | SSE → Streamable HTTP | | F | SSE → Streamable HTTP |
| G | Float → bigint money math | | G | Float → bigint amount math |
| H | Dependency updates | | H | Dependency updates |
### Dependency graph ### Dependency graph

View File

@@ -0,0 +1,237 @@
// packages/builder/scripts/capture-gov-state.mjs
//
// One-time capture: deployed-state fixture for the self-improvement family's
// keyless build core (gov-build.ts). Pulls the 2 reference-script UTxOs, the
// 3 module infrastructure UTxOs, and one live sample proposal UTxO straight
// from Ogmios (raw JSON-RPC, queryLedgerState/utxo), then derives the
// audit-#10 golden token-name pin from the sample's own producing
// transaction (via Koios tx_utxos). Validates every piece before writing so
// the fixture can never silently drift from deployed bytecode - a failed
// validation exits non-zero instead of writing a bad file.
//
// deriveProposalTokenName is inlined below rather than imported from
// gov-build.ts: this script is step 1 of the TDD sequence and runs before
// gov-build.ts exists. Keep this copy byte-for-byte identical to
// gov-build.ts's exported version (both trace to the custodial
// self-improvement.ts original, audit-#10 verified against deployed
// bytecode).
//
// Resolves its own location via import.meta.dirname, so it behaves
// identically regardless of invocation cwd (same convention as
// regen-tool-schemas.mjs). Never runs in CI - unit tests read the
// checked-in JSON at test/fixtures/gov-state.fixture.json.
//
// Run manually (reaches the live testnet):
// node packages/builder/scripts/capture-gov-state.mjs
import { writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { Data, Constr, credentialToAddress, validatorToScriptHash } from '@lucid-evolution/lucid';
import { blake2b } from '@noble/hashes/blake2b';
const OGMIOS = process.env.VECTOR_OGMIOS_URL || 'https://ogmios.vector.testnet.apexfusion.org';
const KOIOS = (process.env.VECTOR_KOIOS_URL || 'https://v2.koios.vector.testnet.apexfusion.org/').replace(/\/+$/, '') + '/api/v1';
const GOV_PROPOSAL_SPEND_HASH = process.env.GOV_PROPOSAL_SPEND_HASH || 'f815f51a76002d6a973e83fecf60f45473e040acee85c631fcce134d';
const GOV_PROPOSAL_MINT_HASH = process.env.GOV_PROPOSAL_MINT_HASH || 'e8f38052352a3d20c5fe025e2a02d615826a154b26f2239286b8d565';
function parseRef(ref) {
const [txHash, idx] = ref.split('#');
return { txHash, index: Number(idx) };
}
const SPEND_REF = parseRef(process.env.GOV_PROPOSAL_SPEND_REF || 'c70a410c9c0c543a0a1103049680b89cbf6fd2277e8469c4d52632dc52b80996#0');
const MINT_REF = parseRef(process.env.GOV_PROPOSAL_MINT_REF || 'c40b64fff5c4056689354076aa3d06431d786a4f8f0c1e492e70261d2309e50a#0');
const INFRA = [
['PARAMS', process.env.GOV_PARAMS_UTXO || '2c082e833649175b4a543a5a0cf61f9b736acdfa0d315d1184645185e9a52796#0'],
['ORACLE', process.env.GOV_ORACLE_UTXO || '7a23dfdf9468dd35cee3cad03008f2538c86834d4e5140e0ffaf2ff93e7c04a7#0'],
['CROSSREFS', process.env.GOV_CROSSREFS_UTXO || '96a4acff8be0fb96b3839ee6c9c1fa75809b94f4967218eaf813ac56b939c4b2#0'],
].map(([name, ref]) => ({ name, ...parseRef(ref) }));
async function ogmios(method, params) {
const r = await fetch(OGMIOS, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', method, params, id: 1 }),
});
if (!r.ok) throw new Error(`Ogmios HTTP error (${r.status}) calling ${method}: ${await r.text()}`);
const j = await r.json();
if (j.error) throw new Error(`Ogmios RPC error calling ${method}: ${JSON.stringify(j.error)}`);
return j.result;
}
// Same shape OgmiosProvider.ogmiosUtxosToLucid checks (packages/shared/src/provider.ts):
// inline datum arrives as a plain CBOR-hex string on `output.datum` for this
// Ogmios version; only fall through to the object forms defensively.
function extractDatum(output) {
if (!output.datum) return null;
if (typeof output.datum === 'string') return output.datum;
if (output.datum.value || output.datum.bytes) return output.datum.value || output.datum.bytes;
return null;
}
function deriveTokenName(prefix, cborHex) {
const hashBytes = blake2b(Buffer.from(cborHex, 'hex'), { dkLen: 32 });
const prefixHex = Buffer.from(prefix, 'utf-8').toString('hex');
const hashSlice = Buffer.from(hashBytes).toString('hex').slice(0, 54); // 27 bytes = 54 hex chars
return prefixHex + hashSlice;
}
function deriveProposalTokenName(txHash, outputIndex) {
const outRefCbor = Data.to(new Constr(0, [txHash, BigInt(outputIndex)]));
return deriveTokenName('prop_', outRefCbor);
}
const failures = [];
const fail = (msg) => failures.push(msg);
console.log(`Ogmios: ${OGMIOS}`);
console.log(`Koios: ${KOIOS}`);
console.log('\nFetching reference-script UTxOs...');
const refUtxoResults = await ogmios('queryLedgerState/utxo', {
outputReferences: [
{ transaction: { id: SPEND_REF.txHash }, index: SPEND_REF.index },
{ transaction: { id: MINT_REF.txHash }, index: MINT_REF.index },
],
});
const refScripts = [];
for (const [label, ref, expectedHash] of [
['spend', SPEND_REF, GOV_PROPOSAL_SPEND_HASH],
['mint', MINT_REF, GOV_PROPOSAL_MINT_HASH],
]) {
const u = refUtxoResults.find((x) => x.transaction.id === ref.txHash && x.index === ref.index);
if (!u) { fail(`${label} reference script UTxO not found: ${ref.txHash}#${ref.index}`); continue; }
const lang = u.script?.language;
const cbor = u.script?.cbor || '';
if (lang !== 'plutus:v3') { fail(`${label} ref script language is "${lang}", want "plutus:v3"`); continue; }
if (!cbor) { fail(`${label} ref script UTxO ${ref.txHash}#${ref.index} has no script CBOR`); continue; }
let computedHash = '';
try {
computedHash = validatorToScriptHash({ type: 'PlutusV3', script: cbor });
} catch (e) {
fail(`${label} script hash computation failed: ${e instanceof Error ? e.message : String(e)}`);
continue;
}
if (computedHash !== expectedHash) {
fail(`${label} computed hash ${computedHash} does not match the constant ${expectedHash}`);
continue;
}
console.log(` ${label}: ${ref.txHash}#${ref.index} - ${cbor.length / 2} bytes - hash ${computedHash} (matches constant)`);
refScripts.push({ name: label, txHash: ref.txHash, index: ref.index, scriptCborHex: cbor, hash: computedHash });
}
console.log('\nFetching infrastructure UTxOs...');
const infraUtxoResults = await ogmios('queryLedgerState/utxo', {
outputReferences: INFRA.map((i) => ({ transaction: { id: i.txHash }, index: i.index })),
});
const infra = [];
for (const i of INFRA) {
const u = infraUtxoResults.find((x) => x.transaction.id === i.txHash && x.index === i.index);
if (!u) { fail(`infra UTxO ${i.name} not found: ${i.txHash}#${i.index}`); continue; }
const lovelace = u.value?.ada?.lovelace;
console.log(` ${i.name}: ${i.txHash}#${i.index} - ALIVE - ${lovelace ?? '?'} lovelace`);
infra.push({
name: i.name,
txHash: i.txHash,
index: i.index,
address: u.address,
valueLovelace: lovelace !== undefined ? String(lovelace) : '0',
datumCbor: extractDatum(u),
});
}
console.log('\nFetching sample proposal UTxO (first prop_-token holder)...');
const proposalSpendAddress = credentialToAddress('Mainnet', { type: 'Script', hash: GOV_PROPOSAL_SPEND_HASH });
const proposalUtxoResults = await ogmios('queryLedgerState/utxo', { addresses: [proposalSpendAddress] });
const propPrefix = Buffer.from('prop_', 'utf-8').toString('hex');
let sample = null;
let sampleTokenName = '';
for (const u of proposalUtxoResults) {
const assets = u.value?.[GOV_PROPOSAL_MINT_HASH];
if (!assets) continue;
const name = Object.keys(assets).find((n) => n.startsWith(propPrefix));
if (name) { sample = u; sampleTokenName = name; break; }
}
let sampleProposal = null;
let goldenTokenPin = null;
if (!sample) {
fail(`no prop_-token UTxO found at ${proposalSpendAddress} among ${proposalUtxoResults.length} UTxOs - need at least one submitted proposal to capture a sample and the golden token-name pin`);
} else {
console.log(` sample: ${sample.transaction.id}#${sample.index} token=${sampleTokenName.slice(0, 24)}...`);
const datumCbor = extractDatum(sample);
if (!datumCbor) {
fail(`sample proposal UTxO ${sample.transaction.id}#${sample.index} has no inline datum`);
} else {
sampleProposal = {
txHash: sample.transaction.id,
index: sample.index,
datumCbor,
tokenUnit: GOV_PROPOSAL_MINT_HASH + sampleTokenName,
};
console.log('\nDeriving the golden token-name pin from the producing transaction (Koios)...');
const kr = await fetch(`${KOIOS}/tx_utxos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', accept: 'application/json' },
body: JSON.stringify({ _tx_hashes: [sample.transaction.id] }),
});
if (!kr.ok) {
fail(`Koios tx_utxos query failed (${kr.status}): ${await kr.text()}`);
} else {
const kj = await kr.json();
const inputs = kj?.[0]?.inputs || [];
console.log(` producing tx ${sample.transaction.id.slice(0, 12)}... has ${inputs.length} input(s)`);
for (const inp of inputs) {
const inHash = inp.tx_hash;
const inIdx = Number(inp.tx_index ?? inp.index ?? 0);
const recomputed = deriveProposalTokenName(inHash, inIdx);
if (recomputed === sampleTokenName) {
goldenTokenPin = { lockTxHash: inHash, lockOutputIndex: inIdx, expectedTokenName: sampleTokenName };
console.log(` MATCH: input ${inHash}#${inIdx} reproduces the on-chain token name via deriveProposalTokenName`);
break;
}
}
if (!goldenTokenPin) {
fail(`no input of spend tx ${sample.transaction.id} reproduces the on-chain token name ${sampleTokenName} via deriveProposalTokenName - audit-#10 regression, STOP`);
}
}
}
}
if (failures.length > 0) {
console.error('\nCAPTURE VALIDATION FAILED:');
for (const f of failures) console.error(` - ${f}`);
process.exit(1);
}
// Deliberately dereference sampleProposal/goldenTokenPin's fields here,
// BEFORE writeFileSync, rather than only embedding them in the fixture
// object below: an object literal doesn't dereference its values, so if a
// future success path ever left either variable null without also pushing a
// fail() (the failures.length check above would then wrongly let a null
// slip through), `const fixture = {...}` and JSON.stringify would happily
// write "sampleProposal": null / "goldenTokenPin": null to disk with no
// error at all. Real property access (.txHash, .lockTxHash, ...) throws a
// TypeError on null, so doing it here makes the STOP-before-write guarantee
// a structural crash-before-write instead of resting solely on every
// success path remembering to keep the failures array in sync.
console.log('\nCapture validated, about to write:');
console.log(` refScripts: ${refScripts.length}/2, infra: ${infra.length}/3`);
console.log(` sampleProposal: ${sampleProposal.txHash}#${sampleProposal.index}`);
console.log(` goldenTokenPin.lockTxHash: ${goldenTokenPin.lockTxHash}`);
console.log(` goldenTokenPin.lockOutputIndex: ${goldenTokenPin.lockOutputIndex}`);
console.log(` goldenTokenPin.expectedTokenName: ${goldenTokenPin.expectedTokenName}`);
const fixture = {
asOf: new Date().toISOString(),
refScripts,
infra,
sampleProposal,
goldenTokenPin,
};
const outPath = resolve(import.meta.dirname, '../test/fixtures/gov-state.fixture.json');
writeFileSync(outPath, JSON.stringify(fixture, null, 2) + '\n');
console.log(`\nwrote ${outPath}`);

View File

@@ -5,12 +5,5 @@ declare namespace NodeJS {
VECTOR_KOIOS_URL: string; VECTOR_KOIOS_URL: string;
VECTOR_SUBMIT_URL: string; VECTOR_SUBMIT_URL: string;
VECTOR_EXPLORER_URL: string; VECTOR_EXPLORER_URL: string;
// Safety Limits
VECTOR_SPEND_LIMIT_PER_TX: string;
VECTOR_SPEND_LIMIT_DAILY: string;
// Audit Log
VECTOR_AUDIT_LOG_PATH: string;
} }
} }

View File

@@ -15,6 +15,7 @@ import {
MIN_AP3X_DEPOSIT, getRegistryAddress, resolveAgentUtxo, parseAgentDatum, MIN_AP3X_DEPOSIT, getRegistryAddress, resolveAgentUtxo, parseAgentDatum,
buildRegisterAgent, buildUpdateAgent, buildTransferAgent, buildDeregisterAgent, buildMessageAgent, buildRegisterAgent, buildUpdateAgent, buildTransferAgent, buildDeregisterAgent, buildMessageAgent,
} from './registry-build.js'; } from './registry-build.js';
import type { RegistryAgentProfile } from './registry-build.js';
function newProvider() { function newProvider() {
return new OgmiosProvider({ ogmiosUrl: VECTOR_OGMIOS_URL, submitUrl: VECTOR_SUBMIT_URL, koiosUrl: VECTOR_KOIOS_URL }); return new OgmiosProvider({ ogmiosUrl: VECTOR_OGMIOS_URL, submitUrl: VECTOR_SUBMIT_URL, koiosUrl: VECTOR_KOIOS_URL });
@@ -102,7 +103,7 @@ ${capList}
try { try {
const provider = newProvider(); const provider = newProvider();
const utxos = await provider.getUtxos(getRegistryAddress()); const utxos = await provider.getUtxos(getRegistryAddress());
const profiles = []; const profiles: RegistryAgentProfile[] = [];
for (const utxo of utxos) { for (const utxo of utxos) {
if (!utxo.datum) continue; if (!utxo.datum) continue;
const profile = parseAgentDatum(utxo.datum, `${utxo.txHash}#${utxo.outputIndex}`, utxo.assets); const profile = parseAgentDatum(utxo.datum, `${utxo.txHash}#${utxo.outputIndex}`, utxo.assets);
@@ -158,7 +159,7 @@ ${capList}
if (rateLimited) return rateLimited; if (rateLimited) return rateLimited;
try { try {
const lucid = await lucidForAddress(newProvider(), changeAddress); const lucid = await lucidForAddress(newProvider(), changeAddress);
const r = await buildRegisterAgent(lucid, { changeAddress, name, description, capabilities, framework, endpoint }); const r = await buildRegisterAgent(lucid, { name, description, capabilities, framework, endpoint });
return { return {
content: [{ content: [{
type: "text" as const, type: "text" as const,
@@ -214,7 +215,7 @@ The DID above is bound to the exact wallet UTxO this build consumed - if that UT
try { try {
const provider = newProvider(); const provider = newProvider();
const lucid = await lucidForAddress(provider, changeAddress); const lucid = await lucidForAddress(provider, changeAddress);
const r = await buildUpdateAgent(lucid, provider, { changeAddress, agentId: agent_id, name, description, capabilities, framework, endpoint }); const r = await buildUpdateAgent(lucid, provider, { agentId: agent_id, name, description, capabilities, framework, endpoint });
return { return {
content: [{ content: [{
type: "text" as const, type: "text" as const,
@@ -263,7 +264,7 @@ The identity NFT and deposit are unchanged by this update. ${SIGN_AND_SUBMIT_COP
try { try {
const provider = newProvider(); const provider = newProvider();
const lucid = await lucidForAddress(provider, changeAddress); const lucid = await lucidForAddress(provider, changeAddress);
const r = await buildTransferAgent(lucid, provider, { changeAddress, agentId: agent_id, newOwnerAddress: new_owner_address }); const r = await buildTransferAgent(lucid, provider, { agentId: agent_id, newOwnerAddress: new_owner_address });
return { return {
content: [{ content: [{
type: "text" as const, type: "text" as const,
@@ -313,7 +314,7 @@ After this transaction confirms, only the new owner can update, transfer, or der
try { try {
const provider = newProvider(); const provider = newProvider();
const lucid = await lucidForAddress(provider, changeAddress); const lucid = await lucidForAddress(provider, changeAddress);
const r = await buildDeregisterAgent(lucid, provider, { changeAddress, agentId: agent_id }); const r = await buildDeregisterAgent(lucid, provider, { agentId: agent_id });
return { return {
content: [{ content: [{
type: "text" as const, type: "text" as const,
@@ -365,7 +366,7 @@ The identity NFT is burned by this transaction; the deposit returns to the chang
try { try {
const provider = newProvider(); const provider = newProvider();
const lucid = await lucidForAddress(provider, changeAddress); const lucid = await lucidForAddress(provider, changeAddress);
const r = await buildMessageAgent(lucid, provider, { changeAddress, agentId: agent_id, messageType: message_type, payload }); const r = await buildMessageAgent(lucid, provider, { agentId: agent_id, messageType: message_type, payload });
return { return {
content: [{ content: [{
type: "text" as const, type: "text" as const,

View File

@@ -0,0 +1,807 @@
// Keyless build core for the self-improvement family (spec PR 8).
// Constructs UNSIGNED transactions only. The user's key never enters this
// package: signing happens on the local signer (packages/signer), submission
// via vector_submit_transaction. Constants, datum parsers, token-name
// derivation, and helpers here are transcribed from the (still custodial,
// pending Task 5) packages/builder/src/vector/self-improvement.ts, typed and
// with instanceof Constr guards added to the parsers - datum field indices
// and Data.to/Constr layouts are otherwise unchanged, since the deployed
// module validators parse these exact bytes.
import {
fromText, toText, Data, Constr, credentialToAddress, getAddressDetails, SLOT_CONFIG_NETWORK,
} from '@lucid-evolution/lucid';
import type { LucidEvolution, UTxO } from '@lucid-evolution/lucid';
import { blake2b } from '@noble/hashes/blake2b';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import type { OgmiosProvider } from '@apexfusion/vector-mcp-shared/provider';
import type {
VectorBuildStakeResult, VectorBuildProposalLockResult, VectorBuildProposalSpendResult,
} from '@apexfusion/vector-mcp-shared/types';
import { toBuildResult } from './build.js';
import { getRegistryAddress } from './registry-build.js';
// ─── Module constants (env-overridable exactly as the custodial module) ────
// Agent Registry policy ID - this module's own copy (self-improvement.ts has
// always declared this independently of registry-build.ts's REGISTRY_POLICY_ID;
// preserved as-is, not unified, since that's a separate, out-of-scope change).
export const AGENT_REGISTRY_POLICY = process.env.AGENT_REGISTRY_POLICY || 'be1a0a2912da180757ed3cd61b56bb8eab0188c19dc3c0e3912d2c01';
// Module contract hashes (from deploy_state.json). GOV_* names are pre-existing
// deployed config keys - kept as-is (renaming them is a separate, out-of-scope
// change; see the PR body's env-var exemption note).
export const GOV_PROPOSAL_SPEND_HASH = process.env.GOV_PROPOSAL_SPEND_HASH || 'f815f51a76002d6a973e83fecf60f45473e040acee85c631fcce134d';
export const GOV_PROPOSAL_MINT_HASH = process.env.GOV_PROPOSAL_MINT_HASH || 'e8f38052352a3d20c5fe025e2a02d615826a154b26f2239286b8d565';
export const GOV_CRITIQUE_SPEND_HASH = process.env.GOV_CRITIQUE_SPEND_HASH || 'ced52074861af95e2082004d6061b0fc4bb30fded61f9605bfc20e55';
export const GOV_CRITIQUE_MINT_HASH = process.env.GOV_CRITIQUE_MINT_HASH || '2e252a89894d379ce5c0023a57de4627056e4a96da72bd8fedba04bd';
export const GOV_ENDORSEMENT_SPEND_HASH = process.env.GOV_ENDORSEMENT_SPEND_HASH || '5fc449848d85f30287e5bc0bd2b3e95d872ef97be27f1480c12f1a9d';
export const GOV_TREASURY_ADDRESS = process.env.GOV_TREASURY_ADDRESS || 'addr1wx434t2jc3m5uhdf7tq05xjdqu3q5z7a2lhrmn5mapsd43srh7ll8';
// Reference script UTxOs (CIP-33) - for the validated proposal spend+mint (Task 4).
export const GOV_PROPOSAL_SPEND_REF = process.env.GOV_PROPOSAL_SPEND_REF || 'c70a410c9c0c543a0a1103049680b89cbf6fd2277e8469c4d52632dc52b80996#0';
export const GOV_PROPOSAL_MINT_REF = process.env.GOV_PROPOSAL_MINT_REF || 'c40b64fff5c4056689354076aa3d06431d786a4f8f0c1e492e70261d2309e50a#0';
// Infrastructure UTxOs (module reference inputs).
export const GOV_PARAMS_UTXO = process.env.GOV_PARAMS_UTXO || '2c082e833649175b4a543a5a0cf61f9b736acdfa0d315d1184645185e9a52796#0';
export const GOV_ORACLE_UTXO = process.env.GOV_ORACLE_UTXO || '7a23dfdf9468dd35cee3cad03008f2538c86834d4e5140e0ffaf2ff93e7c04a7#0';
export const GOV_CROSSREFS_UTXO = process.env.GOV_CROSSREFS_UTXO || '96a4acff8be0fb96b3839ee6c9c1fa75809b94f4967218eaf813ac56b939c4b2#0';
// Proposal state CBOR constructor tags.
export const STATE_NAMES: Record<number, string> = {
0: 'Open',
1: 'Amended',
2: 'Adopted',
3: 'Rejected',
4: 'Expired',
5: 'Withdrawn',
};
export const TYPE_NAMES: Record<number, string> = {
0: 'ParameterChange',
1: 'TreasurySpend',
2: 'ProtocolUpgrade',
3: 'GameActivation',
4: 'GeneralSuggestion',
};
export const PRIORITY_NAMES: Record<number, string> = {
0: 'Standard',
1: 'Emergency',
};
export const CRITIQUE_TYPE_NAMES: Record<number, string> = {
0: 'Supportive',
1: 'Opposing',
2: 'Amendment',
};
// Wire-level stake minimums (mirrored in the future tool schemas, Task 5) -
// enforced here too so a direct caller of buildCritique/buildEndorse cannot
// bypass them.
export const MIN_CRITIQUE_STAKE_APEX = 10;
export const MIN_ENDORSE_STAKE_APEX = 5;
export const MIN_PROPOSAL_STAKE_APEX = 25;
// ─── Slot/time config ───────────────────────────────────────────────────────
// Minimal provider surface needed for slot/time queries. OgmiosProvider
// satisfies this at runtime; the base Lucid `Provider` interface does not
// declare either method (they are OgmiosProvider-specific additions), so
// build functions that only take a `lucid` recover this surface via
// slotClockOf() below rather than taking a redundant provider parameter.
export interface SlotClockProvider {
getSystemStartMs(): Promise<number>;
getNetworkTip?(): Promise<{ slot: number; hash?: string }>;
}
// Recovers the provider a LucidEvolution instance was built with (Lucid()'s
// own config closure returns the exact same reference it was constructed
// with - see @lucid-evolution/lucid's Lucid() factory). Lets build functions
// that only take `lucid` still reach slot/tip queries: callers (including
// offline tests) supply the extended provider once, at lucidForAddress() time.
export function slotClockOf(lucid: LucidEvolution): SlotClockProvider {
const provider = lucid.config().provider;
if (!provider) throw new Error('Lucid instance has no provider configured - build it via lucidForAddress first.');
return provider as unknown as SlotClockProvider;
}
// Vector slot config - resolved from chain via Ogmios queryNetwork/startTime.
// Cached after first query so SLOT_CONFIG_NETWORK is set before Lucid needs
// it. Module-global cache, same as the custodial original: callers (tests
// included) must use a consistent zeroTime across calls in one process.
let vectorZeroTime: number | null = null;
export async function ensureSlotConfig(provider: Pick<SlotClockProvider, 'getSystemStartMs'>): Promise<number> {
if (vectorZeroTime === null) {
vectorZeroTime = await provider.getSystemStartMs();
SLOT_CONFIG_NETWORK.Mainnet = { zeroTime: vectorZeroTime, zeroSlot: 0, slotLength: 1000 };
}
return vectorZeroTime;
}
// ─── Small helpers ───────────────────────────────────────────────────────────
export function lovelaceToApex(lovelace: number | bigint): string {
return (Number(BigInt(String(lovelace))) / 1_000_000).toFixed(6);
}
export function scriptHashToAddress(hash: string): string {
return credentialToAddress('Mainnet', { type: 'Script', hash });
}
export function parseUtxoRef(ref: string): { txHash: string; outputIndex: number } {
const [txHash, idx] = ref.split('#');
return { txHash, outputIndex: parseInt(idx, 10) };
}
// Address helper local to this family (mirrors registry-build.ts's
// paymentKeyHashOf exactly, kept as an independent copy rather than a
// cross-family import - each build-core file depends only on build.ts, not
// on a sibling family file; see task-3-report.md for the reasoning).
//
// Deliberate exception to that rule (Task 4): buildProposalSpend below DOES
// import getRegistryAddress from registry-build.ts - the self-improvement
// family's NFT-resolution fallback needs the registry's own script address
// to scan, and that address is derived from registry-build.ts's own
// REGISTRY_SCRIPT_CBOR constant, not something this file could reasonably
// duplicate without risking the two copies drifting apart. One-directional
// (gov-build.ts -> registry-build.ts only, never the reverse), so it does
// not introduce a cycle.
export function paymentKeyHashOf(address: string): string {
let details;
try {
details = getAddressDetails(address);
} catch (e) {
throw new Error(`Invalid address: ${e instanceof Error ? e.message : String(e)}`);
}
if (details.paymentCredential?.type !== 'Key' || !details.paymentCredential.hash) {
throw new Error('Address must have a verification key payment credential, not a script credential.');
}
return details.paymentCredential.hash;
}
// Derive token name: prefix + blake2b_256(CBOR(data))[0..27]
export function deriveTokenName(prefix: string, cborHex: string): string {
const hashBytes = blake2b(Buffer.from(cborHex, 'hex'), { dkLen: 32 });
const prefixHex = Buffer.from(prefix, 'utf-8').toString('hex');
const hashSlice = Buffer.from(hashBytes).toString('hex').slice(0, 54); // 27 bytes = 54 hex chars
return prefixHex + hashSlice;
}
// AS-IS, audit-#10-verified: this flat Constr(0, [txHash, idx]) through
// Lucid's own Data.to (indefinite-length CBOR arrays) reproduces the token
// name actually minted by the deployed bytecode - see
// packages/builder/test/gov-build.test.ts's golden-pin test and
// packages/builder/test/fixtures/gov-state.fixture.json's goldenTokenPin,
// both captured live 2026-07-30. Do not "fix" the encoding.
export function deriveProposalTokenName(txHash: string, outputIndex: number): string {
const outRefCbor = Data.to(new Constr(0, [txHash, BigInt(outputIndex)]));
return deriveTokenName('prop_', outRefCbor);
}
export function deriveActivityTokenName(agentDid: string): string {
const didBytes = Buffer.from(agentDid, 'hex');
const hashBytes = blake2b(didBytes, { dkLen: 32 });
const prefixHex = Buffer.from('pact_', 'utf-8').toString('hex');
const hashSlice = Buffer.from(hashBytes).toString('hex').slice(0, 54);
return prefixHex + hashSlice;
}
// ─── Filebase IPFS upload ────────────────────────────────────────────────────
const FILEBASE_ACCESS_KEY = process.env.FILEBASE_ACCESS_KEY || '';
const FILEBASE_SECRET_KEY = process.env.FILEBASE_SECRET_KEY || '';
const FILEBASE_BUCKET = process.env.FILEBASE_BUCKET || '';
// A network side effect that lives in the hosted builder by design (moved
// verbatim from the custodial module): no wallet key material ever enters
// this function, it only ever sees the already-resolved document text.
export async function uploadToFilebase(document: string, namePrefix: string): Promise<{ cid: string; hash: string }> {
if (!FILEBASE_ACCESS_KEY || !FILEBASE_SECRET_KEY || !FILEBASE_BUCKET) {
throw new Error('Filebase not configured. Set FILEBASE_ACCESS_KEY, FILEBASE_SECRET_KEY, and FILEBASE_BUCKET env vars.');
}
// Canonical JSON: parse then re-stringify for deterministic bytes
const parsed = JSON.parse(document);
const canonical = JSON.stringify(parsed);
const docBytes = new TextEncoder().encode(canonical);
// blake2b_256 hash
const hashBytes = blake2b(docBytes, { dkLen: 32 });
const hashHex = Buffer.from(hashBytes).toString('hex');
const key = `${namePrefix}-${hashHex.slice(0, 16)}.json`;
const s3 = new S3Client({
region: 'us-east-1',
endpoint: 'https://s3.filebase.com',
credentials: { accessKeyId: FILEBASE_ACCESS_KEY, secretAccessKey: FILEBASE_SECRET_KEY },
forcePathStyle: true,
});
const resp = await s3.send(new PutObjectCommand({
Bucket: FILEBASE_BUCKET,
Key: key,
Body: canonical,
ContentType: 'application/json',
}));
// Filebase returns the CID in response metadata
const cid = resp.$metadata?.httpStatusCode === 200
? (resp as unknown as { VersionId?: string }).VersionId || ''
: '';
// If CID not in response, try HeadObject to get it from x-amz-meta-cid
let finalCid = cid;
if (!finalCid) {
const { HeadObjectCommand } = await import('@aws-sdk/client-s3');
const head = await s3.send(new HeadObjectCommand({ Bucket: FILEBASE_BUCKET, Key: key }));
finalCid = head.Metadata?.cid || '';
}
if (!finalCid) {
throw new Error('Filebase upload succeeded but CID not returned. Check bucket is IPFS-enabled.');
}
return { cid: finalCid, hash: hashHex };
}
// ─── Datum parsers ───────────────────────────────────────────────────────────
export interface GovProposal {
proposerDid: string;
proposalHash: string;
proposalType: string;
storageUri: string;
stakeAmount: number;
submittedAt: number;
reviewWindow: number;
priority: string;
amendmentCount: number;
incorporatedCritiques: number;
state: string;
}
export interface GovCritique {
criticDid: string;
proposalRef: Constr<Data>;
critiqueHash: string;
storageUri: string;
critiqueType: string;
stakeAmount: number;
submittedAt: number;
incorporated: boolean;
}
export interface GovEndorsement {
endorserDid: string;
proposalRef: Constr<Data>;
stakeAmount: number;
createdAt: number;
}
// Parse ProposalDatum from CBOR. Field indices kept exactly as the custodial
// module (0-11); instanceof Constr guards added around the indexed-Constr
// fields, matching registry-build.ts's parseAgentDatum pattern.
export function parseProposalDatum(datumCbor: string): GovProposal | null {
try {
const c = Data.from(datumCbor);
if (!(c instanceof Constr) || c.fields.length < 12) return null;
const proposerDid = c.fields[0];
const proposalHash = c.fields[2];
const typeField = c.fields[3];
const priorityField = c.fields[8];
const stateField = c.fields[11];
const incorporatedCritiques = c.fields[10];
if (typeof proposerDid !== 'string' || typeof proposalHash !== 'string') return null;
if (!(typeField instanceof Constr) || !(priorityField instanceof Constr) || !(stateField instanceof Constr)) return null;
return {
proposerDid,
proposalHash,
proposalType: TYPE_NAMES[Number(typeField.index)] || 'Unknown',
storageUri: toText(c.fields[4] as string),
stakeAmount: Number(c.fields[5]),
submittedAt: Number(c.fields[6]),
reviewWindow: Number(c.fields[7]),
priority: PRIORITY_NAMES[Number(priorityField.index)] || 'Standard',
amendmentCount: Number(c.fields[9]),
incorporatedCritiques: Array.isArray(incorporatedCritiques) ? incorporatedCritiques.length : 0,
state: STATE_NAMES[Number(stateField.index)] || 'Unknown',
};
} catch {
return null;
}
}
// Parse CritiqueDatum from CBOR. Field indices kept exactly as the custodial
// module (0-8).
export function parseCritiqueDatum(datumCbor: string): GovCritique | null {
try {
const c = Data.from(datumCbor);
if (!(c instanceof Constr) || c.fields.length < 9) return null;
const criticDid = c.fields[0];
const proposalRef = c.fields[2];
const critiqueHash = c.fields[3];
const critiqueTypeField = c.fields[5];
const incorporatedField = c.fields[8];
if (typeof criticDid !== 'string' || typeof critiqueHash !== 'string') return null;
if (!(proposalRef instanceof Constr) || !(critiqueTypeField instanceof Constr) || !(incorporatedField instanceof Constr)) return null;
return {
criticDid,
proposalRef,
critiqueHash,
storageUri: toText(c.fields[4] as string),
critiqueType: CRITIQUE_TYPE_NAMES[Number(critiqueTypeField.index)] || 'Unknown',
stakeAmount: Number(c.fields[6]),
submittedAt: Number(c.fields[7]),
incorporated: Number(incorporatedField.index) === 1,
};
} catch {
return null;
}
}
// Parse EndorsementDatum from CBOR. Field indices kept exactly as the
// custodial module (0-4).
export function parseEndorsementDatum(datumCbor: string): GovEndorsement | null {
try {
const c = Data.from(datumCbor);
if (!(c instanceof Constr) || c.fields.length < 5) return null;
const endorserDid = c.fields[0];
const proposalRef = c.fields[2];
if (typeof endorserDid !== 'string') return null;
if (!(proposalRef instanceof Constr)) return null;
return {
endorserDid,
proposalRef,
stakeAmount: Number(c.fields[3]),
createdAt: Number(c.fields[4]),
};
} catch {
return null;
}
}
// ─── Build: critique ─────────────────────────────────────────────────────────
export async function buildCritique(lucid: LucidEvolution, p: {
agentDid: string;
proposalTxHash: string;
proposalOutputIndex: number;
critiqueType: 'Supportive' | 'Opposing' | 'Amendment';
stakeApex: number;
critiqueDocument?: string;
critiqueHash?: string;
storageUri?: string;
}): Promise<VectorBuildStakeResult> {
const changeAddress = await lucid.wallet().address();
// Validation order: stake minimum -> document/hash resolution (Filebase
// upload if a document was given) -> datum build -> lock build.
if (!(p.stakeApex >= MIN_CRITIQUE_STAKE_APEX)) {
throw new Error(`Critique stake must be at least ${MIN_CRITIQUE_STAKE_APEX} AP3X.`);
}
const stakeLovelace = BigInt(Math.floor(p.stakeApex * 1_000_000));
let finalHash = p.critiqueHash;
let finalUri = p.storageUri;
let ipfsCid: string | undefined;
let documentHash: string | undefined;
if (p.critiqueDocument) {
const uploaded = await uploadToFilebase(p.critiqueDocument, 'critique');
finalHash = uploaded.hash;
finalUri = `ipfs://${uploaded.cid}`;
ipfsCid = uploaded.cid;
documentHash = uploaded.hash;
}
if (!finalHash || finalHash.length !== 64) {
throw new Error('critiqueHash must be 64 hex characters (32 bytes). Provide critiqueDocument for automatic hashing or critiqueHash manually.');
}
if (!finalUri) {
throw new Error('storageUri is required. Provide critiqueDocument for automatic upload or storageUri manually.');
}
const vkeyHash = paymentKeyHashOf(changeAddress);
let critiqueTypeDatum: Constr<Data>;
switch (p.critiqueType) {
case 'Supportive': critiqueTypeDatum = new Constr(0, []); break;
case 'Opposing': critiqueTypeDatum = new Constr(1, []); break;
case 'Amendment': critiqueTypeDatum = new Constr(2, [finalHash]); break;
default: {
const exhaustive: never = p.critiqueType;
throw new Error(`Unknown critiqueType: ${String(exhaustive)}`);
}
}
const provider = slotClockOf(lucid);
await ensureSlotConfig(provider);
const tip = (await provider.getNetworkTip?.()) ?? { slot: 0 };
const currentSlot = tip.slot || 0;
const critiqueDatum = Data.to(new Constr(0, [
p.agentDid, // critic_did
new Constr(0, [vkeyHash]), // critic_credential
new Constr(0, [p.proposalTxHash, BigInt(p.proposalOutputIndex)]), // proposal_ref
finalHash, // critique_hash
fromText(finalUri), // storage_uri
critiqueTypeDatum, // critique_type
stakeLovelace, // stake_amount
BigInt(currentSlot), // submitted_at
new Constr(0, []), // incorporated = False
]));
const critiqueSpendAddress = scriptHashToAddress(GOV_CRITIQUE_SPEND_HASH);
const completed = await lucid.newTx()
.pay.ToAddressWithData(
critiqueSpendAddress,
{ kind: 'inline', value: critiqueDatum },
{ lovelace: stakeLovelace + 2_000_000n },
)
.complete({ localUPLCEval: false });
return {
...toBuildResult(completed),
op: 'critique',
proposalRef: `${p.proposalTxHash}#${p.proposalOutputIndex}`,
stakeLovelace: stakeLovelace.toString(),
scriptAddress: critiqueSpendAddress,
storageUri: finalUri,
...(ipfsCid ? { ipfsCid, documentHash } : {}),
};
}
// ─── Build: endorse ──────────────────────────────────────────────────────────
export async function buildEndorse(lucid: LucidEvolution, p: {
agentDid: string;
proposalTxHash: string;
proposalOutputIndex: number;
stakeApex: number;
}): Promise<VectorBuildStakeResult> {
const changeAddress = await lucid.wallet().address();
if (!(p.stakeApex >= MIN_ENDORSE_STAKE_APEX)) {
throw new Error(`Endorsement stake must be at least ${MIN_ENDORSE_STAKE_APEX} AP3X.`);
}
const stakeLovelace = BigInt(Math.floor(p.stakeApex * 1_000_000));
const vkeyHash = paymentKeyHashOf(changeAddress);
const provider = slotClockOf(lucid);
await ensureSlotConfig(provider);
const tip = (await provider.getNetworkTip?.()) ?? { slot: 0 };
const currentSlot = tip.slot || 0;
const endorsementDatum = Data.to(new Constr(0, [
p.agentDid, // endorser_did
new Constr(0, [vkeyHash]), // endorser_credential
new Constr(0, [p.proposalTxHash, BigInt(p.proposalOutputIndex)]), // proposal_ref
stakeLovelace, // stake_amount
BigInt(currentSlot), // created_at
]));
const endorsementSpendAddress = scriptHashToAddress(GOV_ENDORSEMENT_SPEND_HASH);
const completed = await lucid.newTx()
.pay.ToAddressWithData(
endorsementSpendAddress,
{ kind: 'inline', value: endorsementDatum },
{ lovelace: stakeLovelace + 2_000_000n },
)
.complete({ localUPLCEval: false });
return {
...toBuildResult(completed),
op: 'endorse',
proposalRef: `${p.proposalTxHash}#${p.proposalOutputIndex}`,
stakeLovelace: stakeLovelace.toString(),
scriptAddress: endorsementSpendAddress,
};
}
// ─── Build: proposal lock (step 1 of 2) ─────────────────────────────────────
export async function buildProposalLock(lucid: LucidEvolution, p: {
agentDid: string;
proposalType: 'ParameterChange' | 'TreasurySpend' | 'ProtocolUpgrade' | 'GameActivation' | 'GeneralSuggestion';
stakeApex: number;
typeParams?: {
paramName?: string; currentValue?: number; proposedValue?: number;
amount?: number; recipientDescription?: string; upgradeHash?: string; gameId?: number;
};
priority?: 'Standard' | 'Emergency';
proposalDocument?: string;
proposalHash?: string;
storageUri?: string;
}): Promise<VectorBuildProposalLockResult> {
const changeAddress = await lucid.wallet().address();
// Validation order: stake minimum -> type-specific params (cheap,
// synchronous) -> document/hash/URI resolution (the one network side
// effect, a Filebase upload, is deliberately validated last).
if (!(p.stakeApex >= MIN_PROPOSAL_STAKE_APEX)) {
throw new Error(`Proposal stake must be at least ${MIN_PROPOSAL_STAKE_APEX} AP3X.`);
}
const stakeLovelace = BigInt(Math.floor(p.stakeApex * 1_000_000));
// Proposal-type datum. Same Constr indices as the custodial switch - the
// deployed module validator parses these exact bytes.
let typeDatum: Constr<Data>;
switch (p.proposalType) {
case 'ParameterChange':
if (!p.typeParams?.paramName || p.typeParams?.currentValue == null || p.typeParams?.proposedValue == null) {
throw new Error('ParameterChange requires paramName, currentValue, proposedValue');
}
typeDatum = new Constr(0, [fromText(p.typeParams.paramName), BigInt(p.typeParams.currentValue), BigInt(p.typeParams.proposedValue)]);
break;
case 'TreasurySpend':
if (!p.typeParams?.amount || !p.typeParams?.recipientDescription) {
throw new Error('TreasurySpend requires amount, recipientDescription');
}
typeDatum = new Constr(1, [BigInt(p.typeParams.amount), fromText(p.typeParams.recipientDescription)]);
break;
case 'ProtocolUpgrade':
typeDatum = new Constr(2, [p.typeParams?.upgradeHash || '']);
break;
case 'GameActivation':
typeDatum = new Constr(3, [BigInt(p.typeParams?.gameId || 0)]);
break;
case 'GeneralSuggestion':
typeDatum = new Constr(4, []);
break;
default: {
const exhaustive: never = p.proposalType;
throw new Error(`Unknown proposalType: ${String(exhaustive)}`);
}
}
// Document/hash/URI resolution: Filebase upload if a document was given,
// else the caller-supplied hash + URI must already be valid.
let finalHash = p.proposalHash;
let finalUri = p.storageUri;
let ipfsCid: string | undefined;
if (p.proposalDocument) {
const uploaded = await uploadToFilebase(p.proposalDocument, 'proposal');
finalHash = uploaded.hash;
finalUri = `ipfs://${uploaded.cid}`;
ipfsCid = uploaded.cid;
}
if (!finalHash || finalHash.length !== 64) {
throw new Error('proposalHash must be 64 hex characters (32 bytes). Provide proposalDocument for automatic hashing or proposalHash manually.');
}
if (!finalUri) {
throw new Error('storageUri is required. Provide proposalDocument for automatic upload or storageUri manually.');
}
const vkeyHash = paymentKeyHashOf(changeAddress);
const provider = slotClockOf(lucid);
const zeroTime = await ensureSlotConfig(provider);
const tip = (await provider.getNetworkTip?.()) ?? { slot: 0 };
const submittedAtMs = zeroTime + (tip.slot || 0) * 1000; // POSIX ms - the validity-range unit, not slots
const priorityDatum = p.priority === 'Emergency' ? new Constr(1, []) : new Constr(0, []);
const proposalDatum = Data.to(new Constr(0, [
p.agentDid, // proposer_did
new Constr(0, [vkeyHash]), // proposer_credential
finalHash, // proposal_hash
typeDatum, // proposal_type
fromText(finalUri), // storage_uri
stakeLovelace, // stake_amount
BigInt(submittedAtMs), // submitted_at
604_800_000n, // review_window (~7 days in ms)
priorityDatum, // priority
0n, // amendment_count
[], // incorporated_critiques
new Constr(0, []), // state = Open
]));
const proposalSpendAddress = scriptHashToAddress(GOV_PROPOSAL_SPEND_HASH);
const completed = await lucid.newTx()
.pay.ToAddressWithData(
proposalSpendAddress,
{ kind: 'inline', value: proposalDatum },
{ lovelace: stakeLovelace + 2_000_000n },
)
.complete({ localUPLCEval: false });
return {
...toBuildResult(completed),
scriptAddress: proposalSpendAddress,
stakeLovelace: stakeLovelace.toString(),
storageUri: finalUri,
proposalHash: finalHash,
...(ipfsCid ? { ipfsCid } : {}),
};
}
// ─── Build: proposal spend (step 2 of 2 - validated spend + mint) ──────────
export async function buildProposalSpend(
lucid: LucidEvolution,
// Assumed to be the same provider instance `lucid` was constructed with
// (lucidForAddress(provider, changeAddress)) - passed explicitly (unlike
// buildCritique/buildEndorse's slotClockOf recovery) because this build
// resolves UTxOs by outref/unit directly against it, not through lucid's
// own UTxO cache. SlotClockProvider (not OgmiosProvider's own stricter
// getNetworkTip) supplies the slot/tip surface, same as slotClockOf's
// callers elsewhere in this file - keeps the (optional) hash field out of
// this build's contract, since it never uses it.
provider: Pick<OgmiosProvider, 'getUtxos' | 'getUtxoByUnit' | 'getUtxosByOutRef'> & SlotClockProvider,
p: { agentDid: string; lockTxHash: string; lockOutputIndex?: number },
// Test-only escape hatch, not part of the documented call contract: every
// production caller (Task 5's tool handler included) omits this and gets
// parity with the custodial module (provider-side eval). Only
// gov-build.test.ts's dedicated native-eval-attempt test passes
// { localUPLCEval: true }, to ask the REAL deployed validators a question
// FixtureProvider's missing evaluateTx would otherwise make impossible to
// ask at all.
opts?: { localUPLCEval?: boolean },
): Promise<VectorBuildProposalSpendResult> {
const changeAddress = await lucid.wallet().address();
const lockOutputIndex = p.lockOutputIndex ?? 0;
const proposalSpendAddress = scriptHashToAddress(GOV_PROPOSAL_SPEND_HASH);
// 1. Resolve the locked UTxO. Its datum is reused VERBATIM below as the
// continuing datum - the chain's own bytes are the truth, not a
// reconstruction.
const lockedUtxos = await provider.getUtxosByOutRef([{ txHash: p.lockTxHash, outputIndex: lockOutputIndex }]);
const lockedUtxo = lockedUtxos[0];
if (!lockedUtxo) {
throw new Error('Locked proposal UTxO not found - has the lock transaction confirmed? Run vector_await_transaction { txHash } first.');
}
// Fail fast on a caller passing an unrelated txHash that happens to
// resolve to a real UTxO elsewhere (same defensive pattern as
// build.ts's buildInteractContract spend-action address check) - a
// datum-parse failure two lines below would eventually catch most cases
// of this, but this gives a specific, actionable error instead of a
// generic "malformed data" one.
if (lockedUtxo.address !== proposalSpendAddress) {
throw new Error(
`Locked UTxO ${p.lockTxHash}#${lockOutputIndex} is not at the proposal script address ` +
`(found ${lockedUtxo.address}) - pass the lock transaction hash returned by ` +
`vector_build_self_improvement_proposal_lock.`,
);
}
if (!lockedUtxo.datum) {
throw new Error('Locked proposal UTxO has no inline datum - the lock transaction may be malformed.');
}
// 2. Sanity-parse + cross-check the proposer DID before spending anything.
const proposal = parseProposalDatum(lockedUtxo.datum);
if (!proposal) {
throw new Error('Could not parse the locked proposal datum. The on-chain data may be malformed.');
}
if (proposal.proposerDid !== p.agentDid) {
throw new Error(`Proposer DID mismatch: the locked proposal belongs to ${proposal.proposerDid}, not ${p.agentDid}.`);
}
// 3. Reference-script UTxOs (CIP-33) - availability errors transcribed
// verbatim from the custodial module (these can be accidentally consumed
// by an unrelated transaction).
const refScriptUtxos = await provider.getUtxosByOutRef([
parseUtxoRef(GOV_PROPOSAL_SPEND_REF),
parseUtxoRef(GOV_PROPOSAL_MINT_REF),
]);
if (refScriptUtxos.length < 2) {
throw new Error(
`Reference script UTxOs missing: found ${refScriptUtxos.length}/2. ` +
`spend_ref=${GOV_PROPOSAL_SPEND_REF}, mint_ref=${GOV_PROPOSAL_MINT_REF}. ` +
`These UTxOs may have been consumed - redeploy with scripts/redeploy_ref_scripts.py ` +
`and update GOV_PROPOSAL_SPEND_REF / GOV_PROPOSAL_MINT_REF env vars.`,
);
}
const missingScriptRef = refScriptUtxos.filter((u) => !u.scriptRef);
if (missingScriptRef.length > 0) {
throw new Error(
`Reference script UTxOs found but missing scriptRef field for ${missingScriptRef.length} UTxO(s). ` +
`The UTxOs at ${missingScriptRef.map((u) => u.txHash).join(', ')} exist but don't contain scripts. ` +
`Redeploy reference scripts and update env vars.`,
);
}
// Module infrastructure reference inputs - resolved the same way the
// custodial module did (not individually validated here; the deployed
// validator itself rejects the spend if one is missing from the tx).
const govRefUtxos = await provider.getUtxosByOutRef([
parseUtxoRef(GOV_PARAMS_UTXO),
parseUtxoRef(GOV_ORACLE_UTXO),
parseUtxoRef(GOV_CROSSREFS_UTXO),
]);
// 4. The agent's registry NFT (CIP-31 reference input, proves the DID).
// Same try-getUtxoByUnit-catch-scan-getUtxos(registryAddress) fallback as
// registry-build.ts's resolveAgentUtxo: getUtxoByUnit needs a live indexer
// (FixtureProvider always throws), so offline building depends on the
// scan fallback actually running.
const nftUnit = AGENT_REGISTRY_POLICY + p.agentDid;
let nftUtxo: UTxO | undefined;
try {
nftUtxo = await provider.getUtxoByUnit(nftUnit);
} catch {
// Koios unavailable, asset not found, or an offline provider - fall through to the address scan.
}
if (!nftUtxo) {
const registryUtxos = await provider.getUtxos(getRegistryAddress());
nftUtxo = registryUtxos.find((u) => u.assets[nftUnit] && u.assets[nftUnit] > 0n);
}
if (!nftUtxo) {
throw new Error(
`Agent registry NFT not found on-chain. Expected token: ${AGENT_REGISTRY_POLICY.slice(0, 12)}...${p.agentDid.slice(0, 12)}... ` +
`The agent may need to re-register with vector_build_register_agent.`,
);
}
// 5. Token names, validity window, activity datum.
const propTokenName = deriveProposalTokenName(p.lockTxHash, lockOutputIndex);
const actTokenName = deriveActivityTokenName(p.agentDid);
const propTokenUnit = GOV_PROPOSAL_MINT_HASH + propTokenName;
const actTokenUnit = GOV_PROPOSAL_MINT_HASH + actTokenName;
const zeroTime = await ensureSlotConfig(provider);
const tip = (await provider.getNetworkTip?.()) ?? { slot: 0 };
// The validator checks activity.last_proposal_slot == tx validity lower bound.
const validFromMs = zeroTime + (tip.slot || 0) * 1000;
const validToMs = validFromMs + 360_000; // 6 minutes - the agent's signing deadline
const vkeyHash = paymentKeyHashOf(changeAddress);
// First proposal from this agent: activity count starts at 1.
// TODO: for a subsequent proposal, find the existing pact_ UTxO and
// increment count instead of always minting a fresh one - the custodial
// module carried the same simplification; not fixed here.
const activityDatum = Data.to(new Constr(0, [
p.agentDid, // agent_did
new Constr(0, [vkeyHash]), // agent_credential
1n, // active_proposal_count
BigInt(validFromMs), // last_proposal_slot (POSIX ms, matches tx validity lower bound)
]));
const submitRedeemer = Data.to(new Constr(0, []));
const completed = await lucid.newTx()
.collectFrom([lockedUtxo], submitRedeemer)
.readFrom(refScriptUtxos)
.readFrom(govRefUtxos)
.readFrom([nftUtxo])
.mintAssets({ [propTokenUnit]: 1n, [actTokenUnit]: 1n }, submitRedeemer)
.pay.ToAddressWithData(
proposalSpendAddress,
{ kind: 'inline', value: lockedUtxo.datum },
// Value-preservation (registry-family lesson): carry the locked
// UTxO's OWN coin through rather than recomputing stake+2 AP3X - that
// constant only happens to equal it today.
{ ...lockedUtxo.assets, [propTokenUnit]: 1n },
)
.pay.ToAddressWithData(
proposalSpendAddress,
{ kind: 'inline', value: activityDatum },
{ lovelace: 2_000_000n, [actTokenUnit]: 1n },
)
.addSigner(changeAddress)
.validFrom(validFromMs)
.validTo(validToMs)
.complete({ localUPLCEval: opts?.localUPLCEval ?? false });
return {
...toBuildResult(completed),
proposalTokenName: propTokenName,
activityTokenName: actTokenName,
scriptAddress: proposalSpendAddress,
validToMs,
};
}

View File

@@ -157,12 +157,13 @@ export function verifyOwnership(profile: RegistryAgentProfile, walletVkeyHash: s
} }
export async function buildRegisterAgent(lucid: LucidEvolution, p: { export async function buildRegisterAgent(lucid: LucidEvolution, p: {
changeAddress: string; name: string; description: string; name: string; description: string;
capabilities: string[]; framework: string; endpoint: string; capabilities: string[]; framework: string; endpoint: string;
}): Promise<VectorBuildRegisterResult> { }): Promise<VectorBuildRegisterResult> {
const changeAddress = await lucid.wallet().address();
validateEndpoint(p.endpoint); validateEndpoint(p.endpoint);
validateCapabilities(p.capabilities); validateCapabilities(p.capabilities);
const vkeyHash = paymentKeyHashOf(p.changeAddress); const vkeyHash = paymentKeyHashOf(changeAddress);
const utxos = await lucid.wallet().getUtxos(); const utxos = await lucid.wallet().getUtxos();
// Same selection heuristic as the old custodial tool (behavior parity): // Same selection heuristic as the old custodial tool (behavior parity):
// prefer a pure-lovelace UTxO covering deposit + fee headroom, else the first. // prefer a pure-lovelace UTxO covering deposit + fee headroom, else the first.
@@ -180,7 +181,7 @@ export async function buildRegisterAgent(lucid: LucidEvolution, p: {
.mintAssets({ [nftUnit]: 1n }, registerRedeemer) .mintAssets({ [nftUnit]: 1n }, registerRedeemer)
.attach.MintingPolicy(registryScript()) .attach.MintingPolicy(registryScript())
.pay.ToAddressWithData(getRegistryAddress(), { kind: 'inline', value: datum }, { lovelace: MIN_AP3X_DEPOSIT, [nftUnit]: 1n }) .pay.ToAddressWithData(getRegistryAddress(), { kind: 'inline', value: datum }, { lovelace: MIN_AP3X_DEPOSIT, [nftUnit]: 1n })
.addSigner(p.changeAddress) .addSigner(changeAddress)
.complete(); .complete();
return { return {
...toBuildResult(completed), ...toBuildResult(completed),
@@ -195,17 +196,18 @@ export async function buildUpdateAgent(
lucid: LucidEvolution, lucid: LucidEvolution,
provider: Pick<OgmiosProvider, 'getUtxos' | 'getUtxoByUnit'>, provider: Pick<OgmiosProvider, 'getUtxos' | 'getUtxoByUnit'>,
p: { p: {
changeAddress: string; agentId: string; name?: string; description?: string; agentId: string; name?: string; description?: string;
capabilities?: string[]; framework?: string; endpoint?: string; capabilities?: string[]; framework?: string; endpoint?: string;
}, },
): Promise<VectorBuildAgentOpResult> { ): Promise<VectorBuildAgentOpResult> {
const changeAddress = await lucid.wallet().address();
if (p.name === undefined && p.description === undefined && p.capabilities === undefined if (p.name === undefined && p.description === undefined && p.capabilities === undefined
&& p.framework === undefined && p.endpoint === undefined) { && p.framework === undefined && p.endpoint === undefined) {
throw new Error('At least one field must be provided to update (name, description, capabilities, framework, or endpoint).'); throw new Error('At least one field must be provided to update (name, description, capabilities, framework, or endpoint).');
} }
if (p.endpoint !== undefined) validateEndpoint(p.endpoint); if (p.endpoint !== undefined) validateEndpoint(p.endpoint);
if (p.capabilities !== undefined) validateCapabilities(p.capabilities); if (p.capabilities !== undefined) validateCapabilities(p.capabilities);
const vkeyHash = paymentKeyHashOf(p.changeAddress); const vkeyHash = paymentKeyHashOf(changeAddress);
const { profile, utxo } = await resolveAgentUtxo(provider, p.agentId); const { profile, utxo } = await resolveAgentUtxo(provider, p.agentId);
verifyOwnership(profile, vkeyHash); verifyOwnership(profile, vkeyHash);
// Owner is written from profile.ownerVkeyHash, not the caller's vkeyHash: // Owner is written from profile.ownerVkeyHash, not the caller's vkeyHash:
@@ -232,7 +234,7 @@ export async function buildUpdateAgent(
.collectFrom([utxo], spendRedeemer) .collectFrom([utxo], spendRedeemer)
.attach.SpendingValidator(registryScript()) .attach.SpendingValidator(registryScript())
.pay.ToAddressWithData(getRegistryAddress(), { kind: 'inline', value: newDatum }, utxo.assets) .pay.ToAddressWithData(getRegistryAddress(), { kind: 'inline', value: newDatum }, utxo.assets)
.addSigner(p.changeAddress) .addSigner(changeAddress)
.complete(); .complete();
const updatedFields: string[] = []; const updatedFields: string[] = [];
if (p.name !== undefined) updatedFields.push('name'); if (p.name !== undefined) updatedFields.push('name');
@@ -249,9 +251,10 @@ export async function buildUpdateAgent(
export async function buildTransferAgent( export async function buildTransferAgent(
lucid: LucidEvolution, lucid: LucidEvolution,
provider: Pick<OgmiosProvider, 'getUtxos' | 'getUtxoByUnit'>, provider: Pick<OgmiosProvider, 'getUtxos' | 'getUtxoByUnit'>,
p: { changeAddress: string; agentId: string; newOwnerAddress: string }, p: { agentId: string; newOwnerAddress: string },
): Promise<VectorBuildAgentOpResult> { ): Promise<VectorBuildAgentOpResult> {
const vkeyHash = paymentKeyHashOf(p.changeAddress); const changeAddress = await lucid.wallet().address();
const vkeyHash = paymentKeyHashOf(changeAddress);
let newOwnerHash: string; let newOwnerHash: string;
try { try {
newOwnerHash = paymentKeyHashOf(p.newOwnerAddress); newOwnerHash = paymentKeyHashOf(p.newOwnerAddress);
@@ -272,7 +275,7 @@ export async function buildTransferAgent(
.collectFrom([utxo], spendRedeemer) .collectFrom([utxo], spendRedeemer)
.attach.SpendingValidator(registryScript()) .attach.SpendingValidator(registryScript())
.pay.ToAddressWithData(getRegistryAddress(), { kind: 'inline', value: newDatum }, utxo.assets) .pay.ToAddressWithData(getRegistryAddress(), { kind: 'inline', value: newDatum }, utxo.assets)
.addSigner(p.changeAddress) .addSigner(changeAddress)
.complete(); .complete();
return { return {
...toBuildResult(completed), ...toBuildResult(completed),
@@ -283,9 +286,10 @@ export async function buildTransferAgent(
export async function buildDeregisterAgent( export async function buildDeregisterAgent(
lucid: LucidEvolution, lucid: LucidEvolution,
provider: Pick<OgmiosProvider, 'getUtxos' | 'getUtxoByUnit'>, provider: Pick<OgmiosProvider, 'getUtxos' | 'getUtxoByUnit'>,
p: { changeAddress: string; agentId: string }, p: { agentId: string },
): Promise<VectorBuildAgentOpResult> { ): Promise<VectorBuildAgentOpResult> {
const vkeyHash = paymentKeyHashOf(p.changeAddress); const changeAddress = await lucid.wallet().address();
const vkeyHash = paymentKeyHashOf(changeAddress);
const { profile, utxo, nftUnit } = await resolveAgentUtxo(provider, p.agentId); const { profile, utxo, nftUnit } = await resolveAgentUtxo(provider, p.agentId);
verifyOwnership(profile, vkeyHash); verifyOwnership(profile, vkeyHash);
const spendRedeemer = Data.to(new Constr(1, [])); // Deregister const spendRedeemer = Data.to(new Constr(1, [])); // Deregister
@@ -296,7 +300,7 @@ export async function buildDeregisterAgent(
// REGISTRY_POLICY_ID above) does NOT match the in-workspace Aiken source // REGISTRY_POLICY_ID above) does NOT match the in-workspace Aiken source
// under agent-infrastructure/contracts/agent-registry/ - that source // under agent-infrastructure/contracts/agent-registry/ - that source
// compiles to policy 5dd51189..., a different script entirely (confirmed // compiles to policy 5dd51189..., a different script entirely (confirmed
// by hashing both; provenance worth resolving before the governance // by hashing both; provenance worth resolving before the self-improvement
// family trusts that source as ground truth). Differential-tested // family trusts that source as ground truth). Differential-tested
// directly against the deployed bytecode instead: deregister enforces a // directly against the deployed bytecode instead: deregister enforces a
// floor on the owner's returned change - a sole-input deregister of a // floor on the owner's returned change - a sole-input deregister of a
@@ -321,7 +325,7 @@ export async function buildDeregisterAgent(
.collectFrom([feeUtxo]) .collectFrom([feeUtxo])
.mintAssets({ [nftUnit]: -1n }, mintRedeemer) .mintAssets({ [nftUnit]: -1n }, mintRedeemer)
.attach.MintingPolicy(registryScript()) .attach.MintingPolicy(registryScript())
.addSigner(p.changeAddress) .addSigner(changeAddress)
.complete(); .complete();
return { return {
...toBuildResult(completed), ...toBuildResult(completed),
@@ -332,9 +336,10 @@ export async function buildDeregisterAgent(
export async function buildMessageAgent( export async function buildMessageAgent(
lucid: LucidEvolution, lucid: LucidEvolution,
provider: Pick<OgmiosProvider, 'getUtxos' | 'getUtxoByUnit'>, provider: Pick<OgmiosProvider, 'getUtxos' | 'getUtxoByUnit'>,
p: { changeAddress: string; agentId: string; messageType: string; payload: string }, p: { agentId: string; messageType: string; payload: string },
): Promise<VectorBuildMessageResult> { ): Promise<VectorBuildMessageResult> {
paymentKeyHashOf(p.changeAddress); // validate before any resolution work const changeAddress = await lucid.wallet().address();
paymentKeyHashOf(changeAddress); // validate before any resolution work
const { profile } = await resolveAgentUtxo(provider, p.agentId); const { profile } = await resolveAgentUtxo(provider, p.agentId);
if (!profile.ownerVkeyHash) throw new Error('Could not parse agent owner from registry datum'); if (!profile.ownerVkeyHash) throw new Error('Could not parse agent owner from registry datum');
const recipientAddress = credentialToAddress('Mainnet', { type: 'Key', hash: profile.ownerVkeyHash }); const recipientAddress = credentialToAddress('Mainnet', { type: 'Key', hash: profile.ownerVkeyHash });
@@ -342,7 +347,7 @@ export async function buildMessageAgent(
.pay.ToAddress(recipientAddress, { lovelace: 2_000_000n }) .pay.ToAddress(recipientAddress, { lovelace: 2_000_000n })
.attachMetadata(AGENT_MESSAGE_LABEL, { .attachMetadata(AGENT_MESSAGE_LABEL, {
msg: ['a2a'], msg: ['a2a'],
from: metadataStr(p.changeAddress), from: metadataStr(changeAddress),
to: metadataStr(p.agentId), to: metadataStr(p.agentId),
type: p.messageType, type: p.messageType,
payload: metadataStr(p.payload), payload: metadataStr(p.payload),

View File

@@ -1,144 +0,0 @@
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import type { SpendLimits, SpendStatus, AuditEntry } from '@apexfusion/vector-mcp-shared/types';
const VECTOR_SPEND_LIMIT_PER_TX = parseInt(process.env.VECTOR_SPEND_LIMIT_PER_TX || '100000000'); // 100 AP3X
const VECTOR_SPEND_LIMIT_DAILY = parseInt(process.env.VECTOR_SPEND_LIMIT_DAILY || '500000000'); // 500 AP3X
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// From the bundled entrypoint's directory (packages/builder/build), three levels
// up lands on the repo root, which is where .env.example and the README document
// the default audit log path (./vector-audit-log.json) as living.
const projectRoot = resolve(__dirname, '../../..');
export class SafetyLayer {
private dailySpent: number = 0;
private dailyResetTime: number;
private auditLog: AuditEntry[] = [];
private limits: SpendLimits;
private auditLogPath: string;
constructor() {
this.limits = {
perTransaction: VECTOR_SPEND_LIMIT_PER_TX,
daily: VECTOR_SPEND_LIMIT_DAILY,
};
// Reset daily limit at midnight UTC
this.dailyResetTime = this.getNextMidnightUTC();
// Audit log file persistence
this.auditLogPath = process.env.VECTOR_AUDIT_LOG_PATH
? resolve(process.env.VECTOR_AUDIT_LOG_PATH)
: resolve(projectRoot, 'vector-audit-log.json');
this.loadAuditLog();
}
private getNextMidnightUTC(): number {
const now = new Date();
const tomorrow = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1));
return tomorrow.getTime();
}
private checkAndResetDaily(): void {
if (Date.now() >= this.dailyResetTime) {
this.dailySpent = 0;
this.dailyResetTime = this.getNextMidnightUTC();
}
}
private loadAuditLog(): void {
try {
if (existsSync(this.auditLogPath)) {
const data = readFileSync(this.auditLogPath, 'utf-8');
const parsed = JSON.parse(data);
if (Array.isArray(parsed)) {
this.auditLog = parsed;
// Recalculate dailySpent from today's entries (survives server restart)
this.recalculateDailySpent();
}
}
} catch (err) {
console.error(`Warning: Could not load audit log from ${this.auditLogPath}:`, err);
this.auditLog = [];
}
}
private recalculateDailySpent(): void {
const todayStart = new Date();
todayStart.setUTCHours(0, 0, 0, 0);
const todayStartMs = todayStart.getTime();
this.dailySpent = 0;
for (const entry of this.auditLog) {
const entryTime = new Date(entry.timestamp).getTime();
if (entryTime >= todayStartMs) {
this.dailySpent += entry.amountLovelace;
}
}
}
private persistAuditLog(): void {
try {
writeFileSync(this.auditLogPath, JSON.stringify(this.auditLog, null, 2));
} catch (err) {
console.error(`Warning: Could not persist audit log to ${this.auditLogPath}:`, err);
}
}
checkTransaction(amountLovelace: number): { allowed: boolean; reason?: string } {
this.checkAndResetDaily();
if (amountLovelace > this.limits.perTransaction) {
return {
allowed: false,
reason: `Transaction amount ${(amountLovelace / 1_000_000).toFixed(6)} AP3X exceeds per-transaction limit of ${(this.limits.perTransaction / 1_000_000).toFixed(6)} AP3X`,
};
}
if (this.dailySpent + amountLovelace > this.limits.daily) {
const remaining = this.limits.daily - this.dailySpent;
return {
allowed: false,
reason: `Transaction would exceed daily spend limit. Daily limit: ${(this.limits.daily / 1_000_000).toFixed(6)} AP3X, already spent: ${(this.dailySpent / 1_000_000).toFixed(6)} AP3X, remaining: ${(remaining / 1_000_000).toFixed(6)} AP3X`,
};
}
return { allowed: true };
}
recordTransaction(txHash: string, amountLovelace: number, recipient: string): void {
this.checkAndResetDaily();
this.dailySpent += amountLovelace;
this.auditLog.push({
timestamp: new Date().toISOString(),
txHash,
amountLovelace,
recipient,
action: 'send',
});
this.persistAuditLog();
}
getSpendStatus(): SpendStatus {
this.checkAndResetDaily();
return {
perTransactionLimit: this.limits.perTransaction,
dailyLimit: this.limits.daily,
dailySpent: this.dailySpent,
dailyRemaining: Math.max(0, this.limits.daily - this.dailySpent),
resetTime: new Date(this.dailyResetTime).toISOString(),
};
}
getAuditLog(): AuditEntry[] {
return [...this.auditLog];
}
}
// Singleton instance
export const safetyLayer = new SafetyLayer();

File diff suppressed because it is too large Load Diff

View File

@@ -13,9 +13,11 @@ import { resolve, join, relative } from 'node:path';
*/ */
const SRC_ROOT = resolve(import.meta.dirname!, '../src'); const SRC_ROOT = resolve(import.meta.dirname!, '../src');
const SCRIPTS_ROOT = resolve(import.meta.dirname!, '../scripts'); const SCRIPTS_ROOT = resolve(import.meta.dirname!, '../scripts');
const CUSTODIAL_UNTIL_LATER_PR = new Set([ // Empty since spec PR 8: family 3 (self-improvement) went keyless, taking the
'vector/self-improvement.ts', // family 3 (governance) — goes keyless in spec PR 8 // last custodial file (vector/self-improvement.ts) off the allowlist. Every
]); // builder family is now keyless - this set may never grow again (see the
// size-0 pin below).
const CUSTODIAL_UNTIL_LATER_PR = new Set<string>([]);
// No \b boundaries: this is deliberately a substring match, not a whole-word // No \b boundaries: this is deliberately a substring match, not a whole-word
// one. Word-bounding let `seedPhrase`, `WALLET_SEED` or `bip39x` slip past // one. Word-bounding let `seedPhrase`, `WALLET_SEED` or `bip39x` slip past
// undetected — those are real key-material vocabulary wearing a different // undetected — those are real key-material vocabulary wearing a different
@@ -70,11 +72,13 @@ describe('builder custody boundary', () => {
}); });
// The allowlist may only shrink (PR 7 dropped agent-network.ts to reach 1, // 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 8 drops self-improvement.ts to reach 0 - see the comment above
// PR N" comment above). Pinning the size means any widening is a deliberate, // CUSTODIAL_UNTIL_LATER_PR). Pinning the size means any widening is a
// reviewable number change instead of a silent `.add(...)` slipping through. // deliberate, reviewable number change instead of a silent `.add(...)`
// slipping through - and since the migration is complete, that number can
// now only ever be 0.
test('the allowlist may only shrink', () => { test('the allowlist may only shrink', () => {
assert.equal(CUSTODIAL_UNTIL_LATER_PR.size, 1, 'the custodial allowlist may only shrink - spec PR 8 takes it to 0'); assert.equal(CUSTODIAL_UNTIL_LATER_PR.size, 0, 'the custodial allowlist is EMPTY - the migration is complete; it may never grow again');
}); });
// Pins every FORBIDDEN alternative individually against a fixture corpus, so // Pins every FORBIDDEN alternative individually against a fixture corpus, so
@@ -97,3 +101,32 @@ describe('builder custody boundary', () => {
} }
}); });
}); });
// A second, narrower mechanical guard, same style as the custody boundary
// above but for a different invariant: gov-build.ts's buildProposalSpend
// exposes a 4th, TEST-ONLY `opts?: { localUPLCEval?: boolean }` parameter
// (documented in-source as an escape hatch used only by gov-build.test.ts's
// native-eval-attempt test - see gov-build.ts). Every production caller,
// including self-improvement.ts's vector_build_self_improvement_proposal_spend
// handler, must omit it and get gov-build.ts's own default
// (`opts?.localUPLCEval ?? false` - provider-side eval, parity with the
// deployed module). Nothing in the hermetic or smoke suites would notice a
// handler mutation that passed `{ localUPLCEval: true }` as a 4th argument:
// gov-build.test.ts tests buildProposalSpend directly (not through the tool
// handler), and the smoke suite only inspects tool schemas/descriptions, not
// handler bodies. This test closes that gap the same mechanical way the
// custody boundary above closes the mnemonic gap: by asserting on the
// compiled source text itself.
describe('self-improvement.ts never touches the test-only eval escape hatch', () => {
test('the test-only eval escape hatch may never be passed from a tool handler', () => {
const content = readFileSync(join(SRC_ROOT, 'vector/self-improvement.ts'), 'utf-8');
assert.doesNotMatch(
content,
/localUPLCEval/,
'self-improvement.ts must never reference localUPLCEval - the test-only eval escape hatch may never be passed from a tool handler; ' +
'production callers always get gov-build.ts\'s own default (provider-side eval, parity with the deployed module). ' +
'If buildProposalSpend is now being called with a 4th argument from a tool handler, remove it - that argument exists only for ' +
'gov-build.test.ts\'s native-eval-attempt test.',
);
});
});

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,955 @@
import { describe, test, before } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { CML, Data, Constr, toText, getAddressDetails } from '@lucid-evolution/lucid';
import type { LucidEvolution, UTxO } from '@lucid-evolution/lucid';
import { FixtureProvider, FIXTURE_UTXOS, OWN_ADDRESS } from './fixtures/fixture-provider.ts';
import { lucidForAddress } from '../src/vector/build.ts';
import { getRegistryAddress, buildAgentDatum, MIN_AP3X_DEPOSIT } from '../src/vector/registry-build.ts';
import {
GOV_PROPOSAL_SPEND_HASH, GOV_CRITIQUE_SPEND_HASH, GOV_ENDORSEMENT_SPEND_HASH,
GOV_PROPOSAL_MINT_HASH, AGENT_REGISTRY_POLICY,
MIN_CRITIQUE_STAKE_APEX, MIN_ENDORSE_STAKE_APEX, MIN_PROPOSAL_STAKE_APEX,
scriptHashToAddress, deriveProposalTokenName, deriveActivityTokenName,
parseProposalDatum, parseCritiqueDatum, parseEndorsementDatum,
buildCritique, buildEndorse, buildProposalLock, buildProposalSpend,
} from '../src/vector/gov-build.ts';
// Deployed-state fixture, captured live from Vector testnet by
// scripts/capture-gov-state.mjs (2026-07-30) - see that file for the capture
// and validation logic. `sampleProposal.datumCbor` is a REAL chain datum;
// `goldenTokenPin` is the audit-#10 evidence (recomputed at capture time from
// the sample's own producing transaction).
const FIXTURE_PATH = resolve(import.meta.dirname!, 'fixtures/gov-state.fixture.json');
const fixture = JSON.parse(readFileSync(FIXTURE_PATH, 'utf-8')) as {
asOf: string;
refScripts: Array<{ name: string; txHash: string; index: number; scriptCborHex: string; hash: string }>;
infra: Array<{ name: string; txHash: string; index: number; address: string; valueLovelace: string; datumCbor: string | null }>;
sampleProposal: { txHash: string; index: number; datumCbor: string; tokenUnit: string };
goldenTokenPin: { lockTxHash: string; lockOutputIndex: number; expectedTokenName: string };
};
// A FixtureProvider extended with the 2 chain-clock methods OgmiosProvider
// carries but the base Provider interface (and FixtureProvider) do not:
// gov-build's ensureSlotConfig/slotClockOf need getSystemStartMs and
// getNetworkTip. Object.assign onto a real FixtureProvider instance keeps
// every other method (getUtxos, getUtxosByOutRef, ...) working normally -
// only these two are stand-ins. Same zeroTime in every call site so
// ensureSlotConfig's module-level cache can't make test order matter.
const ZERO_TIME_MS = 1_700_000_000_000;
const TIP_SLOT = 1_000_000;
function chainClockProvider(utxos: typeof FIXTURE_UTXOS) {
return Object.assign(new FixtureProvider(utxos), {
getSystemStartMs: async () => ZERO_TIME_MS,
getNetworkTip: async () => ({ slot: TIP_SLOT }),
});
}
const AGENT_DID = 'ab'.repeat(32);
const PROPOSAL_TX = '11'.repeat(32);
const OWN_VKEY_HASH = getAddressDetails(OWN_ADDRESS).paymentCredential!.hash!;
function decodeTx(cborHex: string) {
return CML.Transaction.from_cbor_hex(cborHex);
}
function findOutputTo(tx: ReturnType<typeof decodeTx>, address: string) {
const outs = tx.body().outputs();
for (let i = 0; i < outs.len(); i++) {
const o = outs.get(i);
if (o.address().to_bech32() === address) return o;
}
return null;
}
// Shared by the mint-content and continuing-output-asset checks below (same
// helper as registry-build.test.ts - Mint's as_positive_multiasset() and
// Value's multi_asset() both return the same MultiAsset type).
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;
}
let lucid: LucidEvolution;
before(async () => {
lucid = await lucidForAddress(chainClockProvider(FIXTURE_UTXOS), OWN_ADDRESS);
});
describe('gov-build constants and helpers', () => {
test('family spend addresses derive to the recon-verified bech32 values', () => {
assert.equal(
scriptHashToAddress(GOV_PROPOSAL_SPEND_HASH),
'addr1w8uptag6wcqz665h86planmq73288czq4nhgt333ln8pxngfzg955',
);
assert.equal(
scriptHashToAddress(GOV_CRITIQUE_SPEND_HASH),
'addr1w88d2gr5scd0jh3qsgqy6crpkr7yhvc0mmtpl9s9hlpqu4gra6s0g',
);
assert.equal(
scriptHashToAddress(GOV_ENDORSEMENT_SPEND_HASH),
'addr1w90ugjvy3kzlxq58uk7qh54na9wcwthe003879yqcyh348gvfdvx4',
);
});
// Audit #10 (closed 2026-07-30): deriveProposalTokenName - flat
// Constr(0, [txHash, idx]) through Lucid's own Data.to - reproduces a token
// name actually minted on deployed bytecode. Literals below are the full
// hex captured live by scripts/capture-gov-state.mjs from the producing
// transaction of a real submitted proposal (spend tx
// 01ac86ca226e1824762dec15a405bed9a67f99419f7ef283c3424964a23d2fb8, lock
// input 0b4015bd69ef...#0). Hardcoded (not read from the fixture) so this
// pin catches drift even if the fixture is ever regenerated incorrectly;
// the second half of the test cross-checks fixture/pin agreement.
test('deriveProposalTokenName golden pin matches deployed bytecode (audit #10)', () => {
const LOCK_TX_HASH = '0b4015bd69ef9e93a7eafdc778433ea1d1acb4f8ab7b2efaa52990b65415e919';
const LOCK_OUTPUT_INDEX = 0;
const EXPECTED_TOKEN_NAME = '70726f705f1a38676b49274bb694b9677f8b17af728bb4fca74bfd3572049bfe';
assert.equal(deriveProposalTokenName(LOCK_TX_HASH, LOCK_OUTPUT_INDEX), EXPECTED_TOKEN_NAME);
// fixture/pin agreement: the checked-in capture must still say the same thing.
assert.equal(fixture.goldenTokenPin.lockTxHash, LOCK_TX_HASH);
assert.equal(fixture.goldenTokenPin.lockOutputIndex, LOCK_OUTPUT_INDEX);
assert.equal(fixture.goldenTokenPin.expectedTokenName, EXPECTED_TOKEN_NAME);
});
test('parseProposalDatum parses the captured REAL chain datum into a populated object', () => {
const parsed = parseProposalDatum(fixture.sampleProposal.datumCbor);
assert.ok(parsed, 'fixture sample proposal datum failed to parse');
assert.ok(
['Open', 'Amended', 'Adopted', 'Rejected', 'Expired', 'Withdrawn'].includes(parsed!.state),
`unexpected state: ${parsed!.state}`,
);
assert.ok(
['ParameterChange', 'TreasurySpend', 'ProtocolUpgrade', 'GameActivation', 'GeneralSuggestion'].includes(parsed!.proposalType),
`unexpected proposalType: ${parsed!.proposalType}`,
);
assert.equal(typeof parsed!.proposerDid, 'string');
assert.ok(parsed!.proposerDid.length > 0);
assert.ok(parsed!.storageUri.startsWith('ipfs://') || parsed!.storageUri.length > 0);
assert.ok(parsed!.stakeAmount > 0);
});
test('parseProposalDatum returns null on garbage, never throws', () => {
assert.equal(parseProposalDatum('deadbeef'), null);
});
test('MIN_CRITIQUE_STAKE_APEX, MIN_ENDORSE_STAKE_APEX, and MIN_PROPOSAL_STAKE_APEX match the wire minimums', () => {
assert.equal(MIN_CRITIQUE_STAKE_APEX, 10);
assert.equal(MIN_ENDORSE_STAKE_APEX, 5);
assert.equal(MIN_PROPOSAL_STAKE_APEX, 25);
});
});
describe('buildCritique (offline, FixtureProvider)', () => {
test('builds an unsigned lock tx: stake+2 AP3X to the critique address, datum round-trips', async () => {
const r = await buildCritique(lucid, {
agentDid: AGENT_DID,
proposalTxHash: PROPOSAL_TX,
proposalOutputIndex: 0,
critiqueType: 'Supportive',
stakeApex: 12,
critiqueHash: 'cd'.repeat(32),
storageUri: 'ipfs://test-critique-doc',
});
assert.equal(r.op, 'critique');
assert.equal(r.stakeLovelace, '12000000');
assert.equal(r.scriptAddress, scriptHashToAddress(GOV_CRITIQUE_SPEND_HASH));
assert.equal(r.proposalRef, `${PROPOSAL_TX}#0`);
assert.equal(r.ipfsCid, undefined, 'no document was given - no upload should have happened');
const tx = decodeTx(r.txCbor);
const vkeys = tx.witness_set().vkeywitnesses();
assert.ok(vkeys === undefined || vkeys.len() === 0, 'critique build must be unsigned');
assert.equal(CML.hash_transaction(tx.body()).to_hex(), r.txHash);
const critiqueOut = findOutputTo(tx, r.scriptAddress);
assert.ok(critiqueOut, 'no output to the critique address');
assert.equal(critiqueOut!.amount().coin(), 14_000_000n, 'output must be stake (12 AP3X) + 2 AP3X');
assert.ok(critiqueOut!.datum() && critiqueOut!.datum()!.kind() === 1, 'continuing datum must be inline');
const datumCbor = critiqueOut!.datum()!.as_datum()!.to_cbor_hex();
const parsed = parseCritiqueDatum(datumCbor);
assert.ok(parsed, 'built critique datum failed to parse back');
assert.equal(parsed!.criticDid, AGENT_DID);
assert.equal(parsed!.critiqueType, 'Supportive');
assert.equal(parsed!.critiqueHash, 'cd'.repeat(32));
assert.equal(parsed!.storageUri, 'ipfs://test-critique-doc');
assert.equal(parsed!.stakeAmount, 12_000_000);
assert.equal(parsed!.incorporated, false);
assert.equal(parsed!.proposalRef.fields[0], PROPOSAL_TX);
assert.equal(Number(parsed!.proposalRef.fields[1]), 0);
});
test('Amendment critiqueType embeds the critique hash in the type datum too', async () => {
const hash = 'ef'.repeat(32);
const r = await buildCritique(lucid, {
agentDid: AGENT_DID, proposalTxHash: PROPOSAL_TX, proposalOutputIndex: 2,
critiqueType: 'Amendment', stakeApex: 10, critiqueHash: hash, storageUri: 'ipfs://amend',
});
const tx = decodeTx(r.txCbor);
const critiqueOut = findOutputTo(tx, r.scriptAddress)!;
const datumCbor = critiqueOut.datum()!.as_datum()!.to_cbor_hex();
const parsed = parseCritiqueDatum(datumCbor)!;
assert.equal(parsed.critiqueType, 'Amendment');
// the parser only surfaces critique_type's constructor index (matches the
// custodial parser exactly) - decode the raw datum directly to confirm
// the hash is actually embedded in field 5's payload, not just labeled.
const raw = Data.from(datumCbor) as Constr<Data>;
const critiqueTypeField = raw.fields[5] as Constr<Data>;
assert.equal(critiqueTypeField.index, 2);
assert.equal(critiqueTypeField.fields[0], hash);
});
test('attempts a Filebase upload when critiqueDocument is given (fails clean - not configured in tests)', async () => {
// No FILEBASE_* env vars are set in this test environment, so a genuine
// upload attempt must surface the "not configured" error - proving the
// document path is actually wired up without needing to mock the AWS SDK.
await assert.rejects(
buildCritique(lucid, {
agentDid: AGENT_DID, proposalTxHash: PROPOSAL_TX, proposalOutputIndex: 0,
critiqueType: 'Supportive', stakeApex: 12, critiqueDocument: '{"finding":"test"}',
}),
/Filebase not configured/,
);
});
test('stake minimum is validated BEFORE any Filebase upload attempt', async () => {
// If validation order were wrong, this would fail with the Filebase
// error instead (critiqueDocument is provided, stake is not).
await assert.rejects(
buildCritique(lucid, {
agentDid: AGENT_DID, proposalTxHash: PROPOSAL_TX, proposalOutputIndex: 0,
critiqueType: 'Supportive', stakeApex: 1, critiqueDocument: '{"finding":"test"}',
}),
/at least 10/i,
);
});
test('succeeds at exactly the minimum stake (10 AP3X) - the boundary is inclusive', async () => {
const r = await buildCritique(lucid, {
agentDid: AGENT_DID, proposalTxHash: PROPOSAL_TX, proposalOutputIndex: 0,
critiqueType: 'Supportive', stakeApex: 10,
critiqueHash: 'aa'.repeat(32), storageUri: 'ipfs://x',
});
assert.equal(r.stakeLovelace, '10000000');
const tx = decodeTx(r.txCbor);
const critiqueOut = findOutputTo(tx, r.scriptAddress);
assert.ok(critiqueOut, 'no output to the critique address');
assert.equal(critiqueOut!.amount().coin(), 12_000_000n, 'exactly-minimum stake (10 AP3X) must still build: 10 + 2 AP3X');
});
test('rejects a stake below the minimum', async () => {
await assert.rejects(
buildCritique(lucid, {
agentDid: AGENT_DID, proposalTxHash: PROPOSAL_TX, proposalOutputIndex: 0,
critiqueType: 'Supportive', stakeApex: 9,
critiqueHash: 'aa'.repeat(32), storageUri: 'ipfs://x',
}),
/at least 10/i,
);
});
test('rejects when neither critiqueDocument nor critiqueHash/storageUri is provided', async () => {
await assert.rejects(
buildCritique(lucid, {
agentDid: AGENT_DID, proposalTxHash: PROPOSAL_TX, proposalOutputIndex: 0,
critiqueType: 'Supportive', stakeApex: 10,
}),
/critiqueHash|critiqueDocument/,
);
});
test('rejects a malformed critiqueHash (not 64 hex chars)', async () => {
await assert.rejects(
buildCritique(lucid, {
agentDid: AGENT_DID, proposalTxHash: PROPOSAL_TX, proposalOutputIndex: 0,
critiqueType: 'Supportive', stakeApex: 10,
critiqueHash: 'nothex', storageUri: 'ipfs://x',
}),
/64 hex/,
);
});
});
describe('buildEndorse (offline, FixtureProvider)', () => {
test('builds an unsigned lock tx: stake+2 AP3X to the endorsement address, datum round-trips', async () => {
const r = await buildEndorse(lucid, {
agentDid: AGENT_DID, proposalTxHash: PROPOSAL_TX, proposalOutputIndex: 1, stakeApex: 7,
});
assert.equal(r.op, 'endorse');
assert.equal(r.stakeLovelace, '7000000');
assert.equal(r.scriptAddress, scriptHashToAddress(GOV_ENDORSEMENT_SPEND_HASH));
assert.equal(r.proposalRef, `${PROPOSAL_TX}#1`);
const tx = decodeTx(r.txCbor);
const vkeys = tx.witness_set().vkeywitnesses();
assert.ok(vkeys === undefined || vkeys.len() === 0, 'endorse build must be unsigned');
assert.equal(CML.hash_transaction(tx.body()).to_hex(), r.txHash);
const endorseOut = findOutputTo(tx, r.scriptAddress);
assert.ok(endorseOut, 'no output to the endorsement address');
assert.equal(endorseOut!.amount().coin(), 9_000_000n, 'output must be stake (7 AP3X) + 2 AP3X');
assert.ok(endorseOut!.datum() && endorseOut!.datum()!.kind() === 1, 'continuing datum must be inline');
const datumCbor = endorseOut!.datum()!.as_datum()!.to_cbor_hex();
const parsed = parseEndorsementDatum(datumCbor);
assert.ok(parsed, 'built endorsement datum failed to parse back');
assert.equal(parsed!.endorserDid, AGENT_DID);
assert.equal(parsed!.stakeAmount, 7_000_000);
assert.equal(parsed!.proposalRef.fields[0], PROPOSAL_TX);
assert.equal(Number(parsed!.proposalRef.fields[1]), 1);
});
test('succeeds at exactly the minimum stake (5 AP3X) - the boundary is inclusive', async () => {
const r = await buildEndorse(lucid, {
agentDid: AGENT_DID, proposalTxHash: PROPOSAL_TX, proposalOutputIndex: 0, stakeApex: 5,
});
assert.equal(r.stakeLovelace, '5000000');
const tx = decodeTx(r.txCbor);
const endorseOut = findOutputTo(tx, r.scriptAddress);
assert.ok(endorseOut, 'no output to the endorsement address');
assert.equal(endorseOut!.amount().coin(), 7_000_000n, 'exactly-minimum stake (5 AP3X) must still build: 5 + 2 AP3X');
});
test('rejects a stake below the minimum', async () => {
await assert.rejects(
buildEndorse(lucid, {
agentDid: AGENT_DID, proposalTxHash: PROPOSAL_TX, proposalOutputIndex: 0, stakeApex: 4,
}),
/at least 5/i,
);
});
});
describe('buildProposalLock (offline, FixtureProvider)', () => {
test('builds an unsigned lock tx: stake+2 AP3X to the proposal address, datum round-trips (GeneralSuggestion)', async () => {
const r = await buildProposalLock(lucid, {
agentDid: AGENT_DID,
proposalType: 'GeneralSuggestion',
stakeApex: 25,
proposalHash: '22'.repeat(32),
storageUri: 'ipfs://test-proposal-doc',
});
assert.equal(r.stakeLovelace, '25000000');
assert.equal(r.scriptAddress, scriptHashToAddress(GOV_PROPOSAL_SPEND_HASH));
assert.equal(r.proposalHash, '22'.repeat(32));
assert.equal(r.storageUri, 'ipfs://test-proposal-doc');
assert.equal(r.ipfsCid, undefined, 'no document was given - no upload should have happened');
const tx = decodeTx(r.txCbor);
const vkeys = tx.witness_set().vkeywitnesses();
assert.ok(vkeys === undefined || vkeys.len() === 0, 'lock build must be unsigned');
assert.equal(CML.hash_transaction(tx.body()).to_hex(), r.txHash);
const lockOut = findOutputTo(tx, r.scriptAddress);
assert.ok(lockOut, 'no output to the proposal address');
assert.equal(lockOut!.amount().coin(), 27_000_000n, 'output must be stake (25 AP3X) + 2 AP3X');
assert.ok(lockOut!.datum() && lockOut!.datum()!.kind() === 1, 'continuing datum must be inline');
const datumCbor = lockOut!.datum()!.as_datum()!.to_cbor_hex();
const parsed = parseProposalDatum(datumCbor);
assert.ok(parsed, 'built proposal datum failed to parse back');
assert.equal(parsed!.proposerDid, AGENT_DID);
assert.equal(parsed!.proposalType, 'GeneralSuggestion');
assert.equal(parsed!.stakeAmount, 25_000_000);
assert.equal(parsed!.storageUri, 'ipfs://test-proposal-doc');
assert.equal(parsed!.proposalHash, '22'.repeat(32));
assert.equal(parsed!.state, 'Open');
assert.equal(parsed!.priority, 'Standard');
assert.equal(parsed!.amendmentCount, 0);
assert.equal(parsed!.reviewWindow, 604_800_000);
});
test('succeeds at exactly the minimum stake (25 AP3X) - the boundary is inclusive', async () => {
const r = await buildProposalLock(lucid, {
agentDid: AGENT_DID, proposalType: 'GeneralSuggestion', stakeApex: 25,
proposalHash: 'aa'.repeat(32), storageUri: 'ipfs://x',
});
assert.equal(r.stakeLovelace, '25000000');
const tx = decodeTx(r.txCbor);
const lockOut = findOutputTo(tx, r.scriptAddress);
assert.equal(lockOut!.amount().coin(), 27_000_000n, 'exactly-minimum stake (25 AP3X) must still build: 25 + 2 AP3X');
});
test('rejects a stake below the minimum (24 AP3X)', async () => {
await assert.rejects(
buildProposalLock(lucid, {
agentDid: AGENT_DID, proposalType: 'GeneralSuggestion', stakeApex: 24,
proposalHash: 'aa'.repeat(32), storageUri: 'ipfs://x',
}),
/at least 25/i,
);
});
test('ParameterChange without typeParams rejects', async () => {
await assert.rejects(
buildProposalLock(lucid, {
agentDid: AGENT_DID, proposalType: 'ParameterChange', stakeApex: 25,
proposalHash: 'aa'.repeat(32), storageUri: 'ipfs://x',
}),
/ParameterChange requires/,
);
});
test('ParameterChange with typeParams embeds paramName/currentValue/proposedValue in the type datum', async () => {
const r = await buildProposalLock(lucid, {
agentDid: AGENT_DID, proposalType: 'ParameterChange', stakeApex: 25,
typeParams: { paramName: 'MIN_PROPOSAL_STAKE', currentValue: 25, proposedValue: 30 },
proposalHash: 'aa'.repeat(32), storageUri: 'ipfs://x',
});
const tx = decodeTx(r.txCbor);
const lockOut = findOutputTo(tx, r.scriptAddress)!;
const datumCbor = lockOut.datum()!.as_datum()!.to_cbor_hex();
const raw = Data.from(datumCbor) as Constr<Data>;
const typeField = raw.fields[3] as Constr<Data>;
assert.equal(typeField.index, 0);
assert.equal(toText(typeField.fields[0] as string), 'MIN_PROPOSAL_STAKE');
assert.equal(Number(typeField.fields[1]), 25);
assert.equal(Number(typeField.fields[2]), 30);
});
test('TreasurySpend without typeParams rejects', async () => {
await assert.rejects(
buildProposalLock(lucid, {
agentDid: AGENT_DID, proposalType: 'TreasurySpend', stakeApex: 25,
proposalHash: 'aa'.repeat(32), storageUri: 'ipfs://x',
}),
/TreasurySpend requires/,
);
});
// The three Constr indices below are the on-chain contract's own layout
// (0=ParameterChange, 1=TreasurySpend, 2=ProtocolUpgrade, 3=GameActivation,
// 4=GeneralSuggestion - see the custodial switch this was transcribed
// from) - an index swap between any two of these builds a datum the
// deployed validator would parse as the WRONG proposal type. Each test
// decodes the raw type-datum Constr directly (not through the parser,
// which only round-trips the type NAME back from the index) to pin the
// literal on-wire index.
test('TreasurySpend with typeParams builds with type datum index 1 (amount + recipientDescription)', async () => {
const r = await buildProposalLock(lucid, {
agentDid: AGENT_DID, proposalType: 'TreasurySpend', stakeApex: 25,
typeParams: { amount: 500, recipientDescription: 'research grant pool' },
proposalHash: 'aa'.repeat(32), storageUri: 'ipfs://x',
});
const tx = decodeTx(r.txCbor);
const lockOut = findOutputTo(tx, r.scriptAddress)!;
const datumCbor = lockOut.datum()!.as_datum()!.to_cbor_hex();
const raw = Data.from(datumCbor) as Constr<Data>;
const typeField = raw.fields[3] as Constr<Data>;
assert.equal(typeField.index, 1, "TreasurySpend must encode as Constr index 1 - the on-chain contract's layout, not renumbered");
assert.equal(Number(typeField.fields[0]), 500);
assert.equal(toText(typeField.fields[1] as string), 'research grant pool');
});
test('ProtocolUpgrade builds with type datum index 2 (upgradeHash embedded)', async () => {
const r = await buildProposalLock(lucid, {
agentDid: AGENT_DID, proposalType: 'ProtocolUpgrade', stakeApex: 25,
typeParams: { upgradeHash: 'cc'.repeat(32) },
proposalHash: 'aa'.repeat(32), storageUri: 'ipfs://x',
});
const tx = decodeTx(r.txCbor);
const lockOut = findOutputTo(tx, r.scriptAddress)!;
const datumCbor = lockOut.datum()!.as_datum()!.to_cbor_hex();
const raw = Data.from(datumCbor) as Constr<Data>;
const typeField = raw.fields[3] as Constr<Data>;
assert.equal(typeField.index, 2, "ProtocolUpgrade must encode as Constr index 2 - the on-chain contract's layout, not renumbered");
assert.equal(typeField.fields[0], 'cc'.repeat(32));
});
test('GameActivation builds with type datum index 3 (gameId embedded)', async () => {
const r = await buildProposalLock(lucid, {
agentDid: AGENT_DID, proposalType: 'GameActivation', stakeApex: 25,
typeParams: { gameId: 6 },
proposalHash: 'aa'.repeat(32), storageUri: 'ipfs://x',
});
const tx = decodeTx(r.txCbor);
const lockOut = findOutputTo(tx, r.scriptAddress)!;
const datumCbor = lockOut.datum()!.as_datum()!.to_cbor_hex();
const raw = Data.from(datumCbor) as Constr<Data>;
const typeField = raw.fields[3] as Constr<Data>;
assert.equal(typeField.index, 3, "GameActivation must encode as Constr index 3 - the on-chain contract's layout, not renumbered");
assert.equal(Number(typeField.fields[0]), 6);
});
test('manual proposalHash + storageUri path works without a document', async () => {
const r = await buildProposalLock(lucid, {
agentDid: AGENT_DID, proposalType: 'GeneralSuggestion', stakeApex: 25,
proposalHash: 'bb'.repeat(32), storageUri: 'ipfs://manual',
});
assert.equal(r.proposalHash, 'bb'.repeat(32));
assert.equal(r.storageUri, 'ipfs://manual');
});
test('rejects a malformed proposalHash (not 64 hex chars)', async () => {
await assert.rejects(
buildProposalLock(lucid, {
agentDid: AGENT_DID, proposalType: 'GeneralSuggestion', stakeApex: 25,
proposalHash: 'nothex', storageUri: 'ipfs://x',
}),
/64 hex/,
);
});
test('rejects when neither proposalDocument nor proposalHash/storageUri is provided', async () => {
await assert.rejects(
buildProposalLock(lucid, {
agentDid: AGENT_DID, proposalType: 'GeneralSuggestion', stakeApex: 25,
}),
/proposalHash|proposalDocument/,
);
});
test('stake minimum is validated BEFORE any Filebase upload attempt', async () => {
// If validation order were wrong, this would fail with the Filebase
// error instead (proposalDocument is provided, stake is not).
await assert.rejects(
buildProposalLock(lucid, {
agentDid: AGENT_DID, proposalType: 'GeneralSuggestion', stakeApex: 1,
proposalDocument: '{"proposal":"test"}',
}),
/at least 25/i,
);
});
test('attempts a Filebase upload when proposalDocument is given (fails clean - not configured in tests)', async () => {
// No FILEBASE_* env vars are set in this test environment, so a genuine
// upload attempt must surface the "not configured" error - proving the
// document path is actually wired up without needing to mock the AWS SDK.
await assert.rejects(
buildProposalLock(lucid, {
agentDid: AGENT_DID, proposalType: 'GeneralSuggestion', stakeApex: 25,
proposalDocument: '{"proposal":"test"}',
}),
/Filebase not configured/,
);
});
});
describe('buildProposalSpend (offline, FixtureProvider)', () => {
const LOCK_TX_HASH = 'aa'.repeat(32);
const PRESERVE_LOCK_TX_HASH = 'bb'.repeat(32);
const NFT_TX_HASH = 'cc'.repeat(32);
const NFT_UNIT = AGENT_REGISTRY_POLICY + AGENT_DID;
let proposalSpendAddress: string;
let lockDatumCbor: string;
let lockedUtxoFixture: UTxO;
let preserveLockedUtxoFixture: UTxO;
let spendLucid: LucidEvolution;
let spendProvider: ReturnType<typeof chainClockProvider>;
function nftFixtureUtxo(): UTxO {
return {
txHash: NFT_TX_HASH, outputIndex: 0, address: getRegistryAddress(),
assets: { lovelace: MIN_AP3X_DEPOSIT, [NFT_UNIT]: 1n },
datum: buildAgentDatum(OWN_VKEY_HASH, 'FixtureAgent', 'offline fixture agent', ['testing'], 'custom', ''),
};
}
function refScriptFixtureUtxos(): UTxO[] {
return fixture.refScripts.map((s) => ({
txHash: s.txHash, outputIndex: s.index, address: proposalSpendAddress,
assets: { lovelace: 2_000_000n },
scriptRef: { type: 'PlutusV3', script: s.scriptCborHex },
}));
}
function infraFixtureUtxos(): UTxO[] {
return fixture.infra.map((i) => ({
txHash: i.txHash, outputIndex: i.index, address: i.address,
assets: { lovelace: BigInt(i.valueLovelace) },
datum: i.datumCbor,
}));
}
// Assembles the offline chain state buildProposalSpend needs, with the
// ability to omit one category per negative test (missing-NFT,
// missing-ref-scripts).
function baseSpendUtxos(opts?: { skipNft?: boolean; skipRefScripts?: boolean }): UTxO[] {
return [
...FIXTURE_UTXOS, lockedUtxoFixture, preserveLockedUtxoFixture,
...(opts?.skipNft ? [] : [nftFixtureUtxo()]),
...(opts?.skipRefScripts ? [] : refScriptFixtureUtxos()),
...infraFixtureUtxos(),
];
}
// buildProposalSpend's PRODUCTION path (opts omitted - same as Task 5's
// callers) completes with `localUPLCEval: false`, which asks the PROVIDER
// to evaluate the transaction's script redeemers (see gov-build.ts's
// module doc). FixtureProvider deliberately has no evaluateTx (offline
// only - see fixture-provider.ts), and the REAL deployed validators
// legitimately reject this test's synthetic chain state - captured
// honestly, verbatim, in the dedicated native-eval-attempt describe block
// below, not swept under the rug here.
//
// This wrapper stubs evaluateTx with generous-but-harmless execution units
// (well inside the fixture protocol params' per-tx maximum) purely so
// Lucid can finish ASSEMBLING a well-formed, decodable transaction for the
// structural assertions in this describe block. It does NOT assert or
// imply the deployed validator would accept the result - that question is
// answered separately, honestly, by the native-eval-attempt test (which
// never calls evaluateTx at all, so this stub is simply unused there).
//
// Two things this stub deliberately does NOT cover, each carried by a
// different test instead: (1) it fabricates canned ex-units, not real
// ones, so the structural tests' fees are fictitious - nothing here or
// elsewhere asserts on `fee`/`feeAda`; (2) it never inspects or
// constrains REDEEMER CONTENT (only the units Lucid attaches to whichever
// redeemers already exist in the draft tx) - the structural test's
// literal 'd87980' redeemer-data pin, the native-eval-attempt test, and
// the (manual, gated) E2E integration test are what actually exercise
// redeemer correctness; a corrupted redeemer would sail through this stub
// unnoticed.
function stubEvalProvider(utxos: UTxO[]) {
const provider = chainClockProvider(utxos);
return Object.assign(provider, {
evaluateTx: async (txCborHex: string) => {
const draft = CML.Transaction.from_cbor_hex(txCborHex);
const legacy = draft.witness_set().redeemers()?.as_arr_legacy_redeemer();
const tagMap: Record<number, 'spend' | 'mint' | 'publish' | 'withdraw' | 'vote' | 'propose'> = {
0: 'spend', 1: 'mint', 2: 'publish', 3: 'withdraw', 4: 'vote', 5: 'propose',
};
const out: { ex_units: { mem: number; steps: number }; redeemer_index: number; redeemer_tag: typeof tagMap[number] }[] = [];
if (legacy) {
for (let i = 0; i < legacy.len(); i++) {
const r = legacy.get(i);
out.push({
ex_units: { mem: 2_000_000, steps: 500_000_000 },
redeemer_index: Number(r.index()),
redeemer_tag: tagMap[r.tag()] ?? 'spend',
});
}
}
return out;
},
});
}
before(async () => {
proposalSpendAddress = scriptHashToAddress(GOV_PROPOSAL_SPEND_HASH);
// Build a lock offline, then decode ITS OWN output to extract the real
// inline datum bytes - buildProposalSpend must reuse these verbatim, not
// reconstruct them, so the fixture has to carry genuine built-datum CBOR.
const lockResult = await buildProposalLock(lucid, {
agentDid: AGENT_DID, proposalType: 'GeneralSuggestion', stakeApex: 25,
proposalHash: '33'.repeat(32), storageUri: 'ipfs://spend-fixture-doc',
});
const lockTx = decodeTx(lockResult.txCbor);
const lockOut = findOutputTo(lockTx, proposalSpendAddress)!;
lockDatumCbor = lockOut.datum()!.as_datum()!.to_cbor_hex();
lockedUtxoFixture = {
txHash: LOCK_TX_HASH, outputIndex: 0, address: proposalSpendAddress,
assets: { lovelace: 27_000_000n }, datum: lockDatumCbor,
};
// A second locked UTxO with an off-constant lovelace amount, used ONLY by
// the value-preservation test below: if buildProposalSpend recomputed
// stake+2 AP3X instead of carrying the locked input's own coin through,
// 27_000_001n (not 27_000_000n) could never appear in the continuing
// output.
preserveLockedUtxoFixture = {
txHash: PRESERVE_LOCK_TX_HASH, outputIndex: 0, address: proposalSpendAddress,
assets: { lovelace: 27_000_001n }, datum: lockDatumCbor,
};
spendProvider = stubEvalProvider(baseSpendUtxos());
spendLucid = await lucidForAddress(spendProvider, OWN_ADDRESS);
});
test('spends the locked UTxO, mints prop_+pact_ tokens, preserves the locked datum byte-identically', async () => {
// No opts: this is the PRODUCTION path (localUPLCEval: false internally)
// - see stubEvalProvider's comment above for why that still completes
// offline. Structural assertions only; the honesty-gate test below is
// what actually asks the real validators anything.
const r = await buildProposalSpend(spendLucid, spendProvider, {
agentDid: AGENT_DID, lockTxHash: LOCK_TX_HASH, lockOutputIndex: 0,
});
const expectedPropName = deriveProposalTokenName(LOCK_TX_HASH, 0);
const expectedActName = deriveActivityTokenName(AGENT_DID);
assert.equal(r.proposalTokenName, expectedPropName);
assert.equal(r.activityTokenName, expectedActName);
// THE PIN (Task 3 review mandate): the activity token name must come
// from deriveActivityTokenName's 'pact_' prefix - a 'pact_' -> 'act_'
// mutation in the implementation must fail this assertion by name.
assert.equal(
r.activityTokenName.slice(0, 10), Buffer.from('pact_', 'utf-8').toString('hex'),
"activityTokenName must be derived via deriveActivityTokenName's 'pact_' prefix",
);
assert.equal(r.scriptAddress, proposalSpendAddress);
const tx = decodeTx(r.txCbor);
// unsigned
const vkeys = tx.witness_set().vkeywitnesses();
assert.ok(vkeys === undefined || vkeys.len() === 0, 'spend build must be unsigned');
assert.equal(CML.hash_transaction(tx.body()).to_hex(), r.txHash);
// the locked UTxO is actually spent
const ins = tx.body().inputs();
let sawLockedInput = false;
for (let i = 0; i < ins.len(); i++) {
if (ins.get(i).transaction_id().to_hex() === LOCK_TX_HASH) sawLockedInput = true;
}
assert.ok(sawLockedInput, 'the locked UTxO must be a spent input');
// mints exactly prop:1 + act:1, with the exact derived unit names
const mint = tx.body().mint()!;
assert.ok(mint, 'no mint field');
const ma = mint.as_positive_multiasset();
assert.equal(findAssetQuantity(ma, GOV_PROPOSAL_MINT_HASH, expectedPropName), 1n, 'must mint exactly 1 prop_ token of the derived name');
assert.equal(findAssetQuantity(ma, GOV_PROPOSAL_MINT_HASH, expectedActName), 1n, 'must mint exactly 1 pact_ token of the derived name');
// Redeemer content, not just mint/output shape - stubEvalProvider only
// fabricates execution UNITS, it never inspects or constrains redeemer
// DATA, so nothing above this point would catch a corrupted or
// mismatched redeemer. Both the Spend and Mint redeemers must be
// SubmitRedeemer = Data.to(new Constr(0, [])), which CBOR-encodes to the
// fixed 3-byte 'd87980' (tag 121 = alternative 0, empty field array) -
// pinned as a literal, not by re-deriving from Constr, so a redeemer
// corruption can't hide behind a mutated encoder agreeing with itself.
const legacyRedeemers = tx.witness_set().redeemers()?.as_arr_legacy_redeemer();
assert.ok(legacyRedeemers, 'no redeemers in the witness set');
assert.equal(legacyRedeemers!.len(), 2, 'must carry exactly 2 redeemers (Spend + Mint)');
for (let i = 0; i < legacyRedeemers!.len(); i++) {
const redeemer = legacyRedeemers!.get(i);
assert.equal(
redeemer.data().to_cbor_hex(), 'd87980',
`redeemer ${i} (tag ${redeemer.tag()}) must be the empty SubmitRedeemer Constr(0, [])`,
);
}
// exactly two continuing outputs at the proposal address
const outs = tx.body().outputs();
const atSpendAddr: any[] = [];
for (let i = 0; i < outs.len(); i++) {
const o = outs.get(i);
if (o.address().to_bech32() === proposalSpendAddress) atSpendAddr.push(o);
}
assert.equal(atSpendAddr.length, 2, 'must produce exactly 2 continuing outputs at the proposal address');
// Positional pin, matching the mainnet-proven custodial output order
// exactly (the proposal-continuing pay.ToAddressWithData call comes
// before the activity one in both the custodial module and this build):
// output 0 = the proposal-datum continuing output (prop token), output
// 1 = the activity output (act token). Verified empirically against a
// real decoded build before pinning this, not assumed - output 2 (not
// asserted here) is Lucid's own wallet-change output from fee coin
// selection, which is incidental to this build's own logic.
const output0Assets = outs.get(0).amount().multi_asset();
const output1Assets = outs.get(1).amount().multi_asset();
assert.equal(
output0Assets && findAssetQuantity(output0Assets, GOV_PROPOSAL_MINT_HASH, expectedPropName), 1n,
'output 0 must be the proposal-continuing output (carries the prop_ token)',
);
assert.equal(
output1Assets && findAssetQuantity(output1Assets, GOV_PROPOSAL_MINT_HASH, expectedActName), 1n,
'output 1 must be the activity output (carries the pact_ token)',
);
const propOut = atSpendAddr.find((o) => findAssetQuantity(o.amount().multi_asset()!, GOV_PROPOSAL_MINT_HASH, expectedPropName) === 1n);
assert.ok(propOut, 'no continuing output carries the prop_ token');
assert.equal(
propOut.datum()!.as_datum()!.to_cbor_hex(), lockDatumCbor,
'the continuing proposal datum must be byte-identical to the locked input datum',
);
assert.equal(propOut.amount().coin(), 27_000_000n, 'the continuing proposal output must preserve the locked UTxO coin');
const actOut = atSpendAddr.find((o) => findAssetQuantity(o.amount().multi_asset()!, GOV_PROPOSAL_MINT_HASH, expectedActName) === 1n);
assert.ok(actOut, 'no continuing output carries the pact_ token');
assert.equal(actOut.amount().coin(), 2_000_000n);
const activityRaw = Data.from(actOut.datum()!.as_datum()!.to_cbor_hex()) as Constr<Data>;
assert.equal(activityRaw.fields[0], AGENT_DID, 'activity datum agent_did must match');
assert.equal(Number(activityRaw.fields[2]), 1, 'activity datum active_proposal_count must be 1 (first proposal)');
assert.equal(Number(activityRaw.fields[3]), r.validToMs - 360_000, 'activity datum last_proposal_slot must equal validFromMs');
// validity window: exactly 360 slots (360_000ms at the test config's 1000ms/slot)
const start = tx.body().validity_interval_start();
const ttl = tx.body().ttl();
assert.ok(start !== undefined && ttl !== undefined, 'validity interval must be set');
assert.equal(Number(ttl! - start!), 360, 'validity window must be exactly 360 slots (360_000 ms)');
assert.equal(r.validToMs, ZERO_TIME_MS + TIP_SLOT * 1000 + 360_000, 'validToMs must be validFromMs + 360_000');
// required signer = the derived wallet address's payment hash
const signers = tx.body().required_signers();
assert.ok(signers && signers.len() >= 1, 'no required_signers');
let foundSigner = false;
for (let i = 0; i < signers!.len(); i++) {
if (signers!.get(i).to_hex() === OWN_VKEY_HASH) foundSigner = true;
}
assert.ok(foundSigner, 'wallet vkey hash missing from required_signers');
// reference inputs: 3 infra + 2 ref-script + 1 NFT = 6
const refIns = tx.body().reference_inputs();
assert.ok(refIns, 'no reference_inputs on the spend tx');
assert.equal(refIns!.len(), 6, 'must read exactly 3 infra + 2 ref-script + 1 NFT UTxOs');
});
test('value-preservation: continuing proposal output carries the LOCKED UTxO coin, not a recomputed constant', async () => {
const r = await buildProposalSpend(spendLucid, spendProvider, {
agentDid: AGENT_DID, lockTxHash: PRESERVE_LOCK_TX_HASH, lockOutputIndex: 0,
});
const expectedPropName = deriveProposalTokenName(PRESERVE_LOCK_TX_HASH, 0);
const tx = decodeTx(r.txCbor);
const outs = tx.body().outputs();
let propOut: any = null;
for (let i = 0; i < outs.len(); i++) {
const o = outs.get(i);
if (o.address().to_bech32() !== proposalSpendAddress) continue;
const ma = o.amount().multi_asset();
if (ma && findAssetQuantity(ma, GOV_PROPOSAL_MINT_HASH, expectedPropName) === 1n) propOut = o;
}
assert.ok(propOut, 'no continuing output carries the prop_ token');
assert.equal(
propOut.amount().coin(), 27_000_001n,
'must equal the locked input coin exactly, not the recomputed stake+2 constant (27_000_000n)',
);
});
test('absent locked UTxO (random hash) - await-first error', async () => {
await assert.rejects(
buildProposalSpend(spendLucid, spendProvider, {
agentDid: AGENT_DID, lockTxHash: 'ff'.repeat(32), lockOutputIndex: 0,
}),
/vector_await_transaction/,
);
});
test('locked UTxO exists but is not at the proposal script address - fail-fast error', async () => {
// A UTxO that resolves fine by outref but sits at the WALLET's own
// address, not the proposal script address - simulates a caller passing
// an unrelated txHash. Must be rejected before any datum parsing is
// attempted (the datum content here is irrelevant to this check).
const WRONG_ADDR_TX_HASH = 'dd'.repeat(32);
const wrongAddressUtxo: UTxO = {
txHash: WRONG_ADDR_TX_HASH, outputIndex: 0, address: OWN_ADDRESS,
assets: { lovelace: 27_000_000n }, datum: lockDatumCbor,
};
const providerWrongAddr = chainClockProvider([...baseSpendUtxos(), wrongAddressUtxo]);
const lucidWrongAddr = await lucidForAddress(providerWrongAddr, OWN_ADDRESS);
await assert.rejects(
buildProposalSpend(lucidWrongAddr, providerWrongAddr, {
agentDid: AGENT_DID, lockTxHash: WRONG_ADDR_TX_HASH, lockOutputIndex: 0,
}),
/not at the proposal script address/,
);
});
test('DID mismatch between the locked datum and the caller agentDid - clear error', async () => {
await assert.rejects(
buildProposalSpend(spendLucid, spendProvider, {
agentDid: 'ee'.repeat(32), lockTxHash: LOCK_TX_HASH, lockOutputIndex: 0,
}),
/DID mismatch/,
);
});
test('missing agent registry NFT - re-register error', async () => {
const providerNoNft = chainClockProvider(baseSpendUtxos({ skipNft: true }));
const lucidNoNft = await lucidForAddress(providerNoNft, OWN_ADDRESS);
await assert.rejects(
buildProposalSpend(lucidNoNft, providerNoNft, {
agentDid: AGENT_DID, lockTxHash: LOCK_TX_HASH, lockOutputIndex: 0,
}),
/re-register/,
);
});
test('missing reference scripts (omitted from fixtures) - the verbatim redeploy-hint error', async () => {
const providerNoRefs = chainClockProvider(baseSpendUtxos({ skipRefScripts: true }));
const lucidNoRefs = await lucidForAddress(providerNoRefs, OWN_ADDRESS);
await assert.rejects(
buildProposalSpend(lucidNoRefs, providerNoRefs, {
agentDid: AGENT_DID, lockTxHash: LOCK_TX_HASH, lockOutputIndex: 0,
}),
/redeploy_ref_scripts\.py/,
);
});
describe('native-eval attempt (honesty gate)', () => {
// Spec PR 8 Task 4's eval strategy (plan's Global Constraints section,
// "Eval strategy"): attempt NATIVE UPLC evaluation of the REAL deployed
// proposal spend+mint validators (captured bytecode,
// gov-state.fixture.json) against this test file's synthetic chain
// state, calling the SAME buildProposalSpend build used throughout this
// describe block - just with the opts override flipped to
// localUPLCEval: true instead of the structural tests' stubbed-provider
// production path.
//
// OUTCOME (captured 2026-07-30, verbatim - see task-4-report.md): FAILS.
// The validator trace shows it entered proposal_spend and successfully
// read PARAMS, then crashed - a genuine Plutus evaluation rejection, not
// a Lucid-level "couldn't build this" error:
//
// TxBuilderError: { Complete: "failed script execution Spend[0] the
// validator crashed / exited prematurely Trace ENTER:proposal_spend
// Trace OK:read_params" }
//
// What this trace DOES establish: the crash is a genuine chain-logic
// rejection inside the deployed validator, not a Lucid-side "couldn't
// even assemble this transaction" failure - the crash happens in a
// LATER check the plan explicitly named as chain-state-dependent
// (oracle freshness, or a DID/NFT cross-check needing exact real values
// this fixture's synthetic ORACLE/NFT data cannot satisfy offline).
//
// What it does NOT establish: that everything up to and including
// read_params is correct. "OK:read_params" is ONE-DIRECTIONAL evidence
// - an EARLIER failure (crashing before ENTER:proposal_spend, or before
// OK:read_params) WOULD imply a structural break, but reaching this
// point proves nothing about checks that come AFTER it. Confirmed
// directly, not assumed: deliberately dropping the NFT reference input
// from buildProposalSpend's readFrom calls (a real structural bug)
// reproduces this EXACT SAME trace, since the validator doesn't touch
// the NFT until a check that comes later. That specific defect class is
// instead caught by the structural test's reference_inputs-length pin
// (== 6) above, not by this trace - which is why that pin, the
// redeemer-content pin, and the output-order pin all live in the
// structural describe block rather than being inferred from this one
// eval attempt.
//
// This is the expected fork, not a structural defect: production keeps
// `localUPLCEval: false` and lets the PROVIDER evaluate (tier-1/E2E
// integration tests exercise that against the live network); this
// describe block's sibling tests above cover the offline-testable half
// (the transaction's own shape) with a stubbed evaluateTx that makes no
// claim about validator acceptance. NEVER weakened to assert.doesNotReject
// or deleted - the failure is pinned so a change in this behavior (pass
// OR a different failure) shows up here, not silently.
test('ATTEMPT: native UPLC eval of the deployed proposal spend+mint validators against fixture chain state (EXPECTED TO FAIL - chain-state-dependent, not a structural defect)', async () => {
await assert.rejects(
buildProposalSpend(spendLucid, spendProvider, {
agentDid: AGENT_DID, lockTxHash: LOCK_TX_HASH, lockOutputIndex: 0,
}, { localUPLCEval: true }),
(err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
// Must be a genuine Plutus validator rejection reached AFTER
// read_params, not a Lucid-side build failure and not a crash
// earlier than this. This is one-directional: it rules OUT an
// earlier-than-expected structural break, it does not rule IN
// that every later check is correct (see the describe-level
// comment above for why, and what does cover that).
assert.match(message, /proposal_spend/, 'expected a proposal_spend validator trace, not a build-level error');
assert.match(message, /OK:read_params/, 'expected the validator to have gotten PAST reading params before crashing - anything earlier would suggest a structural wiring defect, not a chain-state one');
return true;
},
);
});
});
});

View File

@@ -10,7 +10,14 @@ import type { RawServerHandle } from '../setup.ts';
// unit-tests RateLimiter/limiterFor in isolation; test:smoke only ever calls // unit-tests RateLimiter/limiterFor in isolation; test:smoke only ever calls
// listTools(). Neither exercises a single tool handler, so neither would // listTools(). Neither exercises a single tool handler, so neither would
// notice checkRateLimit/rateLimiter falling out of scope in agent-network.ts // notice checkRateLimit/rateLimiter falling out of scope in agent-network.ts
// or self-improvement.ts (both @ts-nocheck - tsc can't catch it either). See // or self-improvement.ts. Both files are typed now (self-improvement.ts as of
// the self-improvement-keyless PR) - no compiler-blind tool-registration file
// remains in the builder - but tsc alone still can't catch this class of bug:
// a handler that silently drops the `if (rateLimited) return rateLimited;`
// early-return typechecks fine (the function still returns the right shape),
// it just never gets called at runtime unless a real request exercises it.
// This test's timing-based hermeticity rationale stands on its own for that
// reason, independent of which files happen to be typed. See
// .superpowers/sdd/pr5-integration-tests-report.md for the mutation-testing // .superpowers/sdd/pr5-integration-tests-report.md for the mutation-testing
// evidence. // evidence.
// //

View File

@@ -1,5 +1,6 @@
import { describe, test, before, after } from 'node:test'; import { describe, test, before, after } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { randomBytes } from 'node:crypto';
import { walletFromSeed } from '@lucid-evolution/lucid'; import { walletFromSeed } from '@lucid-evolution/lucid';
import { startServer, stopServer, callTool, getMnemonic, wait, ServerContext } from '../setup.ts'; import { startServer, stopServer, callTool, getMnemonic, wait, ServerContext } from '../setup.ts';
import { signWithMnemonic } from './sign-helper.ts'; import { signWithMnemonic } from './sign-helper.ts';
@@ -387,3 +388,240 @@ describe('Agent Network Tools', () => {
}, /Deposit returned on confirmation/); }, /Deposit returned on confirmation/);
}); });
}); });
// ─── Self-Improvement Tools ─────────────────────────────────────────────────
// This section has never existed in run.test.ts before this change (checked
// the file's full git history: zero "proposal"/"critique"/"endorse" hits and
// zero hits for the old family name this PR retired, at any point) - added
// fresh here, following this file's
// established conventions (buildSignSubmit, assertSuccessOrKnownError,
// walletHasAda gating, settle waits), rather than "rewritten" from a prior
// custodial version that turns out not to exist in this file.
//
// Runs its OWN dedicated agent lifecycle (registers here, deregisters at the
// end of this section) instead of reusing Agent Network Tools' `agentDid`
// above: that section deregisters its agent as ITS OWN last test, which runs
// before this section in file order, so by the time this section starts,
// that DID's registry NFT is already burned. proposal_spend below needs a
// LIVE agent NFT as a reference input, so reusing a dead DID would fail for
// a reason unrelated to what this section tests.
//
// The self-improvement family's "agentDid" wire param is the trailing hex
// asset-name segment only, NOT the full did:vector:agent:{policyId}:
// {nftAssetName} string the registry family's agent_id means (found running
// self-improvement-keyless.test.ts live - see that file's discovery-test
// comment for the full finding). Both forms are captured below.
//
// COST-CONSCIOUS BY DEFAULT: critique and endorse target a LIVE, already-
// existing proposal discovered via browse - any proposal reference works for
// these (gov-build.ts's buildCritique/buildEndorse build a plain lock paid
// to a NEW UTxO; they never spend or validate the target proposal's state),
// at MINIMUM stakes (10 -> 12 AP3X output; 5 -> 7 AP3X output). That is real,
// on-chain coverage of the critique and endorse build_* tools plus register/
// deregister, for about 15 AP3X one-way (register's 10 AP3X deposit round-
// trips) - this is the path actually run live for this task.
//
// The two-step proposal SUBMISSION (lock -> await -> spend -> await) is
// deliberately NOT run by default here: self-improvement-e2e.test.ts already
// proves that exact path live and rigorously (its "THE MONEY ASSERTION"
// test), including the ~6-minute signing window and its documented
// rebuild-once recovery. Re-running the identical ~29 AP3X one-way sequence
// here would spend real testnet AP3X a second time for no new information
// about the underlying build/sign/submit/await mechanism. Set LEGACY_FULL=1
// to additionally exercise it through THIS file's own broader multi-family
// run (useful as a regression check once other families ahead of it in this
// file have already reshaped the wallet's UTxO set) - that branch reuses the
// exact proven build -> sign -> submit -> await sequence from the E2E, typed
// and typechecked, but was NOT executed as part of this task (only the
// default, cheaper path below was run live against the funded wallet) -
// stated plainly here rather than silently claiming coverage that wasn't
// exercised.
describe('Self-Improvement Tools', () => {
let siAgentFullDid: string | null = null;
let siAgentAssetName: string | null = null;
let siProposalTxHash: string | null = null;
let siProposalOutputIndex = 0;
test('vector_build_register_agent (dedicated self-improvement agent) -> sign -> submit', { timeout: 120_000 }, async () => {
if (walletHasAda) {
console.log('Waiting 10s for UTxOs to settle before register...');
await wait(10);
}
const { buildText, submitted } = await buildSignSubmit('vector_build_register_agent', {
changeAddress: walletAddress,
name: `SelfImprovementLegacy-${Date.now()}`,
description: 'run.test.ts self-improvement section agent',
capabilities: ['research'],
framework: 'custom',
endpoint: '',
}, /Agent DID:/);
const didMatch = buildText.match(/did:vector:agent:([a-f0-9]+):([a-f0-9]+)/);
if (submitted && didMatch) {
siAgentFullDid = didMatch[0];
siAgentAssetName = didMatch[2];
console.log(`Registered self-improvement agent DID: ${siAgentFullDid}`);
}
});
test('vector_self_improvement_browse (proposals) - also discovers a live proposal for critique/endorse below', { timeout: 120_000 }, async () => {
if (walletHasAda && siAgentFullDid) {
console.log('Waiting 10s for agent registration to confirm...');
await wait(10);
}
const text = await callTool(ctx.client, 'vector_self_improvement_browse', { entity: 'proposals' });
console.log(text.slice(0, 500));
assertSuccessOrKnownError(text, /Improvement Proposals/, 'vector_self_improvement_browse (proposals)');
const refMatch = text.match(/\*\*UTxO:\*\*\s*([0-9a-f]{64})#(\d+)/);
if (refMatch) {
siProposalTxHash = refMatch[1];
siProposalOutputIndex = parseInt(refMatch[2], 10);
console.log(`Discovered live proposal ref: ${siProposalTxHash}#${siProposalOutputIndex}`);
}
});
test('vector_self_improvement_analyze_metrics', { timeout: 120_000 }, async () => {
const text = await callTool(ctx.client, 'vector_self_improvement_analyze_metrics', {});
console.log(text.slice(0, 500));
assertSuccessOrKnownError(text, /Proposal Metrics/, 'vector_self_improvement_analyze_metrics');
});
test('vector_build_self_improvement_critique (min stake 10) -> sign -> submit', { timeout: 120_000 }, async () => {
if (!siAgentAssetName || !siProposalTxHash) {
console.log('Skipping critique - no agent registered or no live proposal discovered');
return;
}
if (walletHasAda) {
console.log('Waiting 10s for UTxOs to settle before critique...');
await wait(10);
}
await buildSignSubmit('vector_build_self_improvement_critique', {
changeAddress: walletAddress,
agentDid: siAgentAssetName,
proposalTxHash: siProposalTxHash,
proposalOutputIndex: siProposalOutputIndex,
critiqueType: 'Supportive',
stakeApex: 10,
critiqueHash: randomBytes(32).toString('hex'),
storageUri: 'ipfs://legacy-critique-doc',
}, /Unsigned Critique Built/);
});
test('vector_build_self_improvement_endorse (min stake 5) -> sign -> submit', { timeout: 120_000 }, async () => {
if (!siAgentAssetName || !siProposalTxHash) {
console.log('Skipping endorse - no agent registered or no live proposal discovered');
return;
}
if (walletHasAda) {
console.log('Waiting 10s for UTxOs to settle before endorse...');
await wait(10);
}
await buildSignSubmit('vector_build_self_improvement_endorse', {
changeAddress: walletAddress,
agentDid: siAgentAssetName,
proposalTxHash: siProposalTxHash,
proposalOutputIndex: siProposalOutputIndex,
stakeApex: 5,
}, /Unsigned Endorsement Built/);
});
// LEGACY_FULL=1 only - see the section banner comment above for why this
// is gated off by default. Mirrors self-improvement-e2e.test.ts's proven
// lock -> await -> spend -> await sequence exactly (including the
// rebuild-once-on-window-expiry recovery), adapted to this file's
// walletHasAda-tolerant helpers instead of E2E's hard asserts, since this
// section (unlike the dedicated E2E) may run against an unfunded wallet.
test('vector_build_self_improvement_proposal_lock -> await -> vector_build_self_improvement_proposal_spend -> sign -> submit [LEGACY_FULL=1 only]', { timeout: 600_000, skip: process.env.LEGACY_FULL !== '1' }, async () => {
if (!siAgentAssetName) {
console.log('Skipping full proposal submission - no agent registered');
return;
}
if (walletHasAda) {
console.log('Waiting 10s for UTxOs to settle before proposal lock...');
await wait(10);
}
// Inlined rather than buildSignSubmit: this step needs the lock's own
// submitted tx hash directly (to await it, then feed it to
// proposal_spend) - buildSignSubmit only reports whether a submit
// succeeded, not the hash it produced.
const lockBuildText = await callTool(ctx.client, 'vector_build_self_improvement_proposal_lock', {
changeAddress: walletAddress,
agentDid: siAgentAssetName,
proposalType: 'GeneralSuggestion',
stakeApex: 25,
proposalHash: randomBytes(32).toString('hex'),
storageUri: 'ipfs://legacy-proposal-doc',
});
console.log(lockBuildText.slice(0, 500));
assertSuccessOrKnownError(lockBuildText, /step 1 of 2/, 'vector_build_self_improvement_proposal_lock');
const lockCbor = extractCborOrNull(lockBuildText);
if (!/step 1 of 2/.test(lockBuildText) || !lockCbor) {
console.log('Skipping proposal spend - lock did not build (wallet likely unfunded)');
return;
}
const lockSigned = signWithMnemonic(lockCbor, mnemonic);
const lockSubmitText = await callTool(ctx.client, 'vector_submit_transaction', { signedTxCbor: lockSigned.signedCborHex });
if (walletHasAda) {
assert.match(lockSubmitText, /Transaction Submitted/, `proposal lock: expected success on a funded wallet, got: ${lockSubmitText.slice(0, 300)}`);
} else {
assertSuccessOrKnownError(lockSubmitText, /Transaction Submitted/, 'vector_build_self_improvement_proposal_lock (submit)');
}
if (!/Transaction Submitted/.test(lockSubmitText)) {
console.log('Skipping proposal spend - lock did not submit (wallet likely unfunded)');
return;
}
const lockTxHash = lockSigned.txHash;
const awaitLockText = await callTool(ctx.client, 'vector_await_transaction', { txHash: lockTxHash, timeoutSeconds: 240 });
assert.match(awaitLockText, /Transaction Confirmed/, `proposal lock did not confirm: ${awaitLockText.slice(0, 300)}`);
async function buildSpend(): Promise<{ text: string; validTo: string }> {
const text = await callTool(ctx.client, 'vector_build_self_improvement_proposal_spend', {
changeAddress: walletAddress, agentDid: siAgentAssetName!, lockTxHash,
});
const m = text.match(/[Vv]alid until (.*Z)/);
assert.ok(m, `no signing deadline in spend build:\n${text.slice(0, 500)}`);
return { text, validTo: m[1] };
}
let { text: spendBuildText, validTo } = await buildSpend();
console.log(`Proposal spend built, valid until ${validTo}`);
let signed = signWithMnemonic(extractCborOrNull(spendBuildText)!, mnemonic);
let submitText = await callTool(ctx.client, 'vector_submit_transaction', { signedTxCbor: signed.signedCborHex });
if (!/Transaction Submitted/.test(submitText)) {
console.log(`First proposal-spend submit did not report success - rebuilding ONCE (the documented recovery path for a possible signing-window expiry):\n${submitText.slice(0, 500)}`);
({ text: spendBuildText, validTo } = await buildSpend());
console.log(`Proposal spend rebuilt, valid until ${validTo}`);
signed = signWithMnemonic(extractCborOrNull(spendBuildText)!, mnemonic);
submitText = await callTool(ctx.client, 'vector_submit_transaction', { signedTxCbor: signed.signedCborHex });
}
if (walletHasAda) {
assert.match(submitText, /Transaction Submitted/, `proposal spend: expected success on a funded wallet, got: ${submitText.slice(0, 300)}`);
} else {
assertSuccessOrKnownError(submitText, /Transaction Submitted/, 'vector_build_self_improvement_proposal_spend (submit)');
}
if (/Transaction Submitted/.test(submitText)) {
const hashMatch = submitText.match(/Transaction Hash:\s*([0-9a-f]{64})/);
assert.ok(hashMatch, `no tx hash in submit response:\n${submitText.slice(0, 300)}`);
const awaitSpendText = await callTool(ctx.client, 'vector_await_transaction', { txHash: hashMatch[1], timeoutSeconds: 240 });
assert.match(awaitSpendText, /Transaction Confirmed/, `proposal spend did not confirm: ${awaitSpendText.slice(0, 300)}`);
console.log(`Proposal submission complete via the legacy suite (tx ${hashMatch[1]})`);
}
});
test('vector_build_deregister_agent (dedicated self-improvement agent) -> sign -> submit', { timeout: 120_000 }, async () => {
if (!siAgentFullDid) {
console.log('Skipping deregister - no self-improvement agent registered');
return;
}
if (walletHasAda) {
console.log('Waiting 10s for UTxOs to settle before deregister...');
await wait(10);
}
await buildSignSubmit('vector_build_deregister_agent', {
changeAddress: walletAddress,
agent_id: siAgentFullDid,
}, /Deposit returned on confirmation/);
});
});

View File

@@ -0,0 +1,233 @@
import { describe, test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { randomBytes } from 'node:crypto';
import { walletFromSeed } from '@lucid-evolution/lucid';
import { startServer, stopServer, callTool, getMnemonic, wait, ServerContext } from '../setup.ts';
import { signWithMnemonic } from './sign-helper.ts';
// FULL SELF-IMPROVEMENT LIFECYCLE ON LIVE TESTNET - gated. Registers a fresh
// research agent, locks then spends a real improvement proposal through the
// deployed module validator (the two-step keyless flow spec PR 8
// introduced - the first ever keyless-built validated submit to clear it),
// critiques and endorses that proposal, then deregisters the agent.
// One-way cost: 25 (proposal stake) + 2 (proposal module min) + 2 (activity
// token output) + 10 (critique stake) + 2 (critique module min) + 5 (endorse
// stake) + 2 (endorse module min) = 48 AP3X, plus 6 transaction fees. The
// 10 AP3X registration deposit round-trips (excluded from the 48).
// Requires packages/builder/mnemonic.txt (never committed).
// VECTOR_E2E_SUBMIT=1 node --import tsx --test packages/builder/test/integration/self-improvement-e2e.test.ts
const GATE = process.env.VECTOR_E2E_SUBMIT === '1';
function explorerTxLink(hash: string): string {
return `https://vector.testnet.apexscan.org/transaction/${hash}`;
}
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)('self-improvement lifecycle E2E (live testnet, SUBMITS)', () => {
let ctx: ServerContext;
let mnemonic: string;
let walletAddress: string;
// The self-improvement family's "agentDid" wire param is the trailing hex
// asset-name segment only, NOT the full did:vector:agent:{policyId}:
// {nftAssetName} string the registry family's agent_id means (see
// self-improvement-keyless.test.ts's discovery-test comment for the full
// finding). Both forms are needed here: agentFullDid for the registry
// family's build_register_agent/build_deregister_agent calls (agent_id),
// agentAssetName for every self-improvement build_* call (agentDid).
let agentFullDid: string;
let agentAssetName: string;
let lockTxHash: string;
let proposalTxHash: string; // == the confirmed spend tx hash (the tool's own copy: "use THIS transaction's hash as proposalTxHash")
let critiqueTxHash: string;
let endorseTxHash: string;
let balanceBeforeApex = 0;
let balanceAfterApex = 0;
const txLog: Array<{ label: string; hash: string }> = [];
before(async () => {
ctx = await startServer();
mnemonic = getMnemonic();
walletAddress = walletFromSeed(mnemonic, { network: 'Mainnet', accountIndex: 0 }).address;
});
after(async () => { await stopServer(ctx); });
async function getBalanceApex(): Promise<number> {
const text = await callTool(ctx.client, 'vector_get_balance', { address: walletAddress });
const m = text.match(/AP3X Balance:\s*([\d.]+)/);
assert.ok(m, `could not parse balance:\n${text.slice(0, 300)}`);
return parseFloat(m![1]);
}
// build_* (already built by the caller) -> sign locally -> submit -> await
// confirmation. Hard-asserts throughout (no known-error tolerance): this
// suite runs once, deliberately, against a wallet already verified funded
// (~982 AP3X per the plan's recon), so every step is expected to succeed.
async function signSubmitAwait(buildText: string, label: 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, `${label}: submitted hash != locally computed hash`);
const awaitText = await callTool(ctx.client, 'vector_await_transaction', { txHash: submittedHash, timeoutSeconds: 240 });
assert.match(awaitText, /Transaction Confirmed/, `${label}: did not confirm - ${awaitText.slice(0, 300)}`);
txLog.push({ label, hash: submittedHash });
console.log(` [tx] ${label}: ${submittedHash} (${explorerTxLink(submittedHash)})`);
return submittedHash;
}
test('record starting wallet balance', { timeout: 60_000 }, async () => {
balanceBeforeApex = await getBalanceApex();
console.log(`Starting balance: ${balanceBeforeApex} AP3X`);
});
test('register a fresh research agent -> confirm -> DID', { timeout: 420_000 }, async () => {
const buildText = await callTool(ctx.client, 'vector_build_register_agent', {
changeAddress: walletAddress, name: `SelfImprovementE2E-${Date.now()}`,
description: 'self-improvement lifecycle e2e research agent', capabilities: ['research', 'data-extraction'], framework: 'custom', endpoint: '',
});
const didMatch = buildText.match(/did:vector:agent:([a-f0-9]+):([a-f0-9]+)/);
assert.ok(didMatch, `no DID in register build:\n${buildText.slice(0, 500)}`);
agentFullDid = didMatch![0];
agentAssetName = didMatch![2];
await signSubmitAwait(buildText, 'register agent');
console.log(`Registered agent ${agentFullDid}`);
});
test('proposal lock (step 1 of 2) -> confirm', { timeout: 420_000 }, async () => {
await wait(10); // let register's UTxOs settle
const buildText = await callTool(ctx.client, 'vector_build_self_improvement_proposal_lock', {
changeAddress: walletAddress, agentDid: agentAssetName,
proposalType: 'GeneralSuggestion', stakeApex: 25,
proposalHash: randomBytes(32).toString('hex'), storageUri: 'ipfs://e2e-proposal-doc',
});
assert.match(buildText, /step 1 of 2/);
lockTxHash = await signSubmitAwait(buildText, 'proposal lock');
});
test('proposal spend (step 2 of 2) -> confirm -- THE MONEY ASSERTION', { timeout: 420_000 }, async () => {
await wait(10); // let the lock tx settle so proposal_spend's getUtxosByOutRef can find it (this wait happens BEFORE the 6-min signing clock starts - it does not eat into the deadline below)
async function buildSpend(): Promise<{ text: string; validTo: string }> {
const text = await callTool(ctx.client, 'vector_build_self_improvement_proposal_spend', {
changeAddress: walletAddress, agentDid: agentAssetName, lockTxHash,
});
const m = text.match(/[Vv]alid until (.*Z)/);
assert.ok(m, `no signing deadline in spend build:\n${text.slice(0, 500)}`);
const validTo = m[1];
assert.ok(new Date(validTo).getTime() - Date.now() > 120_000, 'deadline must be meaningfully in the future');
return { text, validTo };
}
// Sign+submit IMMEDIATELY after build - the deployed module gives a
// ~6-minute validity window (buildProposalSpend's validToMs) before the
// signature deadline passes. The live submit-failure error shape for a
// window-expiry rejection isn't known ahead of time, so any submit
// failure on the first attempt triggers the ONE documented rebuild retry
// (per the plan); if that retry also fails, the real error propagates
// (fail loud, never swallowed).
let { text: spendBuildText, validTo } = await buildSpend();
console.log(`Proposal spend built, valid until ${validTo}`);
let signed = signWithMnemonic(extractCbor(spendBuildText), mnemonic);
let submitText = await callTool(ctx.client, 'vector_submit_transaction', { signedTxCbor: signed.signedCborHex });
if (!/Transaction Submitted/.test(submitText)) {
console.log(`First proposal-spend submit did not report success - rebuilding ONCE (the documented recovery path for a possible signing-window expiry):\n${submitText.slice(0, 500)}`);
({ text: spendBuildText, validTo } = await buildSpend());
console.log(`Proposal spend rebuilt, valid until ${validTo}`);
signed = signWithMnemonic(extractCbor(spendBuildText), mnemonic);
submitText = await callTool(ctx.client, 'vector_submit_transaction', { signedTxCbor: signed.signedCborHex });
assert.match(submitText, /Transaction Submitted/, `proposal-spend submit failed even after the one documented rebuild retry - failing loud: ${submitText.slice(0, 500)}`);
}
const submittedHash = extractTxHash(submitText);
assert.equal(submittedHash, signed.txHash, 'submitted hash != locally computed hash');
txLog.push({ label: 'proposal spend', hash: submittedHash });
console.log(` [tx] proposal spend: ${submittedHash} (${explorerTxLink(submittedHash)})`);
const awaitText = await callTool(ctx.client, 'vector_await_transaction', { txHash: submittedHash, timeoutSeconds: 240 });
assert.match(awaitText, /Transaction Confirmed/, `proposal spend did not confirm: ${awaitText.slice(0, 300)}`);
proposalTxHash = submittedHash;
console.log(`*** THE MONEY ASSERTION: proposal spend confirmed on-chain (tx ${proposalTxHash}) - the deployed self-improvement module validator accepted a keyless-built, validated submit (script spend + CIP-33 reference scripts + dual mint). The first one ever. ***`);
});
test('the proposal appears on-chain, filtered by our DID, state Open', { timeout: 120_000 }, async () => {
await wait(5);
const text = await callTool(ctx.client, 'vector_self_improvement_browse', { entity: 'proposals', proposerDid: agentAssetName });
assert.match(text, /GeneralSuggestion Proposal \(Open\)/, `expected our Open GeneralSuggestion proposal:\n${text.slice(0, 800)}`);
assert.ok(text.includes(`${proposalTxHash}#0`), `expected the continuing proposal UTxO ${proposalTxHash}#0 in the browse response:\n${text.slice(0, 800)}`);
});
test('critique (Supportive, stake 10) -> confirm -> visible via browse', { timeout: 420_000 }, async () => {
await wait(5);
const buildText = await callTool(ctx.client, 'vector_build_self_improvement_critique', {
changeAddress: walletAddress, agentDid: agentAssetName, proposalTxHash, proposalOutputIndex: 0,
critiqueType: 'Supportive', stakeApex: 10, critiqueHash: randomBytes(32).toString('hex'), storageUri: 'ipfs://e2e-critique-doc',
});
critiqueTxHash = await signSubmitAwait(buildText, 'critique');
await wait(5);
const browseText = await callTool(ctx.client, 'vector_self_improvement_browse', { entity: 'critiques', proposalTxHash });
assert.match(browseText, /# Critiques \([1-9]\d* found\)/, `expected a non-zero critique count in the browse header:\n${browseText.slice(0, 300)}`);
assert.match(browseText, /Supportive Critique/, `expected our critique in the browse response:\n${browseText.slice(0, 800)}`);
assert.match(browseText, new RegExp(`${critiqueTxHash}#\\d+`), `expected our critique's own UTxO in the browse response:\n${browseText.slice(0, 800)}`);
});
test('endorse (stake 5) -> confirm -> visible via browse', { timeout: 420_000 }, async () => {
await wait(5);
const buildText = await callTool(ctx.client, 'vector_build_self_improvement_endorse', {
changeAddress: walletAddress, agentDid: agentAssetName, proposalTxHash, proposalOutputIndex: 0,
stakeApex: 5,
});
endorseTxHash = await signSubmitAwait(buildText, 'endorse');
await wait(5);
const browseText = await callTool(ctx.client, 'vector_self_improvement_browse', { entity: 'endorsements', proposalTxHash });
assert.match(browseText, /# Endorsements \([1-9]\d* found\)/, `expected a non-zero endorsement count in the browse header:\n${browseText.slice(0, 300)}`);
assert.match(browseText, /Endorsement/, `expected our endorsement in the browse response:\n${browseText.slice(0, 800)}`);
assert.match(browseText, new RegExp(`${endorseTxHash}#\\d+`), `expected our endorsement's own UTxO in the browse response:\n${browseText.slice(0, 800)}`);
});
// Known/accepted risk, same shape as run.test.ts's Agent Network Tools
// section: if an earlier step in this chain fails before this one runs,
// the agent's 10 AP3X deposit stays locked on-chain - nothing in this file
// reclaims it automatically.
test('deregister the agent -> confirm -> deposit returned', { timeout: 420_000 }, async () => {
await wait(10);
const buildText = await callTool(ctx.client, 'vector_build_deregister_agent', {
changeAddress: walletAddress, agent_id: agentFullDid,
});
assert.match(buildText, /Deposit returned on confirmation/);
await signSubmitAwait(buildText, 'deregister agent');
});
test('final balance reconciles to the expected one-way spend + evidence summary', { timeout: 60_000 }, async () => {
await wait(5);
balanceAfterApex = await getBalanceApex();
const delta = balanceBeforeApex - balanceAfterApex;
console.log('\n=== Self-improvement lifecycle E2E evidence ===');
console.log(` agent DID: ${agentFullDid}`);
for (const { label, hash } of txLog) {
console.log(` ${label.padEnd(16)} ${hash} ${explorerTxLink(hash)}`);
}
console.log(` Balance before: ${balanceBeforeApex.toFixed(6)} AP3X`);
console.log(` Balance after: ${balanceAfterApex.toFixed(6)} AP3X`);
console.log(` Delta (one-way): ${delta.toFixed(6)} AP3X`);
console.log(' Expected one-way: 25 (proposal stake) + 2 (proposal module min) + 2 (activity token output)');
console.log(' + 10 (critique stake) + 2 (critique module min) + 5 (endorse stake)');
console.log(' + 2 (endorse module min) = 48 AP3X, plus 6 transaction fees.');
console.log(' The 10 AP3X registration deposit round-trips (excluded from the 48).');
// Sanity bound, not a fee-exact pin (protocol fees vary per tx shape):
// wide enough to tolerate normal fee variance across 6 txs (~1-3 AP3X),
// tight enough to catch a gross error (a dropped deposit refund or a
// duplicated stake would blow well past either bound).
assert.ok(delta > 47 && delta < 54, `expected the wallet down roughly 48-53 AP3X (stakes + module minimums + 6 tx fees), got ${delta.toFixed(6)}`);
});
});

View File

@@ -0,0 +1,197 @@
import { describe, test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { randomBytes } from 'node:crypto';
import { CML } from '@lucid-evolution/lucid';
import { startServer, stopServer, callTool, ServerContext } from '../setup.ts';
import {
scriptHashToAddress, GOV_CRITIQUE_SPEND_HASH, GOV_ENDORSEMENT_SPEND_HASH, GOV_PROPOSAL_SPEND_HASH,
parseProposalDatum,
} from '../../src/vector/gov-build.ts';
// Tier-1 self-improvement integration: LIVE testnet, ZERO secrets. Build-only -
// nothing is signed, nothing is submitted, no value moves. critique, endorse,
// and proposal_lock are all plain locks (a single unsigned payment to a
// script address, no script spend), so this tier can fully exercise their
// datum-construction path against real chain data (a live-discovered agent
// DID and a live-discovered proposal UTxO) with zero risk. The validated
// spend+mint path (buildProposalSpend's POSITIVE case) requires a CONFIRMED
// lock transaction to reference - that is E2E territory
// (self-improvement-e2e.test.ts); this file only covers proposal_spend's
// build-time failure path (no secrets needed to prove a not-found error).
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);
}
function decodeTx(cborHex: string) {
return CML.Transaction.from_cbor_hex(cborHex);
}
function findOutputTo(tx: ReturnType<typeof decodeTx>, address: string) {
const outs = tx.body().outputs();
for (let i = 0; i < outs.len(); i++) {
const o = outs.get(i);
if (o.address().to_bech32() === address) return o;
}
return null;
}
describe('self-improvement keyless tier-1 (live testnet, no secrets)', () => {
// NOTE (found running this live, not a typo): the self-improvement family's
// "agentDid" wire param is NOT the full `did:vector:agent:{policyId}:
// {nftAssetName}` string the registry family's agent_id means (that one
// gets split via registry-build.ts's parseDid()) - it is JUST the trailing
// hex asset-name segment (schema description: "Agent DID (hex) - the asset
// name from Agent Registry NFT"; confirmed in gov-build.ts's
// buildProposalSpend, which concatenates it directly onto
// AGENT_REGISTRY_POLICY to form an asset unit). This is pre-existing,
// inherited verbatim from the custodial code (verified against commit
// 8162673, before Task 5's keyless rewrite) - not something introduced or
// fixable in this task. Passing the FULL DID string here throws
// "Could not serialize the data: ... hex string expected, got non-hex
// character \"di\" at index 0" (Lucid's Data.to treats the string field as
// a ByteArray - reproduced live before this comment was written). Flagged
// as a naming-consistency minor in the task report; tests below use the
// correct (asset-name-only) value.
let liveAgentAssetName: string | null = null;
let liveProposalTxHash: string | null = null;
let liveProposalOutputIndex = 0;
test('discover a live agent DID to reference in builds', { 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?\n${text.slice(0, 500)}`);
liveAgentAssetName = m![2];
});
test('vector_self_improvement_browse (proposals) returns live proposals and yields a real UTxO ref', { timeout: 120_000 }, async () => {
const text = await callTool(ctx.client, 'vector_self_improvement_browse', { entity: 'proposals' });
const countMatch = text.match(/Improvement Proposals \((\d+) found\)/);
assert.ok(countMatch, `no proposal count header:\n${text.slice(0, 500)}`);
assert.ok(Number(countMatch![1]) >= 1, `expected at least 1 live proposal (recon showed 25+), got ${countMatch![1]}`);
const refMatch = text.match(/\*\*UTxO:\*\*\s*([0-9a-f]{64})#(\d+)/);
assert.ok(refMatch, `no proposal UTxO ref found in the response:\n${text.slice(0, 800)}`);
liveProposalTxHash = refMatch![1];
liveProposalOutputIndex = parseInt(refMatch![2], 10);
});
test('vector_self_improvement_analyze_metrics renders proposal metrics and treasury', { timeout: 120_000 }, async () => {
const text = await callTool(ctx.client, 'vector_self_improvement_analyze_metrics', {});
assert.match(text, /# Proposal Metrics/);
assert.match(text, /## Proposals/);
assert.match(text, /## Treasury/);
// Markdown-bold label: "**Balance:** 387.000000 AP3X" - two literal '*'
// sit between the label and the value (same gotcha documented in
// registry-keyless.test.ts), so \s* alone right after "Balance:" doesn't
// cover it. Verified against the actual live response before widening.
assert.match(text, /Balance:\*{0,2}\s*[\d.]+\s*AP3X/);
});
test('vector_build_self_improvement_critique builds an unsigned lock against the live proposal (stake 10 -> 12 AP3X)', { timeout: 120_000 }, async () => {
assert.ok(liveAgentAssetName && liveProposalTxHash, 'needs the DID and proposal ref from earlier tests');
const text = await callTool(ctx.client, 'vector_build_self_improvement_critique', {
changeAddress: OWN_ADDRESS, agentDid: liveAgentAssetName!, proposalTxHash: liveProposalTxHash!, proposalOutputIndex: liveProposalOutputIndex,
critiqueType: 'Supportive', stakeApex: 10, critiqueHash: 'c1'.repeat(32), storageUri: 'ipfs://tier1-placeholder',
});
assertBuildNotSubmitted(text);
const tx = decodeTx(extractCbor(text));
const vkeys = tx.witness_set().vkeywitnesses();
assert.ok(vkeys === undefined || vkeys.len() === 0, 'critique build must be unsigned');
const critiqueAddress = scriptHashToAddress(GOV_CRITIQUE_SPEND_HASH);
const out = findOutputTo(tx, critiqueAddress);
assert.ok(out, `no output to the critique address ${critiqueAddress}`);
assert.equal(out!.amount().coin(), 12_000_000n, 'output must be stake (10 AP3X) + 2 AP3X module minimum');
});
test('vector_build_self_improvement_endorse builds an unsigned lock against the live proposal (stake 5 -> 7 AP3X)', { timeout: 120_000 }, async () => {
assert.ok(liveAgentAssetName && liveProposalTxHash, 'needs the DID and proposal ref from earlier tests');
const text = await callTool(ctx.client, 'vector_build_self_improvement_endorse', {
changeAddress: OWN_ADDRESS, agentDid: liveAgentAssetName!, proposalTxHash: liveProposalTxHash!, proposalOutputIndex: liveProposalOutputIndex,
stakeApex: 5,
});
assertBuildNotSubmitted(text);
const tx = decodeTx(extractCbor(text));
const vkeys = tx.witness_set().vkeywitnesses();
assert.ok(vkeys === undefined || vkeys.len() === 0, 'endorse build must be unsigned');
const endorseAddress = scriptHashToAddress(GOV_ENDORSEMENT_SPEND_HASH);
const out = findOutputTo(tx, endorseAddress);
assert.ok(out, `no output to the endorsement address ${endorseAddress}`);
assert.equal(out!.amount().coin(), 7_000_000n, 'output must be stake (5 AP3X) + 2 AP3X module minimum');
});
test('vector_build_self_improvement_proposal_lock builds an unsigned lock (GeneralSuggestion, stake 25 -> 27 AP3X) and announces the two-step handoff', { timeout: 120_000 }, async () => {
assert.ok(liveAgentAssetName, 'needs the DID from discovery');
const text = await callTool(ctx.client, 'vector_build_self_improvement_proposal_lock', {
changeAddress: OWN_ADDRESS, agentDid: liveAgentAssetName!,
proposalType: 'GeneralSuggestion', stakeApex: 25,
proposalHash: 'a5'.repeat(32), storageUri: 'ipfs://tier1-placeholder-proposal',
});
assertBuildNotSubmitted(text);
// The two-step handoff must be explicit in the copy: this is step 1 of 2,
// and the response must name the counterpart tool + the lockTxHash param
// the agent needs to complete the submission after confirmation.
assert.match(text, /step 1 of 2/);
assert.match(text, /vector_build_self_improvement_proposal_spend[\s\S]{0,120}lockTxHash/,
'response must name the counterpart tool alongside the lockTxHash param it expects');
const tx = decodeTx(extractCbor(text));
const vkeys = tx.witness_set().vkeywitnesses();
assert.ok(vkeys === undefined || vkeys.len() === 0, 'proposal lock build must be unsigned');
const proposalAddress = scriptHashToAddress(GOV_PROPOSAL_SPEND_HASH);
const out = findOutputTo(tx, proposalAddress);
assert.ok(out, `no output to the proposal address ${proposalAddress}`);
assert.equal(out!.amount().coin(), 27_000_000n, 'output must be stake (25 AP3X) + 2 AP3X module minimum');
assert.ok(out!.datum() && out!.datum()!.kind() === 1, 'continuing datum must be inline');
const datumCbor = out!.datum()!.as_datum()!.to_cbor_hex();
const parsed = parseProposalDatum(datumCbor);
assert.ok(parsed, 'built proposal datum failed to parse back');
assert.equal(parsed!.state, 'Open');
assert.equal(parsed!.proposalType, 'GeneralSuggestion');
assert.equal(parsed!.proposerDid, liveAgentAssetName);
assert.equal(parsed!.stakeAmount, 25_000_000);
});
test('vector_build_self_improvement_proposal_spend on an unknown lockTxHash fails fast with the await-first error', { timeout: 30_000 }, async () => {
assert.ok(liveAgentAssetName, 'needs the DID from discovery');
const t0 = Date.now();
const bogusLockTxHash = randomBytes(32).toString('hex');
const text = await callTool(ctx.client, 'vector_build_self_improvement_proposal_spend', {
changeAddress: OWN_ADDRESS, agentDid: liveAgentAssetName!, lockTxHash: bogusLockTxHash,
});
// One real UTxO lookup happens here (getUtxosByOutRef against Ogmios),
// so this cannot be as fast as the fully-local changeAddress check below
// - bounded generously to still catch a hang/retry-storm regression.
// Bound kept strictly below the test's own 30_000ms timeout so this
// assertion's specific message can actually fire - at 30_000 it was
// unreachable (the test runner's own timeout would win first).
assert.ok(Date.now() - t0 < 15_000, 'a not-found outref lookup should not be slow');
assert.match(text, /Locked proposal UTxO not found/);
assert.match(text, /vector_await_transaction/, 'error should point the agent at the await-first recovery step');
});
test('a bogus changeAddress fails fast with a clear local validation error (<5s)', { timeout: 30_000 }, async () => {
const t0 = Date.now();
const text = await callTool(ctx.client, 'vector_build_self_improvement_critique', {
changeAddress: 'addr1garbage', agentDid: liveAgentAssetName || 'ab'.repeat(32), proposalTxHash: liveProposalTxHash || '11'.repeat(32),
proposalOutputIndex: 0, critiqueType: 'Supportive', stakeApex: 10, critiqueHash: 'c1'.repeat(32), storageUri: 'ipfs://x',
});
assert.match(text, /Invalid change address/);
assert.ok(Date.now() - t0 < 5_000, 'address validation should not have hit the network');
});
});

View File

@@ -121,7 +121,7 @@ describe('buildRegisterAgent (real validator, offline)', () => {
test('builds an evaluable register tx: mint +1, deposit output, inline datum, owner signer', async () => { test('builds an evaluable register tx: mint +1, deposit output, inline datum, owner signer', async () => {
const r = await buildRegisterAgent(lucid, { const r = await buildRegisterAgent(lucid, {
changeAddress: OWN_ADDRESS, name: 'UnitAgent', description: 'unit test agent', name: 'UnitAgent', description: 'unit test agent',
capabilities: ['testing', 'ci'], framework: 'custom', endpoint: '', capabilities: ['testing', 'ci'], framework: 'custom', endpoint: '',
}); });
const tx = decodeTx(r.txCbor); const tx = decodeTx(r.txCbor);
@@ -172,16 +172,23 @@ describe('buildRegisterAgent (real validator, offline)', () => {
test('rejects invalid endpoint and empty capability before building', async () => { test('rejects invalid endpoint and empty capability before building', async () => {
await assert.rejects(buildRegisterAgent(lucid, { await assert.rejects(buildRegisterAgent(lucid, {
changeAddress: OWN_ADDRESS, name: 'X', description: '', capabilities: [], framework: 'c', endpoint: 'not a url', name: 'X', description: '', capabilities: [], framework: 'c', endpoint: 'not a url',
}), /Invalid endpoint URL/); }), /Invalid endpoint URL/);
await assert.rejects(buildRegisterAgent(lucid, { await assert.rejects(buildRegisterAgent(lucid, {
changeAddress: OWN_ADDRESS, name: 'X', description: '', capabilities: ['ok', ' '], framework: 'c', endpoint: '', name: 'X', description: '', capabilities: ['ok', ' '], framework: 'c', endpoint: '',
}), /capability/i); }), /capability/i);
}); });
test('rejects a script-credential changeAddress with a clear error', async () => { test('rejects a script-credential changeAddress with a clear error', async () => {
await assert.rejects(buildRegisterAgent(lucid, { // Pre-refactor this test passed a script-address changeAddress param that
changeAddress: getRegistryAddress(), name: 'X', description: '', capabilities: [], framework: 'c', endpoint: '', // disagreed with `lucid`'s own wallet (OWN_ADDRESS) - exactly the
// mismatch class this refactor makes unrepresentable. Now it must
// construct a lucid instance FOR the script address itself, so the
// derived changeAddress (== lucid.wallet().address()) is the one that
// paymentKeyHashOf rejects.
const scriptLucid = await lucidForAddress(new FixtureProvider([REGISTRY_UTXO]), getRegistryAddress());
await assert.rejects(buildRegisterAgent(scriptLucid, {
name: 'X', description: '', capabilities: [], framework: 'c', endpoint: '',
}), /verification key|payment key/i); }), /verification key|payment key/i);
}); });
}); });
@@ -191,7 +198,7 @@ describe('buildMessageAgent (resolves via fixture registry UTxO)', () => {
const provider = new FixtureProvider([...FIXTURE_UTXOS, REGISTRY_UTXO]); const provider = new FixtureProvider([...FIXTURE_UTXOS, REGISTRY_UTXO]);
const lucid = await lucidForAddress(provider, OWN_ADDRESS); const lucid = await lucidForAddress(provider, OWN_ADDRESS);
const r = await buildMessageAgent(lucid, provider, { const r = await buildMessageAgent(lucid, provider, {
changeAddress: OWN_ADDRESS, agentId: AGENT_DID, messageType: 'inquiry', payload: 'unit ping', agentId: AGENT_DID, messageType: 'inquiry', payload: 'unit ping',
}); });
assert.equal(r.agentName, 'FixtureAgent'); assert.equal(r.agentName, 'FixtureAgent');
const tx = decodeTx(r.txCbor); const tx = decodeTx(r.txCbor);
@@ -219,7 +226,7 @@ describe('buildMessageAgent (resolves via fixture registry UTxO)', () => {
const provider = new FixtureProvider(FIXTURE_UTXOS); // no registry UTxO const provider = new FixtureProvider(FIXTURE_UTXOS); // no registry UTxO
const lucid = await lucidForAddress(provider, OWN_ADDRESS); const lucid = await lucidForAddress(provider, OWN_ADDRESS);
await assert.rejects(buildMessageAgent(lucid, provider, { await assert.rejects(buildMessageAgent(lucid, provider, {
changeAddress: OWN_ADDRESS, agentId: AGENT_DID, messageType: 'inquiry', payload: 'x', agentId: AGENT_DID, messageType: 'inquiry', payload: 'x',
}), /Agent not found/); }), /Agent not found/);
}); });
}); });
@@ -254,7 +261,7 @@ describe('registry owner ops (real validator, offline)', () => {
const provider = ownedProvider(); const provider = ownedProvider();
const lucid = await lucidForAddress(provider, OWN_ADDRESS); const lucid = await lucidForAddress(provider, OWN_ADDRESS);
const r = await buildUpdateAgent(lucid, provider, { const r = await buildUpdateAgent(lucid, provider, {
changeAddress: OWN_ADDRESS, agentId: AGENT_DID, description: 'updated by unit test', agentId: AGENT_DID, description: 'updated by unit test',
}); });
assert.equal(r.op, 'update'); assert.equal(r.op, 'update');
assert.equal(r.detail, 'description'); assert.equal(r.detail, 'description');
@@ -281,7 +288,7 @@ describe('registry owner ops (real validator, offline)', () => {
const provider = ownedProvider(); const provider = ownedProvider();
const lucid = await lucidForAddress(provider, OWN_ADDRESS); const lucid = await lucidForAddress(provider, OWN_ADDRESS);
await assert.rejects(buildUpdateAgent(lucid, provider, { await assert.rejects(buildUpdateAgent(lucid, provider, {
changeAddress: OWN_ADDRESS, agentId: AGENT_DID, agentId: AGENT_DID,
}), /At least one field/); }), /At least one field/);
}); });
@@ -299,7 +306,7 @@ describe('registry owner ops (real validator, offline)', () => {
]); ]);
const strangerLucid = await lucidForAddress(strangerProvider, stranger); const strangerLucid = await lucidForAddress(strangerProvider, stranger);
await assert.rejects(buildUpdateAgent(strangerLucid, strangerProvider, { await assert.rejects(buildUpdateAgent(strangerLucid, strangerProvider, {
changeAddress: stranger, agentId: AGENT_DID, description: 'hijack attempt', agentId: AGENT_DID, description: 'hijack attempt',
}), /Ownership check failed/); }), /Ownership check failed/);
}); });
@@ -309,7 +316,7 @@ describe('registry owner ops (real validator, offline)', () => {
const newOwnerHash = '11'.repeat(28); const newOwnerHash = '11'.repeat(28);
const newOwner = credentialToAddress('Mainnet', { type: 'Key', hash: newOwnerHash }); const newOwner = credentialToAddress('Mainnet', { type: 'Key', hash: newOwnerHash });
const r = await buildTransferAgent(lucid, provider, { const r = await buildTransferAgent(lucid, provider, {
changeAddress: OWN_ADDRESS, agentId: AGENT_DID, newOwnerAddress: newOwner, agentId: AGENT_DID, newOwnerAddress: newOwner,
}); });
assert.equal(r.op, 'transfer'); assert.equal(r.op, 'transfer');
const tx = decodeTx(r.txCbor); const tx = decodeTx(r.txCbor);
@@ -324,7 +331,7 @@ describe('registry owner ops (real validator, offline)', () => {
assert.equal(p.name, 'FixtureAgent'); // everything else preserved assert.equal(p.name, 'FixtureAgent'); // everything else preserved
// script-credential new owner refused // script-credential new owner refused
await assert.rejects(buildTransferAgent(lucid, provider, { await assert.rejects(buildTransferAgent(lucid, provider, {
changeAddress: OWN_ADDRESS, agentId: AGENT_DID, newOwnerAddress: getRegistryAddress(), agentId: AGENT_DID, newOwnerAddress: getRegistryAddress(),
}), /verification key/i); }), /verification key/i);
}); });
@@ -332,7 +339,7 @@ describe('registry owner ops (real validator, offline)', () => {
const provider = ownedProvider(); const provider = ownedProvider();
const lucid = await lucidForAddress(provider, OWN_ADDRESS); const lucid = await lucidForAddress(provider, OWN_ADDRESS);
const r = await buildDeregisterAgent(lucid, provider, { const r = await buildDeregisterAgent(lucid, provider, {
changeAddress: OWN_ADDRESS, agentId: AGENT_DID, agentId: AGENT_DID,
}); });
assert.equal(r.op, 'deregister'); assert.equal(r.op, 'deregister');
assert.equal(r.agentName, 'FixtureAgent'); assert.equal(r.agentName, 'FixtureAgent');
@@ -346,6 +353,25 @@ describe('registry owner ops (real validator, offline)', () => {
} }
assert.ok(tx.body().mint(), 'no mint (burn) field on the deregister tx'); assert.ok(tx.body().mint(), 'no mint (burn) field on the deregister tx');
}); });
test('deregister picks the LARGEST pure-AP3X wallet UTxO as the extra input', async () => {
const small = { txHash: 'a1'.repeat(32), outputIndex: 0, address: OWN_ADDRESS, assets: { lovelace: 3_000_000n } };
const large = { txHash: 'a2'.repeat(32), outputIndex: 0, address: OWN_ADDRESS, assets: { lovelace: 900_000_000n } };
// small listed FIRST so first-match selection would pick it
const provider = new FixtureProvider([small, large, REGISTRY_UTXO]);
const lucid = await lucidForAddress(provider, OWN_ADDRESS);
const r = await buildDeregisterAgent(lucid, provider, { agentId: AGENT_DID });
const tx = CML.Transaction.from_cbor_hex(r.txCbor);
const ins = tx.body().inputs();
let sawLarge = false, sawSmall = false;
for (let i = 0; i < ins.len(); i++) {
const h = ins.get(i).transaction_id().to_hex();
if (h === 'a2'.repeat(32)) sawLarge = true;
if (h === 'a1'.repeat(32)) sawSmall = true;
}
assert.ok(sawLarge, 'largest pure-AP3X UTxO must be the chosen extra input');
assert.ok(!sawSmall, 'the smaller pure-AP3X UTxO must not be chosen over the larger');
});
}); });
// Correction round (see task-3-report.md): differential testing against the // Correction round (see task-3-report.md): differential testing against the
@@ -368,7 +394,7 @@ describe('registry owner ops - value preservation & deregister floor (correction
const provider = new FixtureProvider([...FIXTURE_UTXOS, overfunded]); const provider = new FixtureProvider([...FIXTURE_UTXOS, overfunded]);
const lucid = await lucidForAddress(provider, OWN_ADDRESS); const lucid = await lucidForAddress(provider, OWN_ADDRESS);
const r = await buildUpdateAgent(lucid, provider, { const r = await buildUpdateAgent(lucid, provider, {
changeAddress: OWN_ADDRESS, agentId: AGENT_DID, description: 'overfunded update', agentId: AGENT_DID, description: 'overfunded update',
}); });
assert.equal(r.op, 'update'); assert.equal(r.op, 'update');
const tx = decodeTx(r.txCbor); const tx = decodeTx(r.txCbor);
@@ -388,7 +414,7 @@ describe('registry owner ops - value preservation & deregister floor (correction
const newOwnerHash = '22'.repeat(28); const newOwnerHash = '22'.repeat(28);
const newOwner = credentialToAddress('Mainnet', { type: 'Key', hash: newOwnerHash }); const newOwner = credentialToAddress('Mainnet', { type: 'Key', hash: newOwnerHash });
const r = await buildTransferAgent(lucid, provider, { const r = await buildTransferAgent(lucid, provider, {
changeAddress: OWN_ADDRESS, agentId: AGENT_DID, newOwnerAddress: newOwner, agentId: AGENT_DID, newOwnerAddress: newOwner,
}); });
assert.equal(r.op, 'transfer'); assert.equal(r.op, 'transfer');
const tx = decodeTx(r.txCbor); const tx = decodeTx(r.txCbor);
@@ -423,6 +449,15 @@ describe('registry owner ops - value preservation & deregister floor (correction
.complete(); .complete();
const tx = decodeTx(completed.toCBOR()); 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'); assert.equal(tx.body().inputs().len(), 1, 'sole input - Lucid should not need to pull in any extra wallet UTxO here');
// Pin: buildDeregisterAgent's returned `detail` is the ACTUAL spent
// registry UTxO lovelace (25 AP3X here), not the MIN_AP3X_DEPOSIT
// constant. Goes through buildDeregisterAgent itself (unlike the direct
// build above), which is safe here because the provider also carries
// FIXTURE_UTXOS, so the extra fee UTxO that path unconditionally pulls in
// is available.
const r = await buildDeregisterAgent(lucid, provider, { agentId: AGENT_DID });
assert.equal(r.detail, '25000000', 'detail must be the spent registry UTxO lovelace, not the constant');
}); });
test('deregister throws an actionable error (not a validator crash) when the wallet has no plain AP3X-only UTxO', async () => { test('deregister throws an actionable error (not a validator crash) when the wallet has no plain AP3X-only UTxO', async () => {
@@ -433,7 +468,7 @@ describe('registry owner ops - value preservation & deregister floor (correction
const provider = new FixtureProvider([tokenOnlyUtxo, REGISTRY_UTXO]); const provider = new FixtureProvider([tokenOnlyUtxo, REGISTRY_UTXO]);
const lucid = await lucidForAddress(provider, OWN_ADDRESS); const lucid = await lucidForAddress(provider, OWN_ADDRESS);
await assert.rejects(buildDeregisterAgent(lucid, provider, { await assert.rejects(buildDeregisterAgent(lucid, provider, {
changeAddress: OWN_ADDRESS, agentId: AGENT_DID, agentId: AGENT_DID,
}), /AP3X-only UTxO/); }), /AP3X-only UTxO/);
}); });
}); });

View File

@@ -81,6 +81,7 @@ describe('signWithMnemonic preserves an existing script witness set (hermetic pi
test('signing a script-bearing tx keeps the vkey witness AND the original scripts/redeemers/datums, and leaves the body hash unchanged', () => { 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 unsignedCborHex = buildSyntheticScriptBearingTx();
const unsignedBodyHashHex = CML.hash_transaction(CML.Transaction.from_cbor_hex(unsignedCborHex).body()).to_hex(); const unsignedBodyHashHex = CML.hash_transaction(CML.Transaction.from_cbor_hex(unsignedCborHex).body()).to_hex();
const redeemersBefore = CML.Transaction.from_cbor_hex(unsignedCborHex).witness_set().redeemers()!.to_cbor_hex();
const { signedCborHex, txHash } = signWithMnemonic(unsignedCborHex, TEST_ONLY_PUBLIC_MNEMONIC); const { signedCborHex, txHash } = signWithMnemonic(unsignedCborHex, TEST_ONLY_PUBLIC_MNEMONIC);
@@ -106,6 +107,10 @@ describe('signWithMnemonic preserves an existing script witness set (hermetic pi
redeemers!.as_map_redeemer_key_to_redeemer_val()?.len(), 1, redeemers!.as_map_redeemer_key_to_redeemer_val()?.len(), 1,
'expected exactly 1 redeemer to survive signing', 'expected exactly 1 redeemer to survive signing',
); );
assert.equal(
redeemers!.to_cbor_hex(), redeemersBefore,
'redeemer CBOR must survive signing byte-identically',
);
const datums = ws.plutus_datums(); const datums = ws.plutus_datums();
assert.ok(datums && datums.len() === 1, `expected the original plutus datum to survive signing, got ${datums?.len() ?? 0}`); assert.ok(datums && datums.len() === 1, `expected the original plutus datum to survive signing, got ${datums?.len() ?? 0}`);

View File

@@ -5,7 +5,7 @@ import { resolve } from 'node:path';
import { startServer, stopServer, ServerContext } from '../setup.ts'; import { startServer, stopServer, ServerContext } from '../setup.ts';
/** /**
* Snapshot of the current tool surface (23 tools) for the non-custodial split * Snapshot of the current tool surface (24 tools) for the non-custodial split
* series (see docs/architecture/non-custodial-split.md). PR 6 (Task 5) is the * series (see docs/architecture/non-custodial-split.md). PR 6 (Task 5) is the
* PR that took this list from 25 to 23: it deleted `vector_get_address` * PR that took this list from 25 to 23: it deleted `vector_get_address`
* outright, along with the tool that reported per-transaction/daily * outright, along with the tool that reported per-transaction/daily
@@ -13,7 +13,14 @@ import { startServer, stopServer, ServerContext } from '../setup.ts';
* keyless equivalent — spend status is gone because the builder no longer * keyless equivalent — spend status is gone because the builder no longer
* holds keys to limit) as part of making the remaining query tools * holds keys to limit) as part of making the remaining query tools
* (`vector_get_utxos`, `vector_get_transaction_history`, `vector_dry_run`) * (`vector_get_utxos`, `vector_get_transaction_history`, `vector_dry_run`)
* keyless. PRs 2, 6, 7 and 8 each deliberately restructure and rename tool * keyless. Spec PR 8 (Task 5) took it from 23 to 24: the self-improvement
* family went keyless, removing `vector_self_improvement_submit_proposal`
* and renaming `_critique`/`_endorse` to `vector_build_self_improvement_*`,
* and splitting the old single-shot submit into two build_* tools
* (`_proposal_lock` + `_proposal_spend`) since the deployed module validator
* requires a confirmed lock before the spend+mint step can reference it.
* This was the last custodial family - the builder now holds no key material
* anywhere. PRs 2, 6, 7 and 8 each deliberately restructure and rename tool
* families, and each of those PRs is expected to update this list * families, and each of those PRs is expected to update this list
* deliberately, not accidentally. No wallet, no network beyond localhost — * deliberately, not accidentally. No wallet, no network beyond localhost —
* this never needs a mnemonic. * this never needs a mnemonic.
@@ -25,6 +32,10 @@ const EXPECTED_TOOLS = [
'vector_build_interact_contract', 'vector_build_interact_contract',
'vector_build_message_agent', 'vector_build_message_agent',
'vector_build_register_agent', 'vector_build_register_agent',
'vector_build_self_improvement_critique',
'vector_build_self_improvement_endorse',
'vector_build_self_improvement_proposal_lock',
'vector_build_self_improvement_proposal_spend',
'vector_build_send_apex', 'vector_build_send_apex',
'vector_build_send_tokens', 'vector_build_send_tokens',
'vector_build_transaction', 'vector_build_transaction',
@@ -38,9 +49,6 @@ const EXPECTED_TOOLS = [
'vector_get_utxos', 'vector_get_utxos',
'vector_self_improvement_analyze_metrics', 'vector_self_improvement_analyze_metrics',
'vector_self_improvement_browse', 'vector_self_improvement_browse',
'vector_self_improvement_critique',
'vector_self_improvement_endorse',
'vector_self_improvement_submit_proposal',
'vector_submit_transaction', 'vector_submit_transaction',
]; ];
@@ -92,11 +100,11 @@ after(async () => {
}); });
describe('tool inventory smoke test', () => { describe('tool inventory smoke test', () => {
test('exposes exactly 23 tools', () => { test('exposes exactly 24 tools', () => {
assert.equal( assert.equal(
actualNames.length, actualNames.length,
23, 24,
`expected 23 tools, got ${actualNames.length}: ${JSON.stringify(actualNames)}` `expected 24 tools, got ${actualNames.length}: ${JSON.stringify(actualNames)}`
); );
}); });
@@ -132,7 +140,7 @@ describe('tool schema snapshot', () => {
}); });
// One assertion per tool (rather than a single deepStrictEqual over the whole // One assertion per tool (rather than a single deepStrictEqual over the whole
// 23-tool map) so a failure names the specific tool and shows a readable // 24-tool map) so a failure names the specific tool and shows a readable
// diff of just that tool's schema, instead of "objects differ" over an // diff of just that tool's schema, instead of "objects differ" over an
// unreadable combined structure. // unreadable combined structure.
for (const toolName of Object.keys(schemaSnapshot).sort()) { for (const toolName of Object.keys(schemaSnapshot).sort()) {

View File

@@ -243,6 +243,220 @@
"$schema": "http://json-schema.org/draft-07/schema#" "$schema": "http://json-schema.org/draft-07/schema#"
} }
}, },
"vector_build_self_improvement_critique": {
"description": "Build an UNSIGNED transaction that submits a critique on an improvement proposal (Supportive, Opposing, or Amendment). Requires staking at least 10 AP3X. Provide critiqueDocument for automatic IPFS upload, or critiqueHash and storageUri manually. Takes no key material. Sign it locally with vector_signer_sign, broadcast with vector_submit_transaction, then confirm with vector_await_transaction. Find a proposalTxHash to critique with vector_self_improvement_browse.",
"inputSchema": {
"type": "object",
"properties": {
"changeAddress": {
"description": "Your wallet address (holds the stake AP3X; receives change). Get it from vector_signer_get_address.",
"type": "string"
},
"agentDid": {
"description": "Agent identity: the trailing 64-hex asset-name segment of your DID (NOT the full did:vector:agent:... string)",
"type": "string"
},
"proposalTxHash": {
"description": "TX hash of the proposal UTxO to critique",
"type": "string"
},
"proposalOutputIndex": {
"description": "Output index of the proposal UTxO",
"default": 0,
"type": "number"
},
"critiqueDocument": {
"description": "Full critique document as JSON string. Uploaded to IPFS via Filebase; hash and CID computed automatically.",
"type": "string"
},
"critiqueHash": {
"description": "blake2b_256 hash of critique document (64 hex chars). Required if critiqueDocument is not provided.",
"type": "string"
},
"critiqueType": {
"description": "Type of critique",
"type": "string",
"enum": [
"Supportive",
"Opposing",
"Amendment"
]
},
"storageUri": {
"description": "Off-chain storage URI for the critique document. Required if critiqueDocument is not provided.",
"type": "string"
},
"stakeApex": {
"description": "AP3X to stake (minimum 10)",
"type": "number",
"minimum": 10
}
},
"required": [
"changeAddress",
"agentDid",
"proposalTxHash",
"critiqueType",
"stakeApex"
],
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
"vector_build_self_improvement_endorse": {
"description": "Build an UNSIGNED transaction that endorses an improvement proposal by staking at least 5 AP3X. Endorsements signal support to the Foundation Council and are weighted by stake amount. Takes no key material. Sign it locally with vector_signer_sign, broadcast with vector_submit_transaction, then confirm with vector_await_transaction. Find a proposalTxHash to endorse with vector_self_improvement_browse.",
"inputSchema": {
"type": "object",
"properties": {
"changeAddress": {
"description": "Your wallet address (holds the stake AP3X; receives change). Get it from vector_signer_get_address.",
"type": "string"
},
"agentDid": {
"description": "Agent identity: the trailing 64-hex asset-name segment of your DID (NOT the full did:vector:agent:... string)",
"type": "string"
},
"proposalTxHash": {
"description": "TX hash of the proposal UTxO to endorse",
"type": "string"
},
"proposalOutputIndex": {
"description": "Output index of the proposal UTxO",
"default": 0,
"type": "number"
},
"stakeApex": {
"description": "AP3X to stake as endorsement (minimum 5)",
"type": "number",
"minimum": 5
}
},
"required": [
"changeAddress",
"agentDid",
"proposalTxHash",
"stakeApex"
],
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
"vector_build_self_improvement_proposal_lock": {
"description": "Build an UNSIGNED transaction that locks an improvement-proposal stake (step 1 of 2). Requires staking at least 25 AP3X. Provide proposalDocument for automatic IPFS upload, or proposalHash and storageUri manually. Takes no key material. Sign it locally with vector_signer_sign, broadcast with vector_submit_transaction, then confirm with vector_await_transaction. After this confirms, call this tool's counterpart, vector_build_self_improvement_proposal_spend, with the lock transaction hash to complete the submission.",
"inputSchema": {
"type": "object",
"properties": {
"changeAddress": {
"description": "Your wallet address (holds the stake AP3X; receives change). Get it from vector_signer_get_address.",
"type": "string"
},
"agentDid": {
"description": "Agent identity: the trailing 64-hex asset-name segment of your DID (NOT the full did:vector:agent:... string)",
"type": "string"
},
"proposalType": {
"description": "Category of the proposal",
"type": "string",
"enum": [
"ParameterChange",
"TreasurySpend",
"ProtocolUpgrade",
"GameActivation",
"GeneralSuggestion"
]
},
"stakeApex": {
"description": "AP3X to stake (minimum 25)",
"type": "number",
"minimum": 25
},
"typeParams": {
"description": "Type-specific parameters (required for ParameterChange and TreasurySpend)",
"type": "object",
"properties": {
"paramName": {
"type": "string"
},
"currentValue": {
"type": "number"
},
"proposedValue": {
"type": "number"
},
"amount": {
"type": "number"
},
"recipientDescription": {
"type": "string"
},
"upgradeHash": {
"type": "string"
},
"gameId": {
"type": "number"
}
}
},
"priority": {
"description": "Priority level (Emergency requires higher stake and reputation)",
"default": "Standard",
"type": "string",
"enum": [
"Standard",
"Emergency"
]
},
"proposalDocument": {
"description": "Full proposal document as JSON string. Uploaded to IPFS via Filebase; hash and CID computed automatically. If provided, proposalHash and storageUri are ignored.",
"type": "string"
},
"proposalHash": {
"description": "blake2b_256 hash of proposal document (64 hex chars). Required if proposalDocument is not provided.",
"type": "string"
},
"storageUri": {
"description": "Off-chain storage URI for the full proposal (IPFS CID or OriginTrail UAL). Required if proposalDocument is not provided.",
"type": "string"
}
},
"required": [
"changeAddress",
"agentDid",
"proposalType",
"stakeApex"
],
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
"vector_build_self_improvement_proposal_spend": {
"description": "Build an UNSIGNED transaction that completes an improvement-proposal submission (step 2 of 2): spends the locked stake through the module validator and mints the proposal and activity tokens. Requires the confirmed lock transaction hash from vector_build_self_improvement_proposal_lock. Takes no key material. Sign it locally with vector_signer_sign, broadcast with vector_submit_transaction, then confirm with vector_await_transaction.",
"inputSchema": {
"type": "object",
"properties": {
"changeAddress": {
"description": "The SAME wallet address used to build the proposal lock. Get it from vector_signer_get_address.",
"type": "string"
},
"agentDid": {
"description": "Agent identity: the trailing 64-hex asset-name segment of your DID (NOT the full did:vector:agent:... string) - must match the proposer DID in the locked proposal",
"type": "string"
},
"lockTxHash": {
"description": "The CONFIRMED transaction hash from the proposal lock step",
"type": "string"
},
"lockOutputIndex": {
"description": "Output index of the locked proposal UTxO (default: 0)",
"default": 0,
"type": "number"
}
},
"required": [
"changeAddress",
"agentDid",
"lockTxHash"
],
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
"vector_build_send_apex": { "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.", "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": { "inputSchema": {
@@ -589,7 +803,7 @@
} }
}, },
"vector_self_improvement_analyze_metrics": { "vector_self_improvement_analyze_metrics": {
"description": "Analyze proposal metrics: proposal activity, adoption rate, treasury health, and engagement statistics. Read-only - no mnemonic needed.", "description": "Analyze proposal metrics: proposal activity, adoption rate, treasury health, and engagement statistics. Read-only, takes no key material.",
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -646,189 +860,6 @@
"$schema": "http://json-schema.org/draft-07/schema#" "$schema": "http://json-schema.org/draft-07/schema#"
} }
}, },
"vector_self_improvement_critique": {
"description": "Submit a critique on an improvement proposal. Critiques can support, oppose, or propose amendments. Requires staking AP3X. Provide critiqueDocument (JSON string) for automatic IPFS upload, or critiqueHash + storageUri manually.",
"inputSchema": {
"type": "object",
"properties": {
"mnemonic": {
"description": "15 or 24-word BIP39 mnemonic for the wallet",
"type": "string"
},
"agentDid": {
"description": "Agent DID (hex)",
"type": "string"
},
"proposalTxHash": {
"description": "TX hash of the proposal UTxO to critique",
"type": "string"
},
"proposalOutputIndex": {
"description": "Output index of the proposal UTxO",
"default": 0,
"type": "number"
},
"critiqueDocument": {
"description": "Full critique document as JSON string. Uploaded to IPFS via Filebase; hash and CID computed automatically.",
"type": "string"
},
"critiqueHash": {
"description": "blake2b_256 hash of critique document (64 hex chars). Required if critiqueDocument is not provided.",
"type": "string"
},
"critiqueType": {
"description": "Type of critique",
"type": "string",
"enum": [
"Supportive",
"Opposing",
"Amendment"
]
},
"storageUri": {
"description": "Off-chain storage URI for the critique document. Required if critiqueDocument is not provided.",
"type": "string"
},
"stakeApex": {
"description": "AP3X to stake (minimum 10)",
"type": "number",
"minimum": 10
}
},
"required": [
"mnemonic",
"agentDid",
"proposalTxHash",
"critiqueType",
"stakeApex"
],
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
"vector_self_improvement_endorse": {
"description": "Endorse an improvement proposal by staking AP3X. Endorsements signal support to the Foundation Council and are weighted by stake amount.",
"inputSchema": {
"type": "object",
"properties": {
"mnemonic": {
"description": "15 or 24-word BIP39 mnemonic for the wallet",
"type": "string"
},
"agentDid": {
"description": "Agent DID (hex)",
"type": "string"
},
"proposalTxHash": {
"description": "TX hash of the proposal UTxO to endorse",
"type": "string"
},
"proposalOutputIndex": {
"description": "Output index of the proposal UTxO",
"default": 0,
"type": "number"
},
"stakeApex": {
"description": "AP3X to stake as endorsement (minimum 5)",
"type": "number",
"minimum": 5
}
},
"required": [
"mnemonic",
"agentDid",
"proposalTxHash",
"stakeApex"
],
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
"vector_self_improvement_submit_proposal": {
"description": "Submit an improvement proposal to the Vector Self-Improvement Module. Requires staking AP3X. Provide proposalDocument (JSON string) for automatic IPFS upload via Filebase and blake2b_256 hashing, OR provide proposalHash and storageUri manually.",
"inputSchema": {
"type": "object",
"properties": {
"mnemonic": {
"description": "15 or 24-word BIP39 mnemonic for the wallet",
"type": "string"
},
"agentDid": {
"description": "Agent DID (hex) - the asset name from Agent Registry NFT",
"type": "string"
},
"proposalDocument": {
"description": "Full proposal document as JSON string. Uploaded to IPFS via Filebase; hash and CID computed automatically. If provided, proposalHash and storageUri are ignored.",
"type": "string"
},
"proposalHash": {
"description": "blake2b_256 hash of proposal document (64 hex chars). Required if proposalDocument is not provided.",
"type": "string"
},
"proposalType": {
"description": "Category of the proposal",
"type": "string",
"enum": [
"ParameterChange",
"TreasurySpend",
"ProtocolUpgrade",
"GameActivation",
"GeneralSuggestion"
]
},
"storageUri": {
"description": "Off-chain storage URI for the full proposal (IPFS CID or OriginTrail UAL). Required if proposalDocument is not provided.",
"type": "string"
},
"stakeApex": {
"description": "AP3X to stake (minimum 25)",
"type": "number",
"minimum": 25
},
"typeParams": {
"description": "Type-specific parameters (required for ParameterChange and TreasurySpend)",
"type": "object",
"properties": {
"paramName": {
"type": "string"
},
"currentValue": {
"type": "number"
},
"proposedValue": {
"type": "number"
},
"amount": {
"type": "number"
},
"recipientDescription": {
"type": "string"
},
"upgradeHash": {
"type": "string"
},
"gameId": {
"type": "number"
}
}
},
"priority": {
"description": "Priority level (Emergency requires higher stake and reputation)",
"default": "Standard",
"type": "string",
"enum": [
"Standard",
"Emergency"
]
}
},
"required": [
"mnemonic",
"agentDid",
"proposalType",
"stakeApex"
],
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
"vector_submit_transaction": { "vector_submit_transaction": {
"description": "Submit an already-signed transaction to Vector. Use this to broadcast CBOR signed elsewhere (for example by a local signer). Does not sign anything.", "description": "Submit an already-signed transaction to Vector. Use this to broadcast CBOR signed elsewhere (for example by a local signer). Does not sign anything.",
"inputSchema": { "inputSchema": {

View File

@@ -1,26 +1,5 @@
// Vector-specific type definitions // Vector-specific type definitions
export interface SpendLimits {
perTransaction: number; // lovelace
daily: number; // lovelace
}
export interface SpendStatus {
perTransactionLimit: number;
dailyLimit: number;
dailySpent: number;
dailyRemaining: number;
resetTime: string;
}
export interface AuditEntry {
timestamp: string;
txHash: string;
amountLovelace: number;
recipient: string;
action: string;
}
// --- Build Transaction --- // --- Build Transaction ---
export interface TxOutput { export interface TxOutput {
@@ -154,3 +133,30 @@ export interface UnsignedTxResult {
fee: string; fee: string;
feeAda: string; feeAda: string;
} }
// --- Keyless self-improvement builds (spec PR 8) ---
export interface VectorBuildStakeResult extends VectorUnsignedBuildResult {
op: 'critique' | 'endorse';
proposalRef: string; // "txHash#index"
stakeLovelace: string;
scriptAddress: string;
ipfsCid?: string;
documentHash?: string;
storageUri?: string; // set by buildCritique only - buildEndorse has no document/URI concept
}
export interface VectorBuildProposalLockResult extends VectorUnsignedBuildResult {
scriptAddress: string;
stakeLovelace: string;
storageUri: string;
proposalHash: string;
ipfsCid?: string;
}
export interface VectorBuildProposalSpendResult extends VectorUnsignedBuildResult {
proposalTokenName: string;
activityTokenName: string;
scriptAddress: string;
validToMs: number; // the signing deadline the agent must beat
}

View File

@@ -144,30 +144,31 @@ over-counts, which refuses too much rather than too little.
- **Prompt injection is bounded, not eliminated.** Policy refuses out-of-limit - **Prompt injection is bounded, not eliminated.** Policy refuses out-of-limit
transactions, but an injected agent can still cause any transaction *within* policy. transactions, but an injected agent can still cause any transaction *within* policy.
Recipient allowlisting is the next lever. Recipient allowlisting is the next lever.
- **Pairing this signer with the hosted builder now removes the custody exposure it was - **Pairing this signer with the builder in this codebase now removes the custody exposure it
designed to fix — for the wallet, transaction, smart-contract, and agent-registry family.** was designed to fix, for every family the builder exposes.** The signer's own four tools are
The signer's own four tools are complete and independently verified end to end against live complete and independently verified end to end against live chain data
chain data (`test/integration/roundtrip.test.ts`), and the hosted *builder's* tools in these (`test/integration/roundtrip.test.ts`), and this codebase's *builder* tools are keyless across
families are keyless: `vector_build_send_apex`, `vector_build_send_tokens`, the board: every `build_*` tool, across the wallet/transaction, smart-contract, agent-registry,
`vector_build_transaction`, `vector_build_deploy_contract`, `vector_build_interact_contract`, and self-improvement families, takes a wallet *address* and never a mnemonic, and
`vector_build_register_agent`, `vector_build_update_agent`, `vector_build_transfer_agent`, `vector_submit_transaction` / `vector_await_transaction` complete the round trip. The
`vector_build_deregister_agent`, and `vector_build_message_agent` all take a wallet *address*, build → sign → submit → await pipeline is E2E-proven on Vector testnet across all four
never a mnemonic, and `vector_submit_transaction` / `vector_await_transaction` complete the families: an AP3X self-send (`packages/builder/test/integration/keyless-e2e.test.ts`), a full
round trip. The pipeline — hosted keyless build → sign → submit → await — is E2E-proven on register → update → deregister agent lifecycle with the 10 AP3X deposit round-tripped exactly
Vector testnet for both: a payment self-send (`registry-e2e.test.ts`), and a full improvement-proposal lock → spend sequence through the
(`packages/builder/test/integration/keyless-e2e.test.ts`) and a full register → update → deployed self-improvement module validator, followed by a critique and an endorsement
deregister agent lifecycle with the 10 AP3X deposit round-tripped exactly (`self-improvement-e2e.test.ts`) - the first keyless-built, validated proposal submission that
(`registry-e2e.test.ts`). Both sign with the same CML primitives `sign.ts` uses (hash the validator has ever accepted. All three E2E suites sign with the same CML primitives `sign.ts`
body, produce a vkey witness, attach it — explicitly a stand-in for calling 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 `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 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` 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 and the smoke tests above). **Nothing is left open in the code: this was the last custodial
still require your mnemonic as a call parameter, so pairing this signer with the builder does family.** The non-custodial migration described in
not yet cover them. That migration is tracked separately in
[`docs/architecture/non-custodial-split.md`](../../docs/architecture/non-custodial-split.md) [`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 that one is complete in this codebase. It is not yet complete on every deployment: the mainnet hosted
family accurately. instance stays on its last custodial image until a deliberate cutover deploy ships (see that
document's rollout section). Until then, pair this signer with a self-hosted or testnet
builder if you want the guarantee above to actually hold - not the mainnet one.
## Running it ## Running it
@@ -193,10 +194,12 @@ example):
} }
``` ```
Register it alongside the hosted builder so an agent has both sets of tools available. For the Register it alongside the builder so an agent has both sets of tools available. This pairing
wallet, transaction, smart-contract, and agent-registry family, this pairing keeps your keeps your mnemonic off any server running this release, for every family it exposes: wallet,
mnemonic off the hosted server today — both halves are ready. See the last bullet under Known transaction, smart-contract, agent-registry, and self-improvement alike - as long as that
limitations above for what's still open: the Self-Improvement Module. server is actually running this release. The mainnet hosted instance is not, yet: see the
deployment status note in the root README before assuming this covers it. See the last bullet
under Known limitations above for the full evidence.
## Testing ## Testing

View File

@@ -0,0 +1,122 @@
// packages/signer/test/sign-script-tx.test.ts
//
// Hermetic pin: proves the SIGNER's own signTransaction (src/sign.ts) signs a
// script-bearing transaction without dropping its plutus_v3_scripts,
// redeemers, or plutus_datums. Standing follow-up closed here: sign.test.ts's
// FIXTURE_CBOR carries no script witnesses at all, so the signer suite never
// exercised this path before this file - "the signer suite never signs a
// script tx".
//
// Mirrors packages/builder/test/sign-helper.test.ts's synthetic
// script-bearing tx construction (same shape: one input, one output, one
// PlutusV3 script, one redeemer, one datum in the witness set) so the two
// "does signing preserve a script witness set" pins - builder's
// signWithMnemonic path and the signer's own signTransaction(cbor, bech32Key)
// path - stay directly comparable, even though the two packages sign through
// different call surfaces (a mnemonic vs. a raw bech32 private key).
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import * as CML from '@anastasia-labs/cardano-multiplatform-lib-nodejs';
import { signTransaction } from '../src/sign.ts';
import { MnemonicKeySource } from '../src/keysource.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))`). Same constant
// sign-helper.test.ts uses on the builder side. 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
* signTransaction must preserve, without a live network, a real validator,
* or Lucid's TransactionBuilder. Identical shape to
* sign-helper.test.ts's buildSyntheticScriptBearingTx, parameterised on the
* output address so the caller can supply one derived via the signer's own
* MnemonicKeySource rather than a second, separate walletFromSeed call.
*/
function buildSyntheticScriptBearingTx(address: string): string {
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('signTransaction preserves an existing script witness set (hermetic pin, signer side)', () => {
test('signing a script-bearing tx keeps the vkey witness AND the original scripts/redeemers/datums, and leaves the body hash unchanged', () => {
// Same canonical derivation MnemonicKeySource.load() uses elsewhere in
// this package (walletFromSeed under Mainnet, account index 0) - reused
// here rather than a second, separate call, so this fixture's output
// address and its signing key come from one source of truth.
const { privateKeyBech32, address } = new MnemonicKeySource(TEST_ONLY_PUBLIC_MNEMONIC).load();
const unsignedCborHex = buildSyntheticScriptBearingTx(address);
const unsignedBodyHashHex = CML.hash_transaction(CML.Transaction.from_cbor_hex(unsignedCborHex).body()).to_hex();
const redeemersBefore = CML.Transaction.from_cbor_hex(unsignedCborHex).witness_set().redeemers()!.to_cbor_hex();
const { signedCborHex, txHashHex } = signTransaction(unsignedCborHex, privateKeyBech32);
assert.equal(txHashHex, 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',
);
assert.equal(
redeemers!.to_cbor_hex(), redeemersBefore,
'redeemer CBOR must survive signing byte-identically',
);
const datums = ws.plutus_datums();
assert.ok(datums && datums.len() === 1, `expected the original plutus datum to survive signing, got ${datums?.len() ?? 0}`);
});
});