test(builder): self-improvement tier-1 + gated lifecycle E2E - keyless two-step proposal proven on-chain

Adds the three remaining integration layers for spec PR 8 (self-improvement
family keyless): a tier-1 suite exercising all four build_* tools against the
live testnet module with zero secrets (build-only, nothing signed or
submitted), a gated full lifecycle E2E that actually submits, and a fresh
self-improvement section in the legacy run.test.ts suite.

THE MONEY ASSERTION: the gated E2E's proposal-spend step confirmed on-chain
(tx 312ffdef8beebfb8ba94868b0bf289b5fa7b4f8f9f743eefbf6aaef7360d3246) - the
deployed module validator accepted a keyless-built, validated submit (script
spend + CIP-33 reference scripts + dual mint) for the first time ever. First
attempt succeeded; the documented rebuild-once-on-window-expiry recovery was
never triggered. Full lifecycle (register -> proposal lock -> proposal spend
-> critique -> endorse -> deregister), 9/9 tests, wallet reconciled to the
lovelace: 982.280960 -> 932.515125 AP3X (49.765835 one-way; the 10 AP3X
registration deposit round-tripped; 5 of 6 fees recovered exactly from
transaction history, the 6th closes the arithmetic within 0.001 AP3X).

Tier-1 (self-improvement-keyless.test.ts): 8/8 live, no secrets - browse and
analyze_metrics render real chain data, critique/endorse/proposal_lock build
unsigned CBOR at the exact expected AP3X amounts with datum round-trips,
proposal_spend's negative path (unknown lockTxHash) fails fast pointing at
vector_await_transaction, bogus changeAddress fails fast locally.

Legacy suite (run.test.ts): run.test.ts has never had a self-improvement
section in its history (checked full git log before writing anything, per
the dispatch's own fallback instruction) - added one fresh, following the
file's established conventions, with its own dedicated agent lifecycle
(Agent Network Tools' agent is already deregistered by the time this section
runs, so reusing its DID would reference a burned NFT). Default path
(register -> browse -> analyze -> critique -> endorse -> deregister, ~19-20
AP3X one-way) ran live, 23/23 pass. The full two-step proposal submission is
written behind LEGACY_FULL=1 (typechecked, not executed this run - the E2E
above already proves that exact path live; re-running it here would spend
~29 AP3X a second time for no new information), correctly skipped (1 skip).
24 tests total across all 6 describe blocks, 0 failures.

Found and fixed two issues before shipping, neither a src/ change: the
self-improvement family's agentDid wire param is the hex asset-name segment
only (not the full did:vector:agent:... string the registry family's
agent_id means) - pre-existing since before spec PR 8, confirmed against
commit 8162673; and a Markdown-bold-asterisk regex gap in my own
analyze_metrics assertion (same class of gotcha registry-keyless.test.ts
already documents). Content-rule self-review caught and fixed a literal
"governance" string in a code comment and seven new em dashes before commit.

Gates: typecheck 0 (3 packages), unit 392/392, smoke 27/27 (24 tools, 0
drift), auth 12/12, boundary 5/5 - all identical to the entering baseline
(test files only, no src/ changes). Full report with all tx hashes, explorer
links, and the wallet reconciliation at .superpowers/sdd/task-6-report.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
FilipBl4gojevic
2026-07-30 17:35:15 +02:00
parent daa3fd6a43
commit c304d417ef
3 changed files with 661 additions and 0 deletions

View File

@@ -1,5 +1,6 @@
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';
@@ -387,3 +388,240 @@ describe('Agent Network Tools', () => {
}, /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 funds 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,229 @@
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)}`);
return { text, validTo: m[1] };
}
// 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, /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, /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,194 @@
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 funds move. 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.
assert.ok(Date.now() - t0 < 30_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');
});
});