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.
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 failedrequirestatement 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 aFailedRequireErrorwith 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 inlibauthErrorMessage.Reproduction
cashscript and cashc
0.14.0-next.6. Thepaddingparameter only sets the unlocking bytecode length, and with it the input's budget of(41 + unlocking length) × 800.Output (Bitauth URIs trimmed):
37, 14reports 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, 22blames the final require, and its "failing statement" is the whole function body.FailedTransactionEvaluationErrorwith 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 takesfailingIp = ip - 1and looks for a require at that instruction pointer. If it finds one, it throwsFailedRequireError, 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 thenonNullSignatureFailurecase the code already special-cases. For anything else, and resource limits in particular (operation cost density, hashing density, stack depth), throwFailedTransactionEvaluationErrorwith the libauth reason, as the code already does when no require matches.