test(builder): hermetic pin for script-witness preservation; tighten non-owner and submit assertions

Closes an Opus review round on Task 5's registry integration work.

Adds packages/builder/test/sign-helper.test.ts (top-level test/, picked up
by the unit glob) as the first hermetic gate that would fail if
signWithMnemonic's witness-preservation fix regressed. Before this, the
fix had zero coverage under any required gate: sign-helper.ts lives under
test/integration/, outside the unit glob; run.test.ts's
assertSuccessOrKnownError swallows the resulting "Failed to submit
transaction" as an indistinguishable known error; and the E2E's
hash-equality check can't catch it either, since dropping witnesses never
changes the transaction body hash - only the ledger tells the two apart,
and this test never talks to it. The new test builds a synthetic,
fully-offline script-bearing transaction directly via CML (one input/
output, a witness set carrying one PlutusV3 script, one map-form redeemer,
one datum), signs it with a public, well-known, never-funded BIP39 test
vector (computed from the installed bip39 package rather than transcribed
from memory - the first guess had the wrong checksum word and failed
loudly), and asserts every witness category plus the vkey witness survive
signing with the body hash unchanged. Proved it's load-bearing: reverted
the fix to the buggy from-scratch witness set, watched the test fail
naming the exact lost witness, reverted back (byte-identical, zero diff),
re-ran green.

registry-keyless.test.ts: the non-owner-update assertion drops its second
regex alternative (`Failed to build agent update`), which was a superset
match able to hide a real, unrelated build failure behind the same
"ownership was correctly refused" label. Now asserts the specific
`Ownership check failed` message only.

run.test.ts: buildSignSubmit now hard-asserts `Transaction Submitted` on
the submit leg when walletHasAda is true, instead of routing through the
tolerant assertSuccessOrKnownError - on a funded wallet a submit failure
is a real regression, not indistinguishable from an unfunded wallet, and
should fail the test rather than pass silently as a "known error". The
graceful path is kept for the unfunded case. Also documents, in a comment,
the known/accepted risk that a register-succeeds/deregister-fails run in
this file leaves the 10 AP3X test deposit locked on-chain (same shape as
the pre-keyless suite).

Hermetic only this round, per instruction: no live network calls, no
transaction submissions, no real secrets. Full account in
.superpowers/sdd/task-5-report.md's Addendum section.

Gates: typecheck 0, unit 351/351 (350 + 1 new), smoke 26/26, auth 12/12,
boundary 4/4 (confirms the new test's mnemonic constant sits outside the
custody scan by design, same as the rest of test/, and is labeled
TEST_ONLY_PUBLIC_MNEMONIC regardless per public-repo hygiene).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
FilipBl4gojevic
2026-07-29 19:36:33 +02:00
parent 3edb9afd3c
commit 2baff726a0
3 changed files with 137 additions and 3 deletions

View File

@@ -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/);
});
});

View File

@@ -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<hex>\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) };
}

View File

