diff --git a/packages/builder/test/integration/keyless-e2e.test.ts b/packages/builder/test/integration/keyless-e2e.test.ts index 5d4d34c..5829c1c 100644 --- a/packages/builder/test/integration/keyless-e2e.test.ts +++ b/packages/builder/test/integration/keyless-e2e.test.ts @@ -1,8 +1,9 @@ // packages/builder/test/integration/keyless-e2e.test.ts import { describe, test, before, after } from 'node:test'; import assert from 'node:assert/strict'; -import { CML, walletFromSeed } from '@lucid-evolution/lucid'; +import { walletFromSeed } from '@lucid-evolution/lucid'; import { startServer, stopServer, callTool, getMnemonic, ServerContext } from '../setup.ts'; +import { signWithMnemonic } from './sign-helper.ts'; // Full non-custodial pipeline against the live testnet: // keyless build (hosted tool) -> inline CML sign (this process, standing in @@ -31,7 +32,8 @@ describe('keyless E2E: build -> sign -> submit -> await', { skip: !RUN }, () => after(async () => { await stopServer(ctx); }); test('the four-call non-custodial flow lands a self-send on-chain', { timeout: 300_000 }, async () => { - const { address, paymentKey } = walletFromSeed(getMnemonic(), { network: 'Mainnet', accountIndex: 0 }); + const mnemonic = getMnemonic(); + const { address } = walletFromSeed(mnemonic, { network: 'Mainnet', accountIndex: 0 }); // 1. keyless build (hosted) const buildText = await callTool(ctx.client, 'vector_build_send_apex', { @@ -40,21 +42,12 @@ describe('keyless E2E: build -> sign -> submit -> await', { skip: !RUN }, () => const unsignedCbor = extractCbor(buildText); // 2. sign locally (stand-in for vector_signer_sign) - const tx = CML.Transaction.from_cbor_hex(unsignedCbor); - const txHash = CML.hash_transaction(tx.body()); - const sk = CML.PrivateKey.from_bech32(paymentKey); - const vkeyWitness = CML.make_vkey_witness(txHash, sk); - const witnessSet = CML.TransactionWitnessSet.new(); - const vkeys = CML.VkeywitnessList.new(); - vkeys.add(vkeyWitness); - witnessSet.set_vkeywitnesses(vkeys); - const signed = CML.Transaction.new(tx.body(), witnessSet, tx.is_valid(), tx.auxiliary_data()); - const signedCbor = signed.to_cbor_hex(); + const { signedCborHex: signedCbor, txHash } = signWithMnemonic(unsignedCbor, mnemonic); // 3. submit (hosted) const submitText = await callTool(ctx.client, 'vector_submit_transaction', { signedTxCbor: signedCbor }); const submittedHash = extractTxHash(submitText); - assert.equal(submittedHash, txHash.to_hex(), 'submitted hash differs from the locally computed body hash'); + assert.equal(submittedHash, txHash, 'submitted hash differs from the locally computed body hash'); // 4. await confirmation (hosted) const awaitText = await callTool(ctx.client, 'vector_await_transaction', { diff --git a/packages/builder/test/integration/registry-e2e.test.ts b/packages/builder/test/integration/registry-e2e.test.ts new file mode 100644 index 0000000..c77b361 --- /dev/null +++ b/packages/builder/test/integration/registry-e2e.test.ts @@ -0,0 +1,83 @@ +import { describe, test, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { startServer, stopServer, callTool, getMnemonic, wait, ServerContext } from '../setup.ts'; +import { signWithMnemonic } from './sign-helper.ts'; +import { walletFromSeed } from '@lucid-evolution/lucid'; + +// FULL REGISTRY LIFECYCLE ON LIVE TESTNET - gated. Registers a real agent, +// updates it, deregisters it. Net cost ~= 3 tx fees (~0.6 AP3X); the 10 AP3X +// deposit round-trips. Requires packages/builder/mnemonic.txt (never committed). +// VECTOR_E2E_SUBMIT=1 node --import tsx --test packages/builder/test/integration/registry-e2e.test.ts +const GATE = process.env.VECTOR_E2E_SUBMIT === '1'; + +function extractCbor(text: string): string { + const m = text.match(/```\n([0-9a-f]+)\n```/); + assert.ok(m, `no CBOR block:\n${text.slice(0, 500)}`); + return m![1]; +} +function extractTxHash(text: string): string { + const m = text.match(/Transaction Hash:\s*([0-9a-f]{64})/); + assert.ok(m, `no tx hash:\n${text.slice(0, 500)}`); + return m![1]; +} + +(GATE ? describe : describe.skip)('registry lifecycle E2E (live testnet, SUBMITS)', () => { + let ctx: ServerContext; + let mnemonic: string; + let walletAddress: string; + let agentDid: string; + + before(async () => { + ctx = await startServer(); + mnemonic = getMnemonic(); + walletAddress = walletFromSeed(mnemonic, { network: 'Mainnet', accountIndex: 0 }).address; + }); + after(async () => { await stopServer(ctx); }); + + async function signSubmitAwait(buildText: string): Promise { + const { signedCborHex, txHash } = signWithMnemonic(extractCbor(buildText), mnemonic); + const submitText = await callTool(ctx.client, 'vector_submit_transaction', { signedTxCbor: signedCborHex }); + const submittedHash = extractTxHash(submitText); + assert.equal(submittedHash, txHash, 'submitted hash != locally computed hash'); + const awaitText = await callTool(ctx.client, 'vector_await_transaction', { txHash: submittedHash, timeoutSeconds: 240 }); + assert.match(awaitText, /Transaction Confirmed/); + return submittedHash; + } + + test('register -> confirm -> profile readable', { timeout: 420_000 }, async () => { + const buildText = await callTool(ctx.client, 'vector_build_register_agent', { + changeAddress: walletAddress, name: `E2EAgent-${Date.now()}`, + description: 'registry e2e lifecycle agent', capabilities: ['testing', 'e2e'], framework: 'custom', endpoint: '', + }); + const didMatch = buildText.match(/(did:vector:agent:[a-f0-9]+:[a-f0-9]+)/); + assert.ok(didMatch, 'no DID in register build'); + agentDid = didMatch![1]; + await signSubmitAwait(buildText); + await wait(5); + const profile = await callTool(ctx.client, 'vector_get_agent_profile', { agent_id: agentDid }); + assert.match(profile, /registry e2e lifecycle agent/); + }); + + test('update -> confirm -> profile shows the change', { timeout: 420_000 }, async () => { + await wait(10); // let register's UTxOs settle + const buildText = await callTool(ctx.client, 'vector_build_update_agent', { + changeAddress: walletAddress, agent_id: agentDid, description: 'updated by registry e2e', + }); + await signSubmitAwait(buildText); + await wait(5); + const profile = await callTool(ctx.client, 'vector_get_agent_profile', { agent_id: agentDid }); + assert.match(profile, /updated by registry e2e/); + }); + + test('deregister -> confirm -> agent gone', { timeout: 420_000 }, async () => { + await wait(10); + const buildText = await callTool(ctx.client, 'vector_build_deregister_agent', { + changeAddress: walletAddress, agent_id: agentDid, + }); + assert.match(buildText, /Deposit returned on confirmation/); + await signSubmitAwait(buildText); + await wait(5); + const profile = await callTool(ctx.client, 'vector_get_agent_profile', { agent_id: agentDid }); + assert.match(profile, /Agent not found|Failed to get agent profile/); + }); +}); diff --git a/packages/builder/test/integration/registry-keyless.test.ts b/packages/builder/test/integration/registry-keyless.test.ts new file mode 100644 index 0000000..c64a81f --- /dev/null +++ b/packages/builder/test/integration/registry-keyless.test.ts @@ -0,0 +1,90 @@ +import { describe, test, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { CML } from '@lucid-evolution/lucid'; +import { startServer, stopServer, callTool, ServerContext } from '../setup.ts'; + +// Tier-1 registry integration: LIVE testnet, ZERO secrets. Build-only - nothing +// is signed, nothing is submitted, no funds move. The register build runs the +// REAL registry validator server-side during .complete(). +const OWN_ADDRESS = 'addr1qylasu4y34ccwt8hv55tkswa9fthtck9xtppvc4y03kwhaztd0jlxdzk72zj8mgy6x4269v9gytkgffh3k8d3l8987yqu5c35z'; + +let ctx: ServerContext; +before(async () => { ctx = await startServer(); }); +after(async () => { await stopServer(ctx); }); + +function extractCbor(text: string): string { + const m = text.match(/```\n([0-9a-f]+)\n```/); + assert.ok(m, `no CBOR block in response:\n${text.slice(0, 500)}`); + return m![1]; +} +function assertBuildNotSubmitted(text: string) { + assert.doesNotMatch(text, /(? { + let liveDid: string | null = null; + + test('discover returns live agents and yields a DID to work with', { timeout: 120_000 }, async () => { + const text = await callTool(ctx.client, 'vector_discover_agents', { limit: 3 }); + const m = text.match(/(did:vector:agent:[a-f0-9]+:[a-f0-9]+)/); + assert.ok(m, 'no DID found in discovery output - is the testnet registry empty?'); + liveDid = m![1]; + }); + + test('build_register_agent builds an evaluable unsigned registration', { timeout: 120_000 }, async () => { + const text = await callTool(ctx.client, 'vector_build_register_agent', { + changeAddress: OWN_ADDRESS, name: `Tier1Agent-${process.pid}`, + description: 'tier-1 build-only agent', capabilities: ['testing'], framework: 'custom', endpoint: '', + }); + assertBuildNotSubmitted(text); + // Brief specified /Agent DID:\s*did:vector:agent:/ - the live response is + // Markdown ("**Agent DID:** did:vector:agent:..."), so two literal '*' + // sit between the label and the value that \s* alone doesn't cover. + // Verified against the actual tier-1 live response (register-agent build + // succeeded; only this assertion's regex was too strict) before widening + // it, per Task 5's task-5-report.md self-review. + assert.match(text, /Agent DID:\*{0,2}\s*did:vector:agent:/); + const tx = CML.Transaction.from_cbor_hex(extractCbor(text)); + const vkeys = tx.witness_set().vkeywitnesses(); + assert.ok(vkeys === undefined || vkeys.len() === 0, 'register build must be unsigned'); + assert.ok(tx.body().mint(), 'register tx has no mint field'); + }); + + test('build_message_agent builds against a live agent', { timeout: 120_000 }, async () => { + assert.ok(liveDid, 'needs the DID from discovery'); + const text = await callTool(ctx.client, 'vector_build_message_agent', { + changeAddress: OWN_ADDRESS, agent_id: liveDid!, message_type: 'inquiry', payload: 'tier-1 build-only ping', + }); + assertBuildNotSubmitted(text); + const tx = CML.Transaction.from_cbor_hex(extractCbor(text)); + const aux = tx.auxiliary_data(); + assert.ok(aux && aux.metadata() && aux.metadata()!.get(674n), 'label-674 metadata missing'); + }); + + test('get_agent_profile resolves the live agent', { timeout: 120_000 }, async () => { + assert.ok(liveDid); + const text = await callTool(ctx.client, 'vector_get_agent_profile', { agent_id: liveDid! }); + assert.match(text, /Agent Profile/); + assert.match(text, /Registry UTxO:/); + }); + + test('a bogus changeAddress fails fast with a clear validation error', { timeout: 30_000 }, async () => { + const t0 = Date.now(); + const text = await callTool(ctx.client, 'vector_build_register_agent', { + changeAddress: 'addr1garbage', name: 'X', description: '', capabilities: [], framework: 'c', endpoint: '', + }); + assert.match(text, /Invalid change address/); + assert.ok(Date.now() - t0 < 5_000, 'validation should not have hit the network'); + }); + + test('non-owner update is refused at build time', { timeout: 120_000 }, async () => { + assert.ok(liveDid); + const text = await callTool(ctx.client, 'vector_build_update_agent', { + changeAddress: OWN_ADDRESS, agent_id: liveDid!, description: 'not mine', + }); + // The discovered agent belongs to another wallet (verified during planning: + // live datum owners differ from OWN_ADDRESS's key). Expect the ownership fail-fast. + assert.match(text, /Ownership check failed|Failed to build agent update/); + }); +}); diff --git a/packages/builder/test/integration/run.test.ts b/packages/builder/test/integration/run.test.ts index 89bb122..8d00368 100644 --- a/packages/builder/test/integration/run.test.ts +++ b/packages/builder/test/integration/run.test.ts @@ -2,6 +2,7 @@ import { describe, test, before, after } from 'node:test'; import assert from 'node:assert/strict'; import { walletFromSeed } from '@lucid-evolution/lucid'; import { startServer, stopServer, callTool, getMnemonic, wait, ServerContext } from '../setup.ts'; +import { signWithMnemonic } from './sign-helper.ts'; // Always-succeeds PlutusV2 validator (accepts any datum/redeemer/context, returns True) const ALWAYS_SUCCEEDS_V2 = '49480100002221200101'; @@ -222,27 +223,69 @@ describe('Smart Contract Tools', () => { }); // ─── Agent Network Tools ──────────────────────────────────────────────────── +// Keyless since spec PR 7: each old custodial call (mnemonic in, submitted +// on-chain out, all inside one tool call) is now build_* (hosted, unsigned) +// -> signWithMnemonic (local, stands in for the local signer) -> +// vector_submit_transaction (hosted) - the same non-custodial flow the +// Transaction Tools tests above and keyless-e2e.test.ts already exercise. +// vector_discover_agents/vector_get_agent_profile are read-only and were +// never custodial - both unchanged (tool name and call shape). walletHasAda +// gating, the settle waits, and the assertSuccessOrKnownError convention are +// unchanged from the pre-keyless version of this section. + +// Extracts the CBOR block a build_* tool response embeds ("```\n\n```"), +// or null if the build didn't produce one (a known-error response, e.g. an +// unfunded wallet) - lets callers skip signing/submitting instead of crashing +// on a missing match. +function extractCborOrNull(text: string): string | null { + const m = text.match(/```\n([0-9a-f]+)\n```/); + return m ? m[1] : null; +} + +// build_* (hosted, unsigned) -> signWithMnemonic (local) -> +// vector_submit_transaction (hosted). Reuses assertSuccessOrKnownError at +// both legs: the build may legitimately fail a known way on an unfunded +// wallet (no sign/submit attempted then), and submit is only attempted once +// the build actually produced CBOR to sign. Returns the build response text +// (callers that need e.g. the registered DID parse it from there) plus +// whether the transaction was actually submitted, so callers only chain +// follow-on state (agentDid) off a real submission, not just a successful build. +async function buildSignSubmit( + toolName: string, args: Record, buildSuccessPattern: RegExp, +): Promise<{ buildText: string; submitted: boolean }> { + const buildText = await callTool(ctx.client, toolName, args); + console.log(buildText); + assertSuccessOrKnownError(buildText, buildSuccessPattern, toolName); + const cbor = extractCborOrNull(buildText); + if (!buildSuccessPattern.test(buildText) || !cbor) { + console.log(` ⚠ ${toolName}: build did not succeed - skipping sign/submit`); + return { buildText, submitted: false }; + } + const { signedCborHex } = signWithMnemonic(cbor, mnemonic); + const submitText = await callTool(ctx.client, 'vector_submit_transaction', { signedTxCbor: signedCborHex }); + console.log(submitText); + assertSuccessOrKnownError(submitText, /Transaction Submitted/, `${toolName} (submit)`); + return { buildText, submitted: /Transaction Submitted/.test(submitText) }; +} describe('Agent Network Tools', () => { - test('vector_register_agent', { timeout: 120_000 }, async () => { + test('vector_build_register_agent -> sign -> submit', { timeout: 120_000 }, async () => { if (walletHasAda) { console.log('Waiting 10s for UTxOs to settle before register...'); await wait(10); } const timestamp = Date.now(); - const text = await callTool(ctx.client, 'vector_register_agent', { - mnemonic, + const { buildText, submitted } = await buildSignSubmit('vector_build_register_agent', { + changeAddress: walletAddress, name: `TestAgent-${timestamp}`, description: 'Integration test agent', capabilities: ['testing'], framework: 'custom', endpoint: '', - }); - console.log(text); - assertSuccessOrKnownError(text, /Agent DID:/, 'vector_register_agent'); + }, /Agent DID:/); - const didMatch = text.match(/(did:vector:agent:[a-f0-9]+:[a-f0-9]+)/); - if (didMatch) { + const didMatch = buildText.match(/(did:vector:agent:[a-f0-9]+:[a-f0-9]+)/); + if (submitted && didMatch) { agentDid = didMatch[1]; console.log(`Registered agent DID: ${agentDid}`); } @@ -266,7 +309,7 @@ describe('Agent Network Tools', () => { assertSuccessOrKnownError(text, /Agent Profile/, 'vector_get_agent_profile'); }); - test('vector_update_agent', { timeout: 120_000 }, async () => { + test('vector_build_update_agent -> sign -> submit', { timeout: 120_000 }, async () => { if (!agentDid) { console.log('Skipping update — no agent registered'); return; @@ -275,16 +318,14 @@ describe('Agent Network Tools', () => { console.log('Waiting 10s for UTxOs to settle before update...'); await wait(10); } - const text = await callTool(ctx.client, 'vector_update_agent', { - mnemonic, + await buildSignSubmit('vector_build_update_agent', { + changeAddress: walletAddress, agent_id: agentDid, description: 'Updated integration test agent', - }); - console.log(text); - assertSuccessOrKnownError(text, /Agent Updated|Updated fields/, 'vector_update_agent'); + }, /Agent DID:/); }); - test('vector_transfer_agent (to self)', { timeout: 120_000 }, async () => { + test('vector_build_transfer_agent (to self) -> sign -> submit', { timeout: 120_000 }, async () => { if (!agentDid || !walletAddress) { console.log('Skipping transfer — no agent registered or no wallet address'); return; @@ -293,33 +334,29 @@ describe('Agent Network Tools', () => { console.log('Waiting 10s for UTxOs to settle before transfer...'); await wait(10); } - const text = await callTool(ctx.client, 'vector_transfer_agent', { - mnemonic, + await buildSignSubmit('vector_build_transfer_agent', { + changeAddress: walletAddress, agent_id: agentDid, new_owner_address: walletAddress, - }); - console.log(text); - assertSuccessOrKnownError(text, /Agent Transferred|Transfer/, 'vector_transfer_agent'); + }, /Agent DID:/); }); - test('vector_message_agent', { timeout: 120_000 }, async () => { + test('vector_build_message_agent -> sign -> submit', { timeout: 120_000 }, async () => { // Use a known DID format even if registration failed, to test the tool responds const testDid = agentDid || `did:vector:agent:${ALWAYS_SUCCEEDS_V2}:${'a'.repeat(64)}`; if (walletHasAda && agentDid) { console.log('Waiting 10s for UTxOs to settle before message...'); await wait(10); } - const text = await callTool(ctx.client, 'vector_message_agent', { + await buildSignSubmit('vector_build_message_agent', { + changeAddress: walletAddress, agent_id: testDid, message_type: 'inquiry', payload: 'integration test ping', - mnemonic, - }); - console.log(text); - assertSuccessOrKnownError(text, /Message Sent/, 'vector_message_agent'); + }, /Unsigned Message Built/); }); - test('vector_deregister_agent', { timeout: 120_000 }, async () => { + test('vector_build_deregister_agent -> sign -> submit', { timeout: 120_000 }, async () => { if (!agentDid) { console.log('Skipping deregister — no agent registered'); return; @@ -328,11 +365,9 @@ describe('Agent Network Tools', () => { console.log('Waiting 10s for UTxOs to settle before deregister...'); await wait(10); } - const text = await callTool(ctx.client, 'vector_deregister_agent', { - mnemonic, + await buildSignSubmit('vector_build_deregister_agent', { + changeAddress: walletAddress, agent_id: agentDid, - }); - console.log(text); - assertSuccessOrKnownError(text, /Agent Deregistered|deposit returned/, 'vector_deregister_agent'); + }, /Deposit returned on confirmation/); }); }); diff --git a/packages/builder/test/integration/sign-helper.ts b/packages/builder/test/integration/sign-helper.ts new file mode 100644 index 0000000..31e9464 --- /dev/null +++ b/packages/builder/test/integration/sign-helper.ts @@ -0,0 +1,54 @@ +// packages/builder/test/integration/sign-helper.ts +// +// Shared local-signing helper for the integration suites. Stands in for the +// in-tree local signer (packages/signer) — same CML primitives (hash -> +// make_vkey_witness -> witness-set attach), without the signer's own +// decode/policy/audit path. Extracted verbatim (Task 5, spec PR 7) from the +// inline signing block keyless-e2e.test.ts proved against the live testnet in +// PR #9's E2E, so registry-e2e.test.ts and run.test.ts's registry section can +// share it instead of re-inlining the same sequence. +import { CML, walletFromSeed } from '@lucid-evolution/lucid'; + +/** + * Sign an unsigned transaction (CBOR hex) with the payment key derived from a + * mnemonic. Takes the mnemonic directly (not a pre-derived key) so every + * caller can go straight from `getMnemonic()` to a signed transaction in one + * call. + * + * CORRECTION vs. the original inline block this was extracted from: that + * block built the signed tx's witness set from scratch + * (`CML.TransactionWitnessSet.new()`, only ever given the new vkey witness), + * which is lossless only when the unsigned tx's own witness set is empty - + * true for a plain payment (keyless-e2e.test.ts's proven case: no script, no + * redeemer). The agent-registry build_* tools attach a minting policy and/or + * spending validator, so their unsigned CBOR already carries + * plutus_v3_scripts/redeemers in `tx.witness_set()`, matched to a + * script_data_hash baked into the tx BODY at build time. Replacing the + * witness set outright silently drops that content; the ledger then rejects + * the resigned tx with `MissingScriptWitnessesUTXOW` (script witness gone) + * and `PPViewHashesDontMatch` (the body's script_data_hash no longer matches + * a witness set the ledger now sees as script-free) - reproduced live via + * registry-e2e.test.ts's register step, see task-5-report.md. Fixed by + * merging the new vkey witness into the tx's EXISTING witness set with + * `add_all_witnesses` (CML's documented primitive for exactly this "add a + * signature to an already-witnessed tx" composition) instead of discarding + * it. Strictly more correct and a no-op behavior change for the script-less + * case, since there's nothing else to preserve there. + */ +export function signWithMnemonic(unsignedCborHex: string, mnemonic: string): { signedCborHex: string; txHash: string } { + const { paymentKey } = walletFromSeed(mnemonic, { network: 'Mainnet', accountIndex: 0 }); + const tx = CML.Transaction.from_cbor_hex(unsignedCborHex); + const txHash = CML.hash_transaction(tx.body()); + const sk = CML.PrivateKey.from_bech32(paymentKey); + const vkeyWitness = CML.make_vkey_witness(txHash, sk); + + const witnessSet = tx.witness_set(); + const newWitnesses = CML.TransactionWitnessSet.new(); + const vkeys = CML.VkeywitnessList.new(); + vkeys.add(vkeyWitness); + newWitnesses.set_vkeywitnesses(vkeys); + witnessSet.add_all_witnesses(newWitnesses); + + const signed = CML.Transaction.new(tx.body(), witnessSet, tx.is_valid(), tx.auxiliary_data()); + return { signedCborHex: signed.to_cbor_hex(), txHash: txHash.to_hex() }; +}