Skip to content

debug() misattributes failures in 0.14.0 (two bugs, fixed in #462) #475

Open

Description

@mr-zwets

On behalf of Mathieu G. (mr-zwets), drafted with Claude Opus 5.5.

Versions: cashc 0.14.0, cashscript 0.14.0 (commit 5f0eaff), @bitauth/libauth 3.1.0-next.8, Node.js v24.22.0

Both bugs are fixed in #462. This issue tracks them separately because #462 also contains a breaking send() change
meant for 0.15, while these fixes are not breaking and could ship in a 0.14.x patch. Note that #470 conflicts with
the second fix (see below).

In both cases the transaction is correctly rejected by the VM; only the error reported by debug() / send() is
wrong: another require's line and message, an unrelated statement, or
TypeError: Cannot destructure property 'location' of 'locationData[modifiedInstructionPointer]' as it is undefined.

Both repros run with npx tsx repro.mts (top-level await, so .mts or a "type": "module" package).

Bug 1: a failing final require(f(x)) with a non-inlined function is attributed to the wrong statement

When the last require of a contract function fails, and its condition ends in a call to a user-defined function
that cashc emits with OP_DEFINE/OP_INVOKE instead of inlining, the failure is reported at the wrong location.

Imports, multiple contract functions or multiple inputs are not needed. The function must not be inlined: per
shouldInline (packages/cashc/src/generation/inlining.ts:13) it is called more than once and defining it is
smaller than inlining it, it is recursive, or disableInlining is set. A function called once is always inlined
(unless disableInlining), and then the error is reported correctly.

import { compileString } from 'cashc';
import { Contract, MockNetworkProvider, TransactionBuilder, randomUtxo } from 'cashscript';

const source = `pragma cashscript ^0.14.0;

function isTwo(int x) returns (bool) {
    return (x + 1) * 1001 == 3003;
}

contract Two() {
    function spend(int a, int b) {
        require(isTwo(a), "a must be two");
        require(isTwo(b), "b must be two");
    }
}
`;

const artifact = compileString(source);
const provider = new MockNetworkProvider();
const contract = new Contract(artifact, [], { provider });
const utxo = provider.addUtxo(contract.address, randomUtxo({ satoshis: 100_000n }));

// a = 2 passes, b = 3 fails the final require
await new TransactionBuilder({ provider })
  .addInput(utxo, contract.unlock.spend(2n, 3n))
  .addOutput({ to: contract.address, amount: 99_000n })
  .send();

Actual (Bitauth URI omitted):

Two.cash:9 Require statement failed at input 0 in contract Two.cash at line 9 with the following message: a must be two.
Failing statement: isTwo(a)

Expected:

Two.cash:10 Require statement failed at input 0 in contract Two.cash at line 10 with the following message: b must be two.
Failing statement: require(isTwo(b), "b must be two");

spend(3n, 2n), which fails the first require (ending in OP_VERIFY), is reported correctly. With
return x % 2 == 0 && x > 0 && x < 1000; as the function body, the same call throws the TypeError above.

Root cause (packages/cashscript/src/debugging.ts at 5f0eaff): executedDebugSteps (line 64) contains the
steps of every frame, the root script and each invoked function body, each with its own instructions and an ip
starting at 0. For a failing final verify, line 154 calls getFinalExecutedVerifyIp(executedDebugSteps) (line 365),
which strips the trailing cleanup steps (calculateCleanupSize, line 330) and returns the ip + 1 of the step
before them (lines 372-376). When the condition ends in OP_INVOKE, that step is the last instruction of the invoked
function, not the OP_INVOKE:

root    7  OP_INVOKE      <- isTwo(b); the expected result is 7 + 1 = 8
callee  0  OP_1ADD
callee  1  <0xe903>
callee  2  OP_MUL
callee  3  <0xbb0b>
callee  4  OP_NUMEQUAL    <- picked as the final non-cleanup step, giving 4 + 1 = 5
root    8  <none>         <- end of script, stripped as cleanup
root    8  <none>

That callee ip is then used in the root frame: frame.requires.find((message) => message.ip === finalExecutedVerifyIp)
(line 174) matches the first require (ip: 5) instead of the second (ip: 8). When no require matches, the ip goes
to FailedTransactionEvaluationError (line 193) and through the root source map, giving an unrelated statement or,
past the end of the source map, the TypeError in getLocationDataForFrame (packages/cashscript/src/debug-frame.ts:263).

Fix in #462: compute the final verify ip only from the failing frame's steps:

const finalExecutedVerifyIp = getFinalExecutedVerifyIp(
  executedDebugSteps.filter((step) => step.instructions === lastExecutedDebugStep.instructions),
);

Bug 2: an input is debugged with the wrong artifact when one contract name is a prefix of another

If a transaction spends two contracts whose names share a prefix (Foo and FooBar, Counter and CounterReader)
and the shorter name sits at the lower input index, the longer-named contract's input is debugged with the
shorter-named contract's artifact. The error names the wrong contract and resolves the failing ip against that
contract's requires and source map. console.log output for that input is looked up in the wrong artifact too.

import { compileString } from 'cashc';
import { Contract, MockNetworkProvider, TransactionBuilder, randomUtxo } from 'cashscript';

const foo = compileString(`pragma cashscript ^0.14.0;

contract Foo() {
    function spend(int x) {
        require(x > 0, "x must be positive");
        require(x < 10, "x must be below ten");
        require(x == 1, "x must be one");
    }
}
`);

const fooBar = compileString(`pragma cashscript ^0.14.0;

contract FooBar() {
    function spend(int y) {
        require(y > 0, "y must be positive");
        require(y == 2, "y must be two");
    }
}
`);

const provider = new MockNetworkProvider();
const fooContract = new Contract(foo, [], { provider });
const fooBarContract = new Contract(fooBar, [], { provider });
const fooUtxo = provider.addUtxo(fooContract.address, randomUtxo({ satoshis: 100_000n }));
const fooBarUtxo = provider.addUtxo(fooBarContract.address, randomUtxo({ satoshis: 100_000n }));

// Input 0 (Foo) passes; input 1 (FooBar) fails `require(y == 2, ...)`
await new TransactionBuilder({ provider })
  .addInput(fooUtxo, fooContract.unlock.spend(1n))
  .addInput(fooBarUtxo, fooBarContract.unlock.spend(3n))
  .addOutput({ to: fooContract.address, amount: 199_000n })
  .send();

Actual (Bitauth URI omitted):

Foo.cash:6 Error in transaction at input 1 in contract Foo.cash at line 6.
Reason: Unsuccessful evaluation: completed with a non-truthy value on top of the stack. Top stack item: "".
Failing statement: 10

Expected:

FooBar.cash:6 Require statement failed at input 1 in contract FooBar.cash at line 6 with the following message: y must be two.
Failing statement: require(y == 2, "y must be two");

With the inputs swapped (FooBar at input 0) the failure is reported correctly.

Root cause: packages/cashscript/src/debugging.ts:32 at 5f0eaff picks the artifact by name prefix:

const matchingArtifact = artifacts.find((artifact) => unlockingScriptId.startsWith(artifact.contractName));

Unlocking script ids are ${contractName}_${functionName}_input${inputIndex}_unlock
(packages/cashscript/src/libauth-template/utils.ts:64) and artifacts is in input order
(packages/cashscript/src/libauth-template/LibauthTemplate.ts:69-74), so FooBar_spend_input1_unlock matches Foo
first. The name-collision check at debugging.ts:16 only rejects identical names. Matching on `${contractName}_`
would still be ambiguous (Foo and Foo_Bar).

Fix in #462: key the artifacts by their exact unlocking script id in debugLibauthTemplate, using the same
getUnlockScriptName(contract, abiFunction, inputIndex) as the template, and look them up by key in debugTemplate.
debugTemplate is not exported from the package, so the signature change is internal.

Conflict with #470: open PR #470 edits the same lines, still uses the prefix lookup and also changes
debugTemplate's signature. Whichever of #462 and #470 lands second needs to keep the exact-id lookup.

Verification

With #462's two changes applied to a copy of the published cashscript 0.14.0 build, both repros, a
disableInlining single-call variant of bug 1 and the TypeError variant all report the correct contract, line and
require message.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions