Debugging Compiled Script
When a contract does not behave as expected, Rúnar provides an interactive step-through script debugger (runar debug), source maps that link opcodes back to your source code, a steppable ScriptVM for programmatic trace collection, and verbose test output with stack dumps. Together, these tools let you see exactly what happens inside the Bitcoin Script at every opcode.
The Interactive Debugger
The runar debug command launches an interactive REPL that executes your compiled contract one opcode at a time. You can inspect the main and alt stacks, set breakpoints by opcode index or source line, and step through execution with source-mapped annotations.
Launching the Debugger
runar debug ./artifacts/PriceBet.json --method settle --args '{"price": 60000}'
| Flag | Description |
|---|---|
<artifact> | Path to a compiled JSON artifact (must include sourceMap) |
-m, --method <name> | Public method to invoke |
-a, --args <json> | Method arguments as a JSON object |
-u, --unlock <hex> | Raw unlocking script hex (alternative to --method/--args) |
-b, --break <loc> | Initial breakpoint: opcode index or file:line |
The debugger loads the artifact, builds the unlocking script from the method and arguments, and drops you into an interactive session:
Runar Script Debugger v0.1.0
Contract: PriceBet (1204 bytes, 89 opcodes)
Method: settle
Source: PriceBet.runar.ts
>
Debugger Commands
| Command | Short | Description |
|---|---|---|
step | s | Execute one opcode and print the result |
next | n | Execute until the source line changes (step over inlined helpers) |
continue | c | Run until the next breakpoint, error, or completion |
stack | st | Print the full main stack with type annotations |
altstack | as | Print the alt stack |
break <loc> | b | Set a breakpoint: b 47 (opcode index) or b PriceBet.runar.ts:24 (source line) |
delete <id> | d | Delete a breakpoint by ID |
info | i | Show current position, opcode count, breakpoints, and source location |
backtrace | bt | Show the last N executed opcodes with stack depths (default 10) |
run | r | Restart execution from the beginning |
quit | q | Exit the debugger |
help | h | Show the command list |
Example Session
> s
[0000] OP_DUP stack: [0xe803..0000]
PriceBet.runar.ts:23 const msg = num2bin(price, 8n);
> s
[0001] OP_TOALTSTACK stack: []
PriceBet.runar.ts:23 const msg = num2bin(price, 8n);
> b PriceBet.runar.ts:24
Breakpoint 1 at PriceBet.runar.ts:24 (opcode #44)
> c
Hit breakpoint 1 — PriceBet.runar.ts:24 (opcode #44)
[002c] OP_EQUALVERIFY stack: [0x01, 0x01]
PriceBet.runar.ts:24 assert(verifyRabinSig(msg, rabinSig, padding, this.oraclePubKey));
> st
Main stack (2 items, top first):
[1] 0x01 true
[0] 0x01 true
> c
Script completed successfully.
Final stack: [0x01]
Each step shows the byte offset (hex), the opcode name, the stack contents, and — when a source map is available — the corresponding source file, line number, and source text.
Stack Annotations
The debugger automatically annotates stack items based on their size and content:
| Size | Prefix | Annotation |
|---|---|---|
| 0 bytes | — | false |
| 1 byte, value 0x01 | — | true |
| 33 bytes, starts with 0x02/0x03 | — | (PubKey) |
| 20 bytes | — | (Ripemd160/Addr) |
| 32 bytes | — | (Sha256) |
| 64 bytes | — | (Point) |
| 1-8 bytes | — | Decoded as a script number (e.g., 60000n) |
Setting Breakpoints
Breakpoints can be set by opcode index or by source line. Source-line breakpoints are resolved through the artifact’s source map:
> b 47
Breakpoint 1 at opcode #47
> b PriceBet.runar.ts:26
Breakpoint 2 at PriceBet.runar.ts:26 (opcode #52)
> d 1
Deleted breakpoint #1
When you continue, execution runs until it hits a breakpoint, encounters an error, or finishes.
Unlocking and Locking Script Context
The debugger executes both the unlocking script and the locking script in sequence, mirroring how a Bitcoin node validates a transaction. Each step shows which script is executing:
UNL [0000] OP_1 stack: [0x01]
(unlocking script)
[0000] OP_1 stack: [0x01, 0x01]
P2PKH.runar.ts:12 assert(hash160(pubKey) === this.pubKeyHash);
Steps in the unlocking script are prefixed with UNL. Steps in the locking script show the source location when a source map is available.
Source Maps
Every compiled artifact includes a source map (under the sourceMap key) that connects opcode indices to source file locations. The source map uses this format:
{
"sourceMap": {
"mappings": [
{ "opcodeIndex": 0, "sourceFile": "P2PKH.runar.ts", "line": 12, "column": 4 },
{ "opcodeIndex": 1, "sourceFile": "P2PKH.runar.ts", "line": 12, "column": 4 },
{ "opcodeIndex": 5, "sourceFile": "P2PKH.runar.ts", "line": 13, "column": 4 }
]
}
}
The debugger, the test runner’s verbose output, and the SourceMapResolver class all use this data.
SourceMapResolver API
The SourceMapResolver class (exported from runar-testing) provides programmatic access to the source map:
import { SourceMapResolver } from 'runar-testing';
const resolver = new SourceMapResolver(artifact.sourceMap);
// Map an opcode index to a source location
const loc = resolver.resolve(44);
// { file: 'PriceBet.runar.ts', line: 24, column: 4, opcodeIndex: 44 }
// Map a source line back to opcode indices (for breakpoints)
const offsets = resolver.reverseResolve('PriceBet.runar.ts', 24);
// [44, 45, 46]
// List all source files in the map
resolver.sourceFiles; // ['PriceBet.runar.ts']
// Check if the map has any entries
resolver.isEmpty; // false
Programmatic Step-Through with ScriptVM
The ScriptVM class supports both full execution (execute) and step-by-step execution (loadHex + step). Use the step API to build custom debugging tools or collect execution traces in tests:
import { ScriptVM, bytesToHex } from 'runar-testing';
const vm = new ScriptVM();
vm.loadHex(unlockingHex, lockingHex);
const trace = [];
while (!vm.isComplete) {
const result = vm.step();
if (!result) break;
trace.push(result);
}
console.log(`Executed ${trace.length} opcodes`);
console.log(`Success: ${vm.isSuccess}`);
console.log(`Final stack: ${vm.currentStack.map(s => bytesToHex(s))}`);
Each step() call returns a StepResult:
interface StepResult {
offset: number; // Byte offset in the active script
opcode: string; // e.g. 'OP_ADD', 'OP_DUP', 'PUSH_20'
mainStack: Uint8Array[]; // Main stack after this opcode
altStack: Uint8Array[]; // Alt stack after this opcode
error?: string; // Set if the opcode failed
context: 'unlocking' | 'locking'; // Which script is executing
}
step() returns null when there are no more opcodes. After completion, inspect vm.isSuccess and vm.currentStack.
ScriptVM Step API Reference
| Member | Type | Description |
|---|---|---|
loadHex(unlock, lock) | method | Load scripts from hex for stepping |
step() | method | Execute one opcode, return StepResult or null |
pc | getter | Current program counter (byte offset) |
context | getter | 'unlocking' or 'locking' |
currentStack | getter | Copy of the main stack |
currentAltStack | getter | Copy of the alt stack |
isComplete | getter | Whether execution has finished |
isSuccess | getter | Whether execution completed successfully |
Collecting a Trace in Tests
A common pattern is to collect a trace and assert on specific execution points:
import { describe, it, expect } from 'vitest';
import { ScriptVM, hexToBytes, bytesToHex } from 'runar-testing';
describe('execution trace', () => {
it('traces OP_1 OP_2 OP_ADD', () => {
const vm = new ScriptVM();
vm.loadHex('', '515293'); // OP_1 OP_2 OP_ADD
const steps = [];
while (!vm.isComplete) {
const result = vm.step();
if (result) steps.push(result);
}
expect(steps).toHaveLength(3);
expect(steps[0].opcode).toBe('OP_1');
expect(steps[1].opcode).toBe('OP_2');
expect(steps[2].opcode).toBe('OP_ADD');
expect(vm.isSuccess).toBe(true);
expect(vm.currentStack).toHaveLength(1);
});
it('detects OP_VERIFY failure', () => {
const vm = new ScriptVM();
// OP_1 OP_VERIFY OP_0 OP_VERIFY (second verify fails)
vm.loadHex('', '51690069');
const steps = [];
while (!vm.isComplete) {
const result = vm.step();
if (result) steps.push(result);
}
const failStep = steps.find(s => s.error);
expect(failStep).toBeDefined();
expect(failStep.opcode).toBe('OP_VERIFY');
expect(vm.isSuccess).toBe(false);
});
});
Verbose Test Output
When running tests with --verbose, failed assertions include source-mapped context:
runar test --verbose
FAIL tests/PriceBet.test.ts
x settles with invalid oracle sig
Script execution failed at offset 47: OP_VERIFY
Source: PriceBet.runar.ts:24
22 | public settle(price: bigint, rabinSig: RabinSig, ...) {
23 | const msg = num2bin(price, 8n);
> 24 | assert(verifyRabinSig(msg, rabinSig, padding, this.oraclePubKey));
25 | if (price > this.strikePrice) {
Inspecting Compiled Output
Use --asm to include human-readable assembly and --ir to include the ANF intermediate representation:
runar compile contracts/PriceBet.runar.ts --asm --ir --output ./artifacts
The artifact then contains both the asm field (opcode names) and ir field (named temporaries like t0_price, t1_msg), letting you trace how high-level expressions map through the compilation pipeline to final opcodes.
Comparing Script Output
Rúnar guarantees deterministic compilation. Compare compiled output against a known-good version to detect regressions:
diff <(jq -r '.asm' ./artifacts/PriceBet.json) <(jq -r '.asm' tests/golden/PriceBet.json)
Common Script-Level Failures
OP_VERIFY Failure
The most common failure. An OP_VERIFY consumed false from the stack, corresponding to a failed assert(). Use runar debug to step to the failing opcode and inspect the stack, or run runar test --verbose to see the source-mapped error.
Stack Underflow
The script tried to pop from an empty stack. This usually means the unlocking script did not provide enough arguments. Check that you are passing all required arguments to the method.
Oversized Stack
Rúnar enforces a maximum stack depth of 800 elements at compile time. If exceeded:
Error: Stack overflow -- depth 801 exceeds maximum of 800
Reduce stack usage by breaking complex expressions into smaller private methods.
Non-Minimal Encoding
BSV requires numbers to use minimal encoding. The compiler handles this correctly, but if you construct unlocking scripts manually, ensure numbers are minimally encoded.
Further Reading
- Writing Tests —
TestContractAPI and testing patterns - The Test Runner — CLI flags, ScriptVM, and fuzzing
- Common Errors — error catalog with causes and solutions
- CLI Reference — full
runar debugflag reference