From 937ad038994735a118cd71ac1f77e0cfc8581dda Mon Sep 17 00:00:00 2001 From: Mathieu Date: Fri, 2 Oct 2026 12:59:34 +0200 Subject: [PATCH] fix: make debugging linear in the number of inputs debug() evaluates one scenario per input, and each scenario described every other input, source output and output by template scripts, which libauth compiled from CashAssembly for every scenario. Debugging now uses a template that gives those entries as the built transaction's bytecode, so each scenario only compiles its slot, and the compiler and VM are created once per debug run. The public getLibauthTemplate() output is unchanged, and error links are still built from it. Closes #469 Co-Authored-By: Claude Opus 5.5 --- packages/cashscript/src/TransactionBuilder.ts | 23 ++-- packages/cashscript/src/debugging.ts | 61 ++++++---- .../src/libauth-template/LibauthTemplate.ts | 109 +++++++++++++++--- .../libauth-template/LibauthTemplate.test.ts | 5 + .../LibauthTemplateMultiContract.test.ts | 5 + .../test/multi-contract-debugging.test.ts | 24 ++++ packages/cashscript/test/test-util.ts | 30 +++++ website/docs/releases/release-notes.md | 1 + 8 files changed, 212 insertions(+), 46 deletions(-) diff --git a/packages/cashscript/src/TransactionBuilder.ts b/packages/cashscript/src/TransactionBuilder.ts index da93ace41..0131e6e95 100644 --- a/packages/cashscript/src/TransactionBuilder.ts +++ b/packages/cashscript/src/TransactionBuilder.ts @@ -407,7 +407,8 @@ export class TransactionBuilder { console.warn('For the best debugging experience, please recompile your contract with cashc version 0.11.0 or newer.'); } - return debugLibauthTemplate(this.getLibauthTemplate(), this); + const template = getLibauthTemplate(this, this.buildLibauthTransaction(), { useTransactionBytecode: true }); + return debugLibauthTemplate(template, this, () => this.getBitauthUriWithFallback()); } /** @@ -518,22 +519,22 @@ export class TransactionBuilder { txid = await this.provider.sendRawTransaction(tx); } catch (e: any) { const reason = e.error ?? e.message; - - const getBitauthUriWithFallback = (): string => { - try { - return getBitauthUri(this.getLibauthTemplate()); - } catch { - return 'Bitauth URI generation failed'; - } - }; - - throw new FailedTransactionError(reason, getBitauthUriWithFallback()); + throw new FailedTransactionError(reason, this.getBitauthUriWithFallback()); } // The transaction was broadcast successfully, so failing to retrieve it afterwards is not a failed transaction return raw ? this.getTxDetails(txid, raw) : this.getTxDetails(txid); } + // Building the link must not replace the error it is attached to + private getBitauthUriWithFallback(): string { + try { + return getBitauthUri(this.getLibauthTemplate()); + } catch { + return 'Bitauth URI generation failed'; + } + } + private async getTxDetails(txid: string): Promise; private async getTxDetails(txid: string, raw: true): Promise; private async getTxDetails(txid: string, raw?: true): Promise { diff --git a/packages/cashscript/src/debugging.ts b/packages/cashscript/src/debugging.ts index 31a90b392..3db15566a 100644 --- a/packages/cashscript/src/debugging.ts +++ b/packages/cashscript/src/debugging.ts @@ -11,7 +11,11 @@ export type DebugResult = AuthenticationProgramStateCommon[]; export type DebugResults = Record; // debugs the template, optionally logging the execution data -export const debugTemplate = (template: WalletTemplate, artifacts: Artifact[]): DebugResults => { +export const debugTemplate = ( + template: WalletTemplate, + artifacts: Artifact[], + getErrorBitauthUri: () => string = () => getBitauthUri(template), +): DebugResults => { // If a contract has the same name, but a different bytecode, then it is considered a name collision const hasArtifactNameCollision = artifacts.some( (artifact) => ( @@ -26,25 +30,33 @@ export const debugTemplate = (template: WalletTemplate, artifacts: Artifact[]): const results: DebugResults = {}; const unlockingScriptIds = Object.keys(template.scripts).filter((key) => 'unlocks' in template.scripts[key]); + const context = createDebugContext(template, getErrorBitauthUri); + for (const unlockingScriptId of unlockingScriptIds) { const scenarioIds = (template.scripts[unlockingScriptId] as WalletTemplateScriptUnlocking).passes ?? []; const matchingArtifact = artifacts.find((artifact) => unlockingScriptId.startsWith(artifact.contractName)); for (const scenarioId of scenarioIds) { - results[`${unlockingScriptId}.${scenarioId}`] = debugSingleScenario(template, matchingArtifact, unlockingScriptId, scenarioId); + results[`${unlockingScriptId}.${scenarioId}`] = debugSingleScenario( + template, context, matchingArtifact, unlockingScriptId, scenarioId, + ); } } - verifyFullTransaction(template); + verifyFullTransaction(template, context); return results; }; const debugSingleScenario = ( - template: WalletTemplate, artifact: Artifact | undefined, unlockingScriptId: string, scenarioId: string, + template: WalletTemplate, + context: DebugContext, + artifact: Artifact | undefined, + unlockingScriptId: string, + scenarioId: string, ): DebugResult => { - const { vm, program } = createProgram(template, unlockingScriptId, scenarioId); + const { vm, program } = createProgram(template, context, unlockingScriptId, scenarioId); const fullDebugSteps = vm.debug(program); @@ -121,7 +133,7 @@ const debugSingleScenario = ( // public key does not match pkh in EQUALVERIFY // Note: due to P2PKHUnlocker implementation, the CHECKSIG cannot fail in practice, only the EQUALVERIFY can fail if (!artifact) { - throw new FailedTransactionError(error, getBitauthUri(template)); + throw new FailedTransactionError(error, context.getErrorBitauthUri()); } const frame = resolveFrame(artifact, lastExecutedDebugStep); @@ -136,13 +148,13 @@ const debugSingleScenario = ( // Note that we use failingIp here rather than requireStatementIp, see comment above throw new FailedRequireError( - artifact, failingIp, requireStatement, inputIndex, getBitauthUri(template), error, frame, callStack, + artifact, failingIp, requireStatement, inputIndex, context.getErrorBitauthUri(), error, frame, callStack, ); } // Note that we use failingIp here rather than requireStatementIp, see comment above throw new FailedTransactionEvaluationError( - artifact, failingIp, inputIndex, getBitauthUri(template), error, frame, + artifact, failingIp, inputIndex, context.getErrorBitauthUri(), error, frame, ); } @@ -167,7 +179,7 @@ const debugSingleScenario = ( // If there is no artifact, this is a P2PKH debug error, final verify can only occur when final CHECKSIG failed // Note: due to P2PKHUnlocker implementation, this cannot happen in practice if (!artifact) { - throw new FailedTransactionError(evaluationResult, getBitauthUri(template)); + throw new FailedTransactionError(evaluationResult, context.getErrorBitauthUri()); } const frame = resolveFrame(artifact, lastExecutedDebugStep); @@ -183,7 +195,7 @@ const debugSingleScenario = ( sourcemapInstructionPointer, requireStatement, inputIndex, - getBitauthUri(template), + context.getErrorBitauthUri(), undefined, frame, callStack, @@ -191,7 +203,7 @@ const debugSingleScenario = ( } throw new FailedTransactionEvaluationError( - artifact, sourcemapInstructionPointer, inputIndex, getBitauthUri(template), evaluationResult, frame, + artifact, sourcemapInstructionPointer, inputIndex, context.getErrorBitauthUri(), evaluationResult, frame, ); } @@ -207,12 +219,19 @@ const extractInputIndexFromScenario = (scenarioId: string): number => { type Program = AuthenticationProgramCommon; type CreateProgramResult = { vm: VM, program: Program }; +type DebugContext = { compiler: ReturnType, vm: VM, getErrorBitauthUri: () => string }; + +const createDebugContext = (template: WalletTemplate, getErrorBitauthUri: () => string): DebugContext => ({ + compiler: createCompiler(walletTemplateToCompilerConfiguration(template)), + vm: createVirtualMachine(template.supported[0] as VmTarget), + getErrorBitauthUri, +}); -// internal util. instantiates the virtual machine and compiles the template into a program -const createProgram = (template: WalletTemplate, unlockingScriptId: string, scenarioId: string): CreateProgramResult => { - const configuration = walletTemplateToCompilerConfiguration(template); - const vm = createVirtualMachine(template.supported[0] as VmTarget); - const compiler = createCompiler(configuration); +// internal util. compiles a scenario of the template into a program +const createProgram = ( + template: WalletTemplate, context: DebugContext, unlockingScriptId: string, scenarioId: string, +): CreateProgramResult => { + const { compiler, vm } = context; if (!template.scripts[unlockingScriptId]) { throw new Error(`No unlock script found in template for ID ${unlockingScriptId}`); @@ -229,11 +248,11 @@ const createProgram = (template: WalletTemplate, unlockingScriptId: string, scen }); if (typeof scenarioGeneration === 'string') { - throw new FailedTransactionError(scenarioGeneration, getBitauthUri(template)); + throw new FailedTransactionError(scenarioGeneration, context.getErrorBitauthUri()); } if (typeof scenarioGeneration.scenario === 'string') { - throw new FailedTransactionError(scenarioGeneration.scenario, getBitauthUri(template)); + throw new FailedTransactionError(scenarioGeneration.scenario, context.getErrorBitauthUri()); } return { vm, program: scenarioGeneration.scenario.program }; @@ -379,7 +398,7 @@ const getFinalExecutedVerifyIp = (executedDebugSteps: AuthenticationProgramState // After debugging, we want to verify the full transaction to ensure it is valid (this catches any errors that are not // necessarily script errors) -const verifyFullTransaction = (template: WalletTemplate): void => { +const verifyFullTransaction = (template: WalletTemplate, context: DebugContext): void => { const placeholderScriptId = Object.keys(template.scripts).find((key) => 'unlocks' in template.scripts[key]); const placeholderScenarioId = (template.scripts[placeholderScriptId ?? ''] as WalletTemplateScriptUnlocking)?.passes?.[0]; @@ -387,7 +406,7 @@ const verifyFullTransaction = (template: WalletTemplate): void => { throw new Error('No placeholder scenario ID or script ID found'); } - const { vm, program } = createProgram(template, placeholderScriptId, placeholderScenarioId); + const { vm, program } = createProgram(template, context, placeholderScriptId, placeholderScenarioId); const verificationResult = vm.verify({ sourceOutputs: program.sourceOutputs, @@ -395,7 +414,7 @@ const verifyFullTransaction = (template: WalletTemplate): void => { }); if (typeof verificationResult === 'string') { - throw new FailedTransactionError(verificationResult, getBitauthUri(template)); + throw new FailedTransactionError(verificationResult, context.getErrorBitauthUri()); } }; diff --git a/packages/cashscript/src/libauth-template/LibauthTemplate.ts b/packages/cashscript/src/libauth-template/LibauthTemplate.ts index f10e2dff8..66c716830 100644 --- a/packages/cashscript/src/libauth-template/LibauthTemplate.ts +++ b/packages/cashscript/src/libauth-template/LibauthTemplate.ts @@ -31,7 +31,7 @@ import { Utxo, } from '../interfaces.js'; import SignatureTemplate from '../SignatureTemplate.js'; -import { addressToLockScript, extendedStringify, zip } from '../utils.js'; +import { addressToLockScript, extendedStringify, generateLibauthSourceOutputs, zip } from '../utils.js'; import { TransactionBuilder } from '../TransactionBuilder.js'; import { zlibSync } from 'fflate'; import MockNetworkProvider from '../network/MockNetworkProvider.js'; @@ -39,9 +39,27 @@ import { addHexPrefixExceptEmpty, DEFAULT_VM_TARGET, formatBytecodeForDebugging, // TODO: Add / improve descriptions throughout the template generation +export interface LibauthTemplateOptions { + // So libauth only compiles each scenario's slot, keeping debugging linear in the number of inputs. + // The other inputs and outputs then show as raw bytecode in BitAuth IDE + useTransactionBytecode?: boolean; +} + +interface TransactionBytecode { + unlocking: string[]; + sourceLocking: string[]; + outputLocking: string[]; +} + +interface ScenarioBytecode { + transaction?: TransactionBytecode; + lockingBytecodeParamsMapping: Record; +} + export const getLibauthTemplate = ( transactionBuilder: TransactionBuilder, libauthTransaction: TransactionBch, + options: LibauthTemplateOptions = {}, ): WalletTemplate => { if (transactionBuilder.inputs.some((input) => !isStandardUnlockableUtxo(input))) { throw new Error('Cannot use debugging functionality with a transaction that contains custom unlockers'); @@ -51,6 +69,10 @@ export const getLibauthTemplate = ( ? transactionBuilder.provider.vmTarget : DEFAULT_VM_TARGET; + const scenarioBytecode: ScenarioBytecode = options.useTransactionBytecode + ? { transaction: getTransactionBytecode(transactionBuilder, libauthTransaction), lockingBytecodeParamsMapping: {} } + : { lockingBytecodeParamsMapping: generateLockingBytecodeParamsMapping(transactionBuilder) }; + const template: WalletTemplate = { $schema: 'https://ide.bitauth.com/authentication-template-v0.schema.json', description: 'Imported from cashscript', @@ -59,19 +81,33 @@ export const getLibauthTemplate = ( version: 0, entities: generateAllTemplateEntities(transactionBuilder), scripts: generateAllTemplateScripts(transactionBuilder), - scenarios: generateAllTemplateScenarios(libauthTransaction, transactionBuilder), + scenarios: generateAllTemplateScenarios(libauthTransaction, transactionBuilder, scenarioBytecode), }; return template; }; -export const debugLibauthTemplate = (template: WalletTemplate, transaction: TransactionBuilder): DebugResults => { +const getTransactionBytecode = ( + transactionBuilder: TransactionBuilder, + libauthTransaction: TransactionBch, +): TransactionBytecode => ({ + unlocking: libauthTransaction.inputs.map((input) => binToHex(input.unlockingBytecode)), + sourceLocking: generateLibauthSourceOutputs(transactionBuilder.inputs) + .map((output) => binToHex(output.lockingBytecode)), + outputLocking: libauthTransaction.outputs.map((output) => binToHex(output.lockingBytecode)), +}); + +export const debugLibauthTemplate = ( + template: WalletTemplate, + transaction: TransactionBuilder, + getErrorBitauthUri?: () => string, +): DebugResults => { const allArtifacts = transaction.inputs .map(input => isContractUnlocker(input.unlocker) ? input.unlocker.contract : undefined) .filter((contract): contract is Contract => Boolean(contract)) .map(contract => contract.artifact); - return debugTemplate(template, allArtifacts); + return debugTemplate(template, allArtifacts, getErrorBitauthUri); }; export const getBitauthUri = (template: WalletTemplate): string => { @@ -96,7 +132,7 @@ const generateAllTemplateEntities = ( throw new Error('Unknown unlocker type'); }); - return entities.reduce((acc, entity) => ({ ...acc, ...entity }), {}); + return Object.assign({}, ...entities); }; const generateAllTemplateScripts = ( @@ -121,7 +157,7 @@ const generateAllTemplateScripts = ( throw new Error('Unknown unlocker type'); }); - return scripts.reduce((acc, script) => ({ ...acc, ...script }), {}); + return Object.assign({}, ...scripts); }; const generateLockingBytecodeParamsMapping = ( @@ -145,10 +181,11 @@ const generateLockingBytecodeParamsMapping = ( const generateAllTemplateScenarios = ( libauthTransaction: TransactionBch, transactionBuilder: TransactionBuilder, + scenarioBytecode: ScenarioBytecode, ): WalletTemplate['scenarios'] => { const scenarios = transactionBuilder.inputs.map((input, inputIndex) => { if (isP2PKHUnlocker(input.unlocker)) { - return generateTemplateScenariosP2PKH(libauthTransaction, transactionBuilder, inputIndex); + return generateTemplateScenariosP2PKH(libauthTransaction, transactionBuilder, inputIndex, scenarioBytecode); } if (isContractUnlocker(input.unlocker)) { @@ -160,13 +197,14 @@ const generateAllTemplateScenarios = ( input.unlocker.abiFunction, encodedArgs, inputIndex, + scenarioBytecode, ); } throw new Error('Unknown unlocker type'); }); - return scenarios.reduce((acc, scenario) => ({ ...acc, ...scenario }), {}); + return Object.assign({}, ...scenarios); }; const generateTemplateEntitiesP2PKH = ( @@ -342,6 +380,7 @@ const generateTemplateScenarios = ( abiFunction: AbiFunction, encodedFunctionArgs: EncodedFunctionArgument[], inputIndex: number, + scenarioBytecode: ScenarioBytecode, ): WalletTemplate['scenarios'] => { const artifact = contract.artifact; const encodedConstructorArgs = contract.encodedConstructorArgs; @@ -363,8 +402,12 @@ const generateTemplateScenarios = ( privateKeys: generateTemplateScenarioKeys(abiFunction.inputs, encodedFunctionArgs), }, }, - transaction: generateTemplateScenarioTransaction(contract, libauthTransaction, transactionBuilder, inputIndex), - sourceOutputs: generateTemplateScenarioSourceOutputs(transactionBuilder, libauthTransaction, inputIndex), + transaction: generateTemplateScenarioTransaction( + contract, libauthTransaction, transactionBuilder, inputIndex, scenarioBytecode, + ), + sourceOutputs: generateTemplateScenarioSourceOutputs( + transactionBuilder, libauthTransaction, inputIndex, scenarioBytecode, + ), }, }; @@ -375,6 +418,7 @@ const generateTemplateScenariosP2PKH = ( libauthTransaction: TransactionBch, transactionBuilder: TransactionBuilder, inputIndex: number, + scenarioBytecode: ScenarioBytecode, ): WalletTemplate['scenarios'] => { const scenarioIdentifier = `P2PKH_spend_input${inputIndex}_evaluate`; const { signature, publicKey } = getSignatureAndPubkeyFromP2PKHInput(libauthTransaction.inputs[inputIndex]); @@ -391,8 +435,12 @@ const generateTemplateScenariosP2PKH = ( [`public_key_${inputIndex}`]: `0x${binToHex(publicKey)}`, }, }, - transaction: generateTemplateScenarioTransaction(undefined, libauthTransaction, transactionBuilder, inputIndex), - sourceOutputs: generateTemplateScenarioSourceOutputs(transactionBuilder, libauthTransaction, inputIndex), + transaction: generateTemplateScenarioTransaction( + undefined, libauthTransaction, transactionBuilder, inputIndex, scenarioBytecode, + ), + sourceOutputs: generateTemplateScenarioSourceOutputs( + transactionBuilder, libauthTransaction, inputIndex, scenarioBytecode, + ), }, }; @@ -404,8 +452,13 @@ const generateTemplateScenarioTransaction = ( libauthTransaction: TransactionBch, transactionBuilder: TransactionBuilder, slotIndex: number, + scenarioBytecode: ScenarioBytecode, ): WalletTemplateScenario['transaction'] => { - const lockingBytecodeParamsMapping = generateLockingBytecodeParamsMapping(transactionBuilder); + if (scenarioBytecode.transaction) { + return generateTemplateScenarioTransactionFromBytecode(libauthTransaction, slotIndex, scenarioBytecode.transaction); + } + + const { lockingBytecodeParamsMapping } = scenarioBytecode; const zippedInputs = zip(transactionBuilder.inputs, libauthTransaction.inputs); const inputs = zippedInputs.map(([csInput, libauthInput], inputIndex) => { @@ -435,15 +488,43 @@ const generateTemplateScenarioTransaction = ( return { inputs, locktime, outputs, version }; }; +const generateTemplateScenarioTransactionFromBytecode = ( + libauthTransaction: TransactionBch, + slotIndex: number, + transactionBytecode: TransactionBytecode, +): WalletTemplateScenario['transaction'] => { + const inputs = libauthTransaction.inputs.map((libauthInput, inputIndex) => ({ + outpointIndex: libauthInput.outpointIndex, + outpointTransactionHash: binToHex(libauthInput.outpointTransactionHash), + sequenceNumber: libauthInput.sequenceNumber, + unlockingBytecode: inputIndex === slotIndex ? ['slot'] as ['slot'] : transactionBytecode.unlocking[inputIndex], + })); + + const outputs = libauthTransaction.outputs.map((libauthOutput, outputIndex) => ({ + lockingBytecode: transactionBytecode.outputLocking[outputIndex], + token: serialiseTokenDetails(libauthOutput.token), + valueSatoshis: Number(libauthOutput.valueSatoshis), + })); + + return { inputs, locktime: libauthTransaction.locktime, outputs, version: libauthTransaction.version }; +}; + const generateTemplateScenarioSourceOutputs = ( transactionBuilder: TransactionBuilder, libauthTransaction: TransactionBch, slotIndex: number, + scenarioBytecode: ScenarioBytecode, ): Array> => { const zippedInputs = zip(transactionBuilder.inputs, libauthTransaction.inputs); return zippedInputs.map(([csInput, libauthInput], inputIndex) => { + const lockingBytecode = scenarioBytecode.transaction && inputIndex !== slotIndex + ? scenarioBytecode.transaction.sourceLocking[inputIndex] + : generateTemplateScenarioBytecodeForSourceOutputs( + csInput, libauthInput, inputIndex, 'p2pkh_placeholder_lock', inputIndex === slotIndex, + ); + return { - lockingBytecode: generateTemplateScenarioBytecodeForSourceOutputs(csInput, libauthInput, inputIndex, 'p2pkh_placeholder_lock', inputIndex === slotIndex), + lockingBytecode, valueSatoshis: Number(csInput.satoshis), token: serialiseTokenDetails(csInput.token), }; diff --git a/packages/cashscript/test/libauth-template/LibauthTemplate.test.ts b/packages/cashscript/test/libauth-template/LibauthTemplate.test.ts index 13ff6d408..5fc03b964 100644 --- a/packages/cashscript/test/libauth-template/LibauthTemplate.test.ts +++ b/packages/cashscript/test/libauth-template/LibauthTemplate.test.ts @@ -1,4 +1,5 @@ import { fixtures } from '../fixture/libauth-template/fixtures.js'; +import { expectSameDebugOutcomeWithTransactionBytecode } from '../test-util.js'; describe('Libauth Template generation tests (single-contract)', () => { it.each(fixtures)('should generate a valid libauth template for $name', (fixture) => { @@ -7,4 +8,8 @@ describe('Libauth Template generation tests (single-contract)', () => { // console.warn(fixture.transaction.bitauthUri()); expect(generatedTemplate).toEqual(fixture.template); }); + + it.each(fixtures)('should give the same debug outcome when using transaction bytecode for $name', (fixture) => { + expectSameDebugOutcomeWithTransactionBytecode(fixture.transaction); + }); }); diff --git a/packages/cashscript/test/libauth-template/LibauthTemplateMultiContract.test.ts b/packages/cashscript/test/libauth-template/LibauthTemplateMultiContract.test.ts index a1180bae1..20c8099ce 100644 --- a/packages/cashscript/test/libauth-template/LibauthTemplateMultiContract.test.ts +++ b/packages/cashscript/test/libauth-template/LibauthTemplateMultiContract.test.ts @@ -1,4 +1,5 @@ import { fixtures } from '../fixture/libauth-template/multi-contract-fixtures.js'; +import { expectSameDebugOutcomeWithTransactionBytecode } from '../test-util.js'; describe('Libauth Template generation tests (multi-contract)', () => { it.each(fixtures)('should generate a valid libauth template for $name', async (fixture) => { @@ -8,4 +9,8 @@ describe('Libauth Template generation tests (multi-contract)', () => { // console.warn(builder.bitauthUri()); expect(generatedTemplate).toEqual(fixture.template); }); + + it.each(fixtures)('should give the same debug outcome when using transaction bytecode for $name', async (fixture) => { + expectSameDebugOutcomeWithTransactionBytecode(await fixture.transaction); + }); }); diff --git a/packages/cashscript/test/multi-contract-debugging.test.ts b/packages/cashscript/test/multi-contract-debugging.test.ts index 38a29de88..68fb3dfbe 100644 --- a/packages/cashscript/test/multi-contract-debugging.test.ts +++ b/packages/cashscript/test/multi-contract-debugging.test.ts @@ -339,6 +339,30 @@ describe('Multi-Contract-Debugging tests', () => { }); }); + describe('Many inputs', () => { + it('should attribute a failing require statement to the right input among many inputs', async () => { + const contract = new Contract(p2pkhArtifact, [alicePkh], { provider }); + const transaction = new TransactionBuilder({ provider }); + + for (let inputIndex = 0; inputIndex < 40; inputIndex += 1) { + // Unique outpoints, random ones can collide across this many inputs + const utxo = provider.addUtxo( + contract.address, + randomUtxo({ txid: inputIndex.toString(16).padStart(64, '0'), vout: 0, satoshis: 10_000n }), + ); + const unlocker = inputIndex === 33 + ? contract.unlock.spend(bobPub, bobSignatureTemplate) + : contract.unlock.spend(alicePub, new SignatureTemplate(alicePriv)); + transaction.addInput(utxo, unlocker); + } + + transaction.addOutput({ to: contract.address, amount: 300_000n }); + + await expect(transaction) + .toFailRequireWith('P2PKH.cash:4 Require statement failed at input 33 in contract P2PKH.cash at line 4.'); + }); + }); + describe('Non-require error messages', () => { it('should fail with the correct error message when there are name collisions on the contractName', () => { const nameCollision = new Contract(ARTIFACT_NAME_COLLISION, [0n], { provider }); diff --git a/packages/cashscript/test/test-util.ts b/packages/cashscript/test/test-util.ts index e29c158e2..30f81bc35 100644 --- a/packages/cashscript/test/test-util.ts +++ b/packages/cashscript/test/test-util.ts @@ -2,7 +2,9 @@ import { lockingBytecodeToCashAddress, hexToBin, Transaction, + WalletTemplate, binToHex, + decodeTransaction, } from '@bitauth/libauth'; import PQueue from 'p-queue'; import pRetry from 'p-retry'; @@ -14,6 +16,8 @@ import MockNetworkProvider from '../src/network/MockNetworkProvider.js'; import NetworkProvider from '../src/network/NetworkProvider.js'; import SignatureTemplate from '../src/SignatureTemplate.js'; import { TransactionBuilder } from '../src/TransactionBuilder.js'; +import { DebugResults } from '../src/debugging.js'; +import { debugLibauthTemplate, getBitauthUri, getLibauthTemplate } from '../src/libauth-template/LibauthTemplate.js'; export function getTxOutputs(tx: Transaction, network: Network = defaultNetwork): Output[] { return tx.outputs.map((o) => { @@ -141,3 +145,29 @@ export function gatherUtxos( changeAmount, }; } + +// Some fixtures fail evaluation, so the error is compared as well as the results +export function expectSameDebugOutcomeWithTransactionBytecode(transaction: TransactionBuilder): void { + const libauthTransaction = decodeTransaction(hexToBin(transaction.build())); + if (typeof libauthTransaction === 'string') throw new Error(libauthTransaction); + + const scriptTemplate = getLibauthTemplate(transaction, libauthTransaction); + const bytecodeTemplate = getLibauthTemplate(transaction, libauthTransaction, { useTransactionBytecode: true }); + + for (const scenario of Object.values(bytecodeTemplate.scenarios ?? {})) { + const templateInputs = scenario.transaction?.inputs.filter((input) => typeof input.unlockingBytecode !== 'string'); + const templateSourceOutputs = scenario.sourceOutputs?.filter((output) => typeof output.lockingBytecode !== 'string'); + expect(templateInputs).toEqual([expect.objectContaining({ unlockingBytecode: ['slot'] })]); + expect(templateSourceOutputs).toEqual([expect.objectContaining({ lockingBytecode: ['slot'] })]); + } + + const debugOutcome = (template: WalletTemplate): DebugResults | Error => { + try { + return debugLibauthTemplate(template, transaction, () => getBitauthUri(scriptTemplate)); + } catch (error) { + return error as Error; + } + }; + + expect(debugOutcome(bytecodeTemplate)).toEqual(debugOutcome(scriptTemplate)); +} diff --git a/website/docs/releases/release-notes.md b/website/docs/releases/release-notes.md index 83fe5a301..533606a5b 100644 --- a/website/docs/releases/release-notes.md +++ b/website/docs/releases/release-notes.md @@ -44,6 +44,7 @@ This release contains several breaking changes, please refer to the [migration n - :bug: Fix minimum fee check rounding bug. - :bug: Fix bug where `setLocktime()` accepted invalid values. - :bug: Fix edge case bug where a failing final `require(variable)` statement was reported incorrectly in debugging. +- :racehorse: Greatly improve debugging performance for transactions with many inputs, which also speeds up `send()` on the `MockNetworkProvider` and the `toFailRequire` matchers. ## v0.13.3