Rúnar

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}'
FlagDescription
<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

CommandShortDescription
stepsExecute one opcode and print the result
nextnExecute until the source line changes (step over inlined helpers)
continuecRun until the next breakpoint, error, or completion
stackstPrint the full main stack with type annotations
altstackasPrint the alt stack
break <loc>bSet a breakpoint: b 47 (opcode index) or b PriceBet.runar.ts:24 (source line)
delete <id>dDelete a breakpoint by ID
infoiShow current position, opcode count, breakpoints, and source location
backtracebtShow the last N executed opcodes with stack depths (default 10)
runrRestart execution from the beginning
quitqExit the debugger
helphShow 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:

SizePrefixAnnotation
0 bytesfalse
1 byte, value 0x01true
33 bytes, starts with 0x02/0x03(PubKey)
20 bytes(Ripemd160/Addr)
32 bytes(Sha256)
64 bytes(Point)
1-8 bytesDecoded 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

MemberTypeDescription
loadHex(unlock, lock)methodLoad scripts from hex for stepping
step()methodExecute one opcode, return StepResult or null
pcgetterCurrent program counter (byte offset)
contextgetter'unlocking' or 'locking'
currentStackgetterCopy of the main stack
currentAltStackgetterCopy of the alt stack
isCompletegetterWhether execution has finished
isSuccessgetterWhether 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