From 6e308458346410eb01f5f062acd33e6668a16ac9 Mon Sep 17 00:00:00 2001 From: FilipBl4gojevic <84703395+FilipBl4gojevic@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:46:50 +0200 Subject: [PATCH] test: close PR-10 review debt - selection/detail/redeemer pins, signer signs a script tx --- packages/builder/src/vector/agent-network.ts | 3 +- .../integration/rate-limit-gating.test.ts | 4 +- packages/builder/test/registry-build.test.ts | 28 ++++ packages/builder/test/sign-helper.test.ts | 5 + packages/signer/test/sign-script-tx.test.ts | 122 ++++++++++++++++++ 5 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 packages/signer/test/sign-script-tx.test.ts diff --git a/packages/builder/src/vector/agent-network.ts b/packages/builder/src/vector/agent-network.ts index b9a790d..d50bf0c 100644 --- a/packages/builder/src/vector/agent-network.ts +++ b/packages/builder/src/vector/agent-network.ts @@ -15,6 +15,7 @@ import { MIN_AP3X_DEPOSIT, getRegistryAddress, resolveAgentUtxo, parseAgentDatum, buildRegisterAgent, buildUpdateAgent, buildTransferAgent, buildDeregisterAgent, buildMessageAgent, } from './registry-build.js'; +import type { RegistryAgentProfile } from './registry-build.js'; function newProvider() { return new OgmiosProvider({ ogmiosUrl: VECTOR_OGMIOS_URL, submitUrl: VECTOR_SUBMIT_URL, koiosUrl: VECTOR_KOIOS_URL }); @@ -102,7 +103,7 @@ ${capList} try { const provider = newProvider(); const utxos = await provider.getUtxos(getRegistryAddress()); - const profiles = []; + const profiles: RegistryAgentProfile[] = []; for (const utxo of utxos) { if (!utxo.datum) continue; const profile = parseAgentDatum(utxo.datum, `${utxo.txHash}#${utxo.outputIndex}`, utxo.assets); diff --git a/packages/builder/test/integration/rate-limit-gating.test.ts b/packages/builder/test/integration/rate-limit-gating.test.ts index 35e0d74..256e6e1 100644 --- a/packages/builder/test/integration/rate-limit-gating.test.ts +++ b/packages/builder/test/integration/rate-limit-gating.test.ts @@ -10,7 +10,9 @@ import type { RawServerHandle } from '../setup.ts'; // unit-tests RateLimiter/limiterFor in isolation; test:smoke only ever calls // listTools(). Neither exercises a single tool handler, so neither would // 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. agent-network.ts is typed now, so tsc catches a +// scoping regression there; self-improvement.ts (typed in the +// self-improvement-keyless PR) is the remaining compiler-blind case. See // .superpowers/sdd/pr5-integration-tests-report.md for the mutation-testing // evidence. // diff --git a/packages/builder/test/registry-build.test.ts b/packages/builder/test/registry-build.test.ts index cc969eb..3086364 100644 --- a/packages/builder/test/registry-build.test.ts +++ b/packages/builder/test/registry-build.test.ts @@ -346,6 +346,25 @@ describe('registry owner ops (real validator, offline)', () => { } 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, { changeAddress: OWN_ADDRESS, 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 @@ -423,6 +442,15 @@ describe('registry owner ops - value preservation & deregister floor (correction .complete(); 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'); + + // 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, { changeAddress: OWN_ADDRESS, 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 () => { diff --git a/packages/builder/test/sign-helper.test.ts b/packages/builder/test/sign-helper.test.ts index 2174a53..d8ef3e0 100644 --- a/packages/builder/test/sign-helper.test.ts +++ b/packages/builder/test/sign-helper.test.ts @@ -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', () => { const unsignedCborHex = buildSyntheticScriptBearingTx(); 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); @@ -106,6 +107,10 @@ describe('signWithMnemonic preserves an existing script witness set (hermetic pi 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}`); diff --git a/packages/signer/test/sign-script-tx.test.ts b/packages/signer/test/sign-script-tx.test.ts new file mode 100644 index 0000000..e36ce4f --- /dev/null +++ b/packages/signer/test/sign-script-tx.test.ts @@ -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}`); + }); +});