Skip to content

debug() reports an operation-cost overrun as a failed require when the budget runs out on a require's opcode #456

Description

@mr-zwets

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

When a transaction exceeds the VM's operation cost density limit, debug() can report it as a failed require statement and name a require whose condition actually holds. This happens when the operation that crosses the limit is a require's own verify opcode. The thrown error is a FailedRequireError with that require's message, so the developer goes looking for a logic bug that isn't there. The real cause, running out of compute budget, only shows in libauthErrorMessage.

Reproduction

cashscript and cashc 0.14.0-next.6. The padding parameter only sets the unlocking bytecode length, and with it the input's budget of (41 + unlocking length) × 800.

pragma cashscript ^0.14.0;

contract Expensive() {
  function spend(int rounds, bytes unused padding) {
    bytes32 digest = sha256(0x00);
    for (int i = 0; i < rounds; i = i + 1) {
      digest = sha256(digest);
    }
    require(tx.outputs[0].lockingBytecode == tx.inputs[0].lockingBytecode, "Output 0 must pay back to the contract");
    require(tx.outputs[0].value >= 1000, "Output 0 must keep 1000 sats");
  }
}
import { compileFile } from 'cashc';
import { Contract, MockNetworkProvider, TransactionBuilder, randomUtxo } from 'cashscript';

const artifact = compileFile(new URL('./Expensive.cash', import.meta.url));
const provider = new MockNetworkProvider();
const contract = new Contract(artifact, [], { provider });

for (const [rounds, pad] of [[37, 14], [37, 15], [40, 22]]) {
  const utxo = provider.addUtxo(contract.address, randomUtxo({ satoshis: 100_000n }));
  const tx = new TransactionBuilder({ provider })
    .addInput(utxo, contract.unlock.spend(BigInt(rounds), new Uint8Array(pad)))
    .addOutput({ to: contract.address, amount: 10_000n });
  try { tx.debug(); console.log(rounds, pad, 'ok'); }
  catch (e) { console.log(rounds, pad, e.constructor.name, e.message.split('\n')[0], '|', e.libauthErrorMessage); }
}

Output (Bitauth URIs trimmed):

37 14 FailedRequireError Expensive.cash:9 Require statement failed at input 0 in contract Expensive.cash at line 9 with the following message: Output 0 must pay back to the contract. | Program attempted an operation that would exceed the operation cost density limit. Maximum operation cost: 76800 (density control length: 96); operation cost following operation: 76868.
37 15 ok
40 22 FailedRequireError Expensive.cash:4 Require statement failed at input 0 in contract Expensive.cash at line 4 with the following message: Output 0 must keep 1000 sats. | Program attempted an operation that would exceed the operation cost density limit. Maximum operation cost: 83200 (density control length: 104); operation cost following operation: 83273.
  • 37, 14 reports that output 0 does not pay back to the contract, but it does: the same transaction with one more byte of padding (37, 15) passes. Only the budget changed.
  • 40, 22 blames the final require, and its "failing statement" is the whole function body.
  • With other loop counts the same failure is correctly reported as a FailedTransactionEvaluationError with the libauth reason. The misattribution only happens when the budget runs out exactly on a require's opcode.

Cause

packages/cashscript/src/debugging.ts: when the last executed debug step has an error, the SDK takes failingIp = ip - 1 and looks for a require at that instruction pointer. If it finds one, it throws FailedRequireError, whatever the error was. For a resource-limit error that instruction pointer is only where the budget ran out, not a failed condition.

Suggested fix

Attribute the error to a require only when libauth reports a verify-type failure: AuthenticationErrorCommon.failedVerify, plus the nonNullSignatureFailure case the code already special-cases. For anything else, and resource limits in particular (operation cost density, hashing density, stack depth), throw FailedTransactionEvaluationError with the libauth reason, as the code already does when no require matches.

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

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions