diff --git a/packages/builder/test/integration/registry-keyless.test.ts b/packages/builder/test/integration/registry-keyless.test.ts index c64a81f..bb56908 100644 --- a/packages/builder/test/integration/registry-keyless.test.ts +++ b/packages/builder/test/integration/registry-keyless.test.ts @@ -84,7 +84,12 @@ describe('registry keyless tier-1 (live testnet, no secrets)', () => { 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/); + // live datum owners differ from OWN_ADDRESS's key). Expect the ownership + // fail-fast specifically, not the broader "Failed to build agent update" + // wrapper it's nested inside - that second alternative would also match + // any OTHER build failure (a real regression), so it isn't a safe + // stand-in for "ownership was checked and correctly refused." Reviewer- + // confirmed against today's live response; not re-run live for this change. + assert.match(text, /Ownership check failed/); }); }); diff --git a/packages/builder/test/integration/run.test.ts b/packages/builder/test/integration/run.test.ts index 8d00368..2ba954c 100644 --- a/packages/builder/test/integration/run.test.ts +++ b/packages/builder/test/integration/run.test.ts @@ -232,6 +232,11 @@ describe('Smart Contract Tools', () => { // never custodial - both unchanged (tool name and call shape). walletHasAda // gating, the settle waits, and the assertSuccessOrKnownError convention are // unchanged from the pre-keyless version of this section. +// +// Known/accepted risk, same shape as the pre-keyless suite: if register +// submits but a later step in this chain fails before deregister runs, the +// 10 AP3X deposit stays locked on-chain under this test agent - nothing in +// this file reclaims it automatically. // Extracts the CBOR block a build_* tool response embeds ("```\n\n```"), // or null if the build didn't produce one (a known-error response, e.g. an @@ -264,7 +269,18 @@ async function buildSignSubmit( 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)`); + // A successful build against a FUNDED wallet is expected to submit + // successfully too - hard-assert here instead of the tolerant + // assertSuccessOrKnownError, which would otherwise treat a real submit + // regression (e.g. a signing defect - see task-5-report.md's Bug #1) as + // an indistinguishable "known error, wallet may be unfunded" and let it + // pass silently. Falls back to the graceful known-error path when the + // wallet isn't funded (mirrors every other assertion in this file). + if (walletHasAda) { + assert.match(submitText, /Transaction Submitted/, `${toolName} (submit): expected success on a funded wallet, got: ${submitText.slice(0, 300)}`); + } else { + assertSuccessOrKnownError(submitText, /Transaction Submitted/, `${toolName} (submit)`); + } return { buildText, submitted: /Transaction Submitted/.test(submitText) }; } diff --git a/packages/builder/test/sign-helper.test.ts b/packages/builder/test/sign-helper.test.ts new file mode 100644 index 0000000..2174a53 --- /dev/null +++ b/packages/builder/test/sign-helper.test.ts @@ -0,0 +1,113 @@ +// packages/builder/test/sign-helper.test.ts +// +// Hermetic pin for signWithMnemonic's witness-preservation fix +// (test/integration/sign-helper.ts). Proves offline, with zero network +// access and zero real secrets, that signing a script-bearing transaction +// preserves its existing plutus_v3_scripts/redeemers/plutus_datums instead +// of discarding them - the defect found live during Task 5's gated E2E run +// (see task-5-report.md, "Bug #1"). +// +// Why this needs its own test: none of the existing gates catch this +// mutant. sign-helper.ts lives under test/integration/, outside every unit +// test glob (packages/*/test/*.test.ts is non-recursive). run.test.ts's +// assertSuccessOrKnownError treats the resulting "Failed to submit +// transaction" as a tolerated known error (indistinguishable from an +// unfunded wallet) rather than a hard failure. And registry-e2e.test.ts's +// hash-equality check (`assert.equal(submittedHash, txHash, ...)`) cannot +// catch it either: dropping witnesses never changes the transaction BODY, +// so the mutant computes the exact same body hash as the fix - only the +// ledger, which this test never talks to, rejects the mutant's CBOR. This +// file is the only gate that would fail if the fix regressed. +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { CML, walletFromSeed } from '@lucid-evolution/lucid'; +import { signWithMnemonic } from './integration/sign-helper.ts'; + +// Public BIP39 test vector: 23x "abandon" + the checksum word "art" (the +// 24-word encoding of all-zero, 256-bit entropy - verified directly against +// the `bip39` package's own entropyToMnemonic(), not transcribed from +// memory: `bip39.entropyToMnemonic('00'.repeat(32))`). This is the +// canonical example mnemonic used throughout the crypto tooling ecosystem +// specifically because it is universally known, was never funded, and +// holds nothing on any network. TEST-ONLY - used here purely to exercise +// signing logic offline; never use it, or any mnemonic like it, for +// anything that touches real funds. +const TEST_ONLY_PUBLIC_MNEMONIC = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon ' + + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art'; + +/** + * Builds a synthetic, offline, script-bearing unsigned transaction: one + * input, one output, and a witness set carrying one PlutusV3 script, one + * redeemer, and one datum - enough to exercise every witness-set category + * signWithMnemonic must preserve, without a live network, a real validator, + * or Lucid's TransactionBuilder (whose min-UTxO/fee rules this probe has no + * reason to satisfy: these are raw CML data containers, hashed and + * re-serialized, never evaluated or submitted anywhere). + */ +function buildSyntheticScriptBearingTx(): string { + const { address } = walletFromSeed(TEST_ONLY_PUBLIC_MNEMONIC, { network: 'Mainnet', accountIndex: 0 }); + const addr = CML.Address.from_bech32(address); + + const inputs = CML.TransactionInputList.new(); + inputs.add(CML.TransactionInput.new(CML.TransactionHash.from_hex('00'.repeat(32)), 0n)); + + const outputs = CML.TransactionOutputList.new(); + outputs.add(CML.TransactionOutput.new(addr, CML.Value.from_coin(2_000_000n))); + + const body = CML.TransactionBody.new(inputs, outputs, 200_000n); + + const witnessSet = CML.TransactionWitnessSet.new(); + + const scripts = CML.PlutusV3ScriptList.new(); + scripts.add(CML.PlutusV3Script.from_hex('4e4d01000033222220051200120011')); + witnessSet.set_plutus_v3_scripts(scripts); + + const redeemerMap = CML.MapRedeemerKeyToRedeemerVal.new(); + redeemerMap.insert( + CML.RedeemerKey.new(CML.RedeemerTag.Mint, 0n), + CML.RedeemerVal.new(CML.PlutusData.new_bytes(new Uint8Array([1, 2, 3])), CML.ExUnits.new(1_000n, 1_000n)), + ); + witnessSet.set_redeemers(CML.Redeemers.new_map_redeemer_key_to_redeemer_val(redeemerMap)); + + const datums = CML.PlutusDataList.new(); + datums.add(CML.PlutusData.new_bytes(new Uint8Array([9, 9, 9]))); + witnessSet.set_plutus_datums(datums); + + return CML.Transaction.new(body, witnessSet, true).to_cbor_hex(); +} + +describe('signWithMnemonic preserves an existing script witness set (hermetic pin, task-5-report.md Bug #1)', () => { + test('signing a script-bearing tx keeps the vkey witness AND the original scripts/redeemers/datums, and leaves the body hash unchanged', () => { + const unsignedCborHex = buildSyntheticScriptBearingTx(); + const unsignedBodyHashHex = CML.hash_transaction(CML.Transaction.from_cbor_hex(unsignedCborHex).body()).to_hex(); + + const { signedCborHex, txHash } = signWithMnemonic(unsignedCborHex, TEST_ONLY_PUBLIC_MNEMONIC); + + assert.equal(txHash, unsignedBodyHashHex, 'signing must not change the transaction body hash'); + + const signed = CML.Transaction.from_cbor_hex(signedCborHex); + assert.equal( + CML.hash_transaction(signed.body()).to_hex(), unsignedBodyHashHex, + 'the signed tx body hash must still match the unsigned one', + ); + + const ws = signed.witness_set(); + + const vkeys = ws.vkeywitnesses(); + assert.ok(vkeys && vkeys.len() === 1, `expected exactly 1 vkey witness after signing, got ${vkeys?.len() ?? 0}`); + + const scripts = ws.plutus_v3_scripts(); + assert.ok(scripts && scripts.len() === 1, `expected the original plutus_v3 script to survive signing, got ${scripts?.len() ?? 0}`); + + const redeemers = ws.redeemers(); + assert.ok(redeemers, 'expected the original redeemers to survive signing (got none)'); + assert.equal( + redeemers!.as_map_redeemer_key_to_redeemer_val()?.len(), 1, + 'expected exactly 1 redeemer to survive signing', + ); + + const datums = ws.plutus_datums(); + assert.ok(datums && datums.len() === 1, `expected the original plutus datum to survive signing, got ${datums?.len() ?? 0}`); + }); +});