@@ -0,0 +1,113 @@
// packages/builder/test/sign-helper.test.ts
//
// Hermetic pin for signWithMnemonic's witness-preservation fix
// (test/integration/sign-helper.ts). Proves offline, with zero network
// access and zero real secrets, that signing a script-bearing transaction
// preserves its existing plutus_v3_scripts/redeemers/plutus_datums instead
// of discarding them - the defect found live during Task 5's gated E2E run
// (see task-5-report.md, "Bug #1").
//
// Why this needs its own test: none of the existing gates catch this
// mutant. sign-helper.ts lives under test/integration/, outside every unit
// test glob (packages/*/test/*.test.ts is non-recursive). run.test.ts's
// assertSuccessOrKnownError treats the resulting "Failed to submit
// transaction" as a tolerated known error (indistinguishable from an
// unfunded wallet) rather than a hard failure. And registry-e2e.test.ts's
// hash-equality check (`assert.equal(submittedHash, txHash, ...)`) cannot
// catch it either: dropping witnesses never changes the transaction BODY,
// so the mutant computes the exact same body hash as the fix - only the
// ledger, which this test never talks to, rejects the mutant's CBOR. This
// file is the only gate that would fail if the fix regressed.
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { CML, walletFromSeed } from '@lucid-evolution/lucid';
import { signWithMnemonic } from './integration/sign-helper.ts';
// Public BIP39 test vector: 23x "abandon" + the checksum word "art" (the
// 24-word encoding of all-zero, 256-bit entropy - verified directly against
// the `bip39` package's own entropyToMnemonic(), not transcribed from
// memory: `bip39.entropyToMnemonic('00'.repeat(32))`). This is the
// canonical example mnemonic used throughout the crypto tooling ecosystem
// specifically because it is universally known, was never funded, and
// holds nothing on any network. TEST-ONLY - used here purely to exercise
// signing logic offline; never use it, or any mnemonic like it, for
// anything that touches real funds.
const TEST_ONLY_PUBLIC_MNEMONIC =
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon ' +
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art';
/**
* Builds a synthetic, offline, script-bearing unsigned transaction: one
* input, one output, and a witness set carrying one PlutusV3 script, one
* redeemer, and one datum - enough to exercise every witness-set category
* signWithMnemonic must preserve, without a live network, a real validator,
* or Lucid's TransactionBuilder (whose min-UTxO/fee rules this probe has no
* reason to satisfy: these are raw CML data containers, hashed and
* re-serialized, never evaluated or submitted anywhere).
*/
function buildSyntheticScriptBearingTx(): string {
const { address } = walletFromSeed(TEST_ONLY_PUBLIC_MNEMONIC, { network: 'Mainnet', accountIndex: 0 });
const addr = CML.Address.from_bech32(address);
const inputs = CML.TransactionInputList.new();
inputs.add(CML.TransactionInput.new(CML.TransactionHash.from_hex('00'.repeat(32)), 0n));
const outputs = CML.TransactionOutputList.new();
outputs.add(CML.TransactionOutput.new(addr, CML.Value.from_coin(2_000_000n)));
const body = CML.TransactionBody.new(inputs, outputs, 200_000n);
const witnessSet = CML.TransactionWitnessSet.new();
const scripts = CML.PlutusV3ScriptList.new();
scripts.add(CML.PlutusV3Script.from_hex('4e4d01000033222220051200120011'));
witnessSet.set_plutus_v3_scripts(scripts);
const redeemerMap = CML.MapRedeemerKeyToRedeemerVal.new();
redeemerMap.insert(
CML.RedeemerKey.new(CML.RedeemerTag.Mint, 0n),
CML.RedeemerVal.new(CML.PlutusData.new_bytes(new Uint8Array([1, 2, 3])), CML.ExUnits.new(1_000n, 1_000n)),
);
witnessSet.set_redeemers(CML.Redeemers.new_map_redeemer_key_to_redeemer_val(redeemerMap));
const datums = CML.PlutusDataList.new();
datums.add(CML.PlutusData.new_bytes(new Uint8Array([9, 9, 9])));
witnessSet.set_plutus_datums(datums);
return CML.Transaction.new(body, witnessSet, true).to_cbor_hex();
}
describe('signWithMnemonic preserves an existing script witness set (hermetic pin, task-5-report.md Bug #1)', () => {
test('signing a script-bearing tx keeps the vkey witness AND the original scripts/redeemers/datums, and leaves the body hash unchanged', () => {
const unsignedCborHex = buildSyntheticScriptBearingTx();
const unsignedBodyHashHex = CML.hash_transaction(CML.Transaction.from_cbor_hex(unsignedCborHex).body()).to_hex();
const { signedCborHex, txHash } = signWithMnemonic(unsignedCborHex, TEST_ONLY_PUBLIC_MNEMONIC);
assert.equal(txHash, unsignedBodyHashHex, 'signing must not change the transaction body hash');
const signed = CML.Transaction.from_cbor_hex(signedCborHex);
assert.equal(
CML.hash_transaction(signed.body()).to_hex(), unsignedBodyHashHex,
'the signed tx body hash must still match the unsigned one',
);
const ws = signed.witness_set();
const vkeys = ws.vkeywitnesses();
assert.ok(vkeys && vkeys.len() === 1, `expected exactly 1 vkey witness after signing, got ${vkeys?.len() ?? 0}`);
const scripts = ws.plutus_v3_scripts();
assert.ok(scripts && scripts.len() === 1, `expected the original plutus_v3 script to survive signing, got ${scripts?.len() ?? 0}`);
const redeemers = ws.redeemers();
assert.ok(redeemers, 'expected the original redeemers to survive signing (got none)');
assert.equal(
redeemers!.as_map_redeemer_key_to_redeemer_val()?.len(), 1,
'expected exactly 1 redeemer to survive signing',
);
const datums = ws.plutus_datums();
assert.ok(datums && datums.len() === 1, `expected the original plutus datum to survive signing, got ${datums?.len() ?? 0}`);
});
});