Skip to main content

Interpreter & Debugger

@nexscript/interpreter runs Nexa script in JavaScript. It is a port of the full node's script interpreter (src/script/interpreter.cpp) with a step API, so you can run a script to completion, or step through a compiled contract one instruction at a time and map each instruction back to a line of NexScript source. The playground's debugger is built on it.

It is not a replacement for the node: some opcodes are missing and several node checks are not made (see Limitations). Use it to debug and test contract logic, and use a node to decide whether a transaction is valid.

Installation​

npm install @nexscript/interpreter

Scripts are passed as libnexa-ts Script objects. To build one from ASM, use asmToScript from @nexscript/utils rather than Script.fromASM, which turns digit-only data pushes such as 80 into opcodes.

ScriptMachine​

ScriptMachine executes a script the way the node does: beginStep(), then step() or continue(), then endStep().

Constructor​

new ScriptMachine(config?: MachineConfig)

interface MachineConfig {
flags?: number // script flags, default DEFAULT_SCRIPT_FLAGS
requireMinimal?: boolean // overrides SCRIPT_VERIFY_MINIMALDATA
nullFail?: boolean // overrides SCRIPT_VERIFY_NULLFAIL
enableExtBignum?: boolean // overrides SCRIPT_FORK1_OPCODES
maxOps?: number // default 201
maxScriptElementSize?: number // default 520
signatureChecker?: SignatureChecker
importedState?: ImportedState
initialStack?: StackItem[]
initialAltStack?: StackItem[]
}

initialStack and initialAltStack are copied, so execution does not change the arrays you pass. A StackItem is a Uint8Array or a BigNumItem.

importedState supplies the transaction values that OP_CHECKLOCKTIMEVERIFY, OP_CHECKSEQUENCEVERIFY and OP_PUSH_TX_STATE read: checkLockTime() and checkSequence() callbacks, the transaction id and idem, input and output totals, and per-group balances. The interpreter does not work these out from a transaction; it uses what you pass. An opcode whose value is missing fails with DATA_REQUIRED.

Methods​

machine.beginStep(script: Script): boolean
machine.step(): { ok: boolean, error: ScriptError, executed: boolean }
machine.continue(maxSteps?: number): boolean
machine.endStep(): boolean
machine.isMoreSteps(): boolean
machine.getPos(): number
machine.getError(): ScriptError
machine.stack: StackItem[]
machine.altStack: StackItem[]

beginStep() loads a script and resets the position, the condition stack, the op count and the bignum modulus. It keeps the stacks. step() runs one element (an opcode or a data push) and returns ok: false with the error if it fails. executed is false when the element was in a branch that is not taken. continue() steps until the script ends or a step fails, and returns false on failure. It does not call endStep().

getPos() is the element index of the next instruction. This is an index into the script's parsed elements, not a byte offset, and it is what the debug map uses.

endStep() fails with UNBALANCED_CONDITIONAL if an OP_IF is still open. Otherwise it sets the error to OK. It does not look at the stack: a script that leaves OP_0 on top still ends with OK. Check the stack yourself, or end the script with a VERIFY opcode so that a false condition fails during execution.

Example​

import { ScriptMachine } from '@nexscript/interpreter';
import { asmToScript } from '@nexscript/utils';

const machine = new ScriptMachine();
machine.beginStep(asmToScript('OP_2 OP_3 OP_ADD OP_5 OP_NUMEQUALVERIFY OP_1'));
machine.continue(); // true
machine.endStep(); // true
machine.getError(); // 'OK'
machine.stack; // [ Uint8Array(1) [ 1 ] ]

const failing = new ScriptMachine();
failing.beginStep(asmToScript('OP_2 OP_3 OP_ADD OP_6 OP_NUMEQUALVERIFY'));
failing.continue(); // false
failing.getError(); // 'NUMEQUALVERIFY'
failing.getPos(); // 5

ScriptError is a string enum ('OK', 'VERIFY', 'DATA_REQUIRED', ...), ported from the node's script_error.h.

evalScript() / evalUnlockingScript()​

evalScript(script: Script, options?: MachineConfig): StackItem[]
evalUnlockingScript(asm: string, options?: MachineConfig): StackItem[]

Run a script, usually a push-only unlocking script, and return the resulting stack. Use the result as initialStack when you debug the contract script on its own.

DebugSession​

DebugSession wraps a ScriptMachine for step debugging. Each step returns a DebugFrame with the stacks, the error and the source span of the instruction that just ran.

new DebugSession(script: string, options?: MachineConfig & {
debugMap?: DebugSpan[]
hex?: boolean // script is hex bytecode instead of ASM
})

session.step(): DebugFrame
session.run(): DebugFrame
session.isDone(): boolean
session.succeeded(): boolean
session.elementCount: number

interface DebugFrame {
pos: number // element index of the next instruction
stack: StackItem[]
altStack: StackItem[]
error: ScriptError
span?: DebugSpan // innermost span covering the instruction just run
}

run() steps until the script ends or a step fails, and returns the last frame. When you call step() yourself, stop at the first frame whose error is not OK: the session does not stop you, and the next successful step resets the error to OK, so succeeded() can then return true for a script that failed.

succeeded() returns true when there is no error and the top stack item is true. That is the rule for a plain script, not for a contract: a Nexa script template must leave an empty stack, so a compiled contract that passes ends with no items and succeeded() returns false. For a contract, check frame.error === ScriptError.OK && frame.stack.length === 0.

Debug map​

nexc adds two fields to each contract in an artifact:

  • debugBytecode: the contract's bytecode as ASM before optimisation.
  • debugMap: a list of DebugSpans. Each one covers the elements [start, end) of debugBytecode and points at the source that produced them.
interface DebugSpan {
start: number
end: number
kind?: string // 'function', 'require', 'binaryOp', 'identifier', ...
location?: {
start?: { line: number, column: number }
end?: { line: number, column: number }
}
}

Spans nest: an identifier sits inside a binaryOp, inside a require, inside a function. spanForPos(debugMap, pos) returns the smallest span that contains pos, and DebugSession uses it for frame.span. Lines start at 1.

The map indexes debugBytecode, not the deployed bytecode, so always debug debugBytecode. It does the same thing as the optimised bytecode, but it has more instructions, and a few at the start (such as the OP_FROMALTSTACK that reads a constructor argument) have no span.

Debugging a compiled contract​

A Nexa script template runs with the unlocking (satisfier) arguments on the main stack and the constructor arguments on the alt stack. Seed the session the same way: initialStack gets the function arguments, last one first, followed by the function index if the contract has more than one function; initialAltStack gets the constructor arguments as the node pushes them: the hidden parameters in declaration order, then the visible ones, the last one on top.

Example​

import { compileString } from '@nexscript/nexc';
import {
DebugSession, DebugSpan, ScriptError, evalUnlockingScript,
} from '@nexscript/interpreter';
import { ContractArtifact, minimallyEncodeInt } from '@nexscript/utils';

const source = `contract Sum(int target) {
function spend(int a, int b) {
require(a + b == target);
}
}`;
// ContractArtifact does not declare the debug fields, hence the cast
const artifact = compileString(source).contracts[0] as ContractArtifact
& { debugMap: DebugSpan[], debugBytecode: string };
const lines = source.split('\n');

const session = new DebugSession(artifact.debugBytecode, {
debugMap: artifact.debugMap,
initialStack: evalUnlockingScript('OP_3 OP_2'), // spend(2, 3)
initialAltStack: [minimallyEncodeInt(5n)], // new Sum(5)
});

let frame;
do {
frame = session.step();
const line = frame.span?.location?.start?.line;
console.log(frame.pos, frame.span?.kind, line && lines[line - 1].trim());
} while (!session.isDone() && frame.error === ScriptError.OK);
// 1 undefined undefined
// 2 identifier require(a + b == target);
// ...
// 9 binaryOp require(a + b == target);
// 10 require require(a + b == target);

const passed = frame.error === ScriptError.OK && frame.stack.length === 0; // true

With new Sum(6) instead, the last frame has error VERIFY at the require span.

Signature checkers​

The machine does not verify signatures itself; it calls the signatureChecker you pass. Without one, OP_CHECKSIG, OP_CHECKSIGVERIFY, OP_CHECKDATASIG and OP_CHECKDATASIGVERIFY fail with DATA_REQUIRED.

interface SignatureChecker {
checkSig(sig: Uint8Array, pubkey: Uint8Array, scriptCode: Script): boolean
checkDataSig(sig: Uint8Array, message: Uint8Array, pubkey: Uint8Array): boolean
}

With SCRIPT_VERIFY_NULLFAIL set (the default), a failed check with a non-empty signature fails the script with SIG_NULLFAIL instead of pushing false.

DataOnlySignatureChecker​

new DataOnlySignatureChecker()

For scripts that have no transaction. OP_CHECKDATASIG works: it checks a Schnorr signature over the SHA-256 of the message. OP_CHECKSIG fails with DATA_REQUIRED, as on the node when there is no transaction. The same check is available on its own as verifyDataSignature(sig, message, pubkey).

Example​

import { Hash, PrivateKey, Schnorr } from 'libnexa-ts';
import { DataOnlySignatureChecker, ScriptMachine } from '@nexscript/interpreter';
import { asmToScript, binToHex, utf8ToBin } from '@nexscript/utils';

const key = PrivateKey.fromRandom();
const pubkey = key.toPublicKey().toBuffer();
const message = utf8ToBin('hello');
const sig = Schnorr.sign(Hash.sha256(message), key, 'big').toBuffer(true);

const machine = new ScriptMachine({ signatureChecker: new DataOnlySignatureChecker() });
machine.beginStep(asmToScript(
`${binToHex(sig)} ${binToHex(message)} ${binToHex(pubkey)} OP_CHECKDATASIGVERIFY OP_1`,
));
machine.continue(); // true
machine.getError(); // 'OK'

TxSignatureChecker​

new TxSignatureChecker(tx: Transaction, inputIndex: number, subscript?: Script)

Checks OP_CHECKSIG signatures against a real libnexa-ts transaction with TxSigner.verify, so a signature passes only if it signs that transaction's input. OP_CHECKDATASIG works as with DataOnlySignatureChecker.

subscript is the script the signature signs. When you debug a contract, pass the deployed (optimised) script here: the session runs debugBytecode, which differs from what was signed. Without subscript, the checker uses the part of the running script after the last OP_CODESEPARATOR, with the signature removed.

Example​

import { PrivateKey, SighashType, TransactionBuilder, TxSigner } from 'libnexa-ts';
import { ScriptMachine, TxSignatureChecker } from '@nexscript/interpreter';
import { asmToScript, binToHex } from '@nexscript/utils';

const key = PrivateKey.fromRandom();
const address = key.toAddress().toString();
const tx = new TransactionBuilder()
.from({ outpoint: '11'.repeat(32), satoshis: 10000, address })
.to(address, 9000)
.build();

const subscript = asmToScript(`${binToHex(key.toPublicKey().toBuffer())} OP_CHECKSIG`);
const sighashType = SighashType.ALL;
const signature = TxSigner.sign(tx, 0, sighashType, subscript, key);
const sig = new Uint8Array([...signature.toBuffer(true), ...sighashType.toBuffer()]);

const machine = new ScriptMachine({
signatureChecker: new TxSignatureChecker(tx, 0, subscript),
initialStack: [sig],
});
machine.beginStep(subscript);
machine.continue();
machine.stack; // [ Uint8Array(1) [ 1 ] ]

Script flags​

flags is a bitfield with the same bit values as the node's script_flags.h, so a flag set taken from the node can be passed as is. ScriptFlags names the bits, and setFlag(flags, bit, on) and hasFlag(flags, bit) set and test them.

DEFAULT_SCRIPT_FLAGS = SCRIPT_VERIFY_MINIMALDATA | SCRIPT_VERIFY_NULLFAIL | SCRIPT_FORK1_OPCODES

The interpreter only acts on four flags:

FlagEffect
SCRIPT_VERIFY_MINIMALDATANumbers read from the stack must be minimally encoded (NUMBER_BAD_ENCODING). Data pushes are not checked.
SCRIPT_VERIFY_NULLFAILA failed signature check with a non-empty signature fails the script.
SCRIPT_FORK1_OPCODESEnables bignums: OP_BIN2BIGNUM, OP_SETBMD, OP_LSHIFT, OP_RSHIFT and bignum arithmetic.
SCRIPT_UPGRADE2_OPCODESSelects the behaviour after the second hard fork (Upgrade2), below.

The other flags in ScriptFlags (SCRIPT_VERIFY_STRICTENC, SCRIPT_VERIFY_CLEANSTACK, SCRIPT_VERIFY_DISCOURAGE_UPGRADABLE_NOPS, ...) can be set but change nothing.

SCRIPT_UPGRADE2_OPCODES​

SCRIPT_UPGRADE2_OPCODES is not in the defaults, so the interpreter behaves as the node does before Upgrade2. Two things change when you set it:

  • Bignum operand order. Before Upgrade2 the node passes the operands of a bignum arithmetic opcode the other way round, so a b OP_SUB computes b - a when either operand is a bignum. After Upgrade2 it computes a - b, like plain number arithmetic. This changes the result of OP_SUB, OP_DIV, OP_MOD, OP_LESSTHAN, OP_GREATERTHAN, OP_LESSTHANOREQUAL and OP_GREATERTHANOREQUAL; for the other binary opcodes the order makes no difference.
  • Opcodes run in a branch that is not taken. Before Upgrade2 the node runs every opcode from OP_IF to OP_ENDIF (0x63-0x68) even in a branch that is not taken, which includes OP_JUMP (0x65). After Upgrade2 only OP_IF, OP_NOTIF, OP_ELSE and OP_ENDIF run there.

Example​

import { DEFAULT_SCRIPT_FLAGS, ScriptFlags, ScriptMachine } from '@nexscript/interpreter';
import { asmToScript } from '@nexscript/utils';

const UPGRADE2 = DEFAULT_SCRIPT_FLAGS | ScriptFlags.SCRIPT_UPGRADE2_OPCODES;

function run(asm: string, flags: number) {
const machine = new ScriptMachine({ flags });
machine.beginStep(asmToScript(asm));
machine.continue();
return machine;
}

run('OP_5 OP_BIN2BIGNUM OP_3 OP_BIN2BIGNUM OP_SUB', DEFAULT_SCRIPT_FLAGS).stack; // [ BigNumItem { value: -2n } ]
run('OP_5 OP_BIN2BIGNUM OP_3 OP_BIN2BIGNUM OP_SUB', UPGRADE2).stack; // [ BigNumItem { value: 2n } ]

run('OP_0 OP_IF OP_JUMP OP_ENDIF OP_1', DEFAULT_SCRIPT_FLAGS).getError(); // 'BAD_OPCODE'
run('OP_0 OP_IF OP_JUMP OP_ENDIF OP_1', UPGRADE2).getError(); // 'OK'

Bignums​

With SCRIPT_FORK1_OPCODES set, a stack item can be a BigNumItem, an arbitrary-precision integer. OP_BIN2BIGNUM turns a byte item into one. Arithmetic, bitwise and shift opcodes take the bignum path when either operand is a bignum, and push a BigNumItem (comparisons push 0 or 1 as a bignum). Every bignum result is reduced modulo the bignum modulus, using a remainder that keeps the sign of the value. The modulus is 2^64 when a script starts; OP_SETBMD pops a new one, up to 2^4096.

class BigNumItem { constructor(public value: bigint) }
isBigNum(item: StackItem): item is BigNumItem
itemToBytes(item: StackItem): Uint8Array // a bignum's serialised form
decodeBigNum(bytes: Uint8Array): bigint

Example​

import { BigNumItem, ScriptMachine, itemToBytes } from '@nexscript/interpreter';
import { asmToScript } from '@nexscript/utils';

const machine = new ScriptMachine({ initialStack: [new BigNumItem(2n ** 70n)] });
machine.beginStep(asmToScript('OP_1 OP_ADD'));
machine.continue();
machine.stack; // [ BigNumItem { value: 1n } ]: 2^70 + 1 mod 2^64

const wide = new ScriptMachine({ initialStack: [new BigNumItem(2n ** 70n)] });
wide.beginStep(asmToScript(`${'00'.repeat(16)}01 OP_SETBMD OP_1 OP_ADD`)); // modulus 2^128
wide.continue();
wide.stack; // [ BigNumItem { value: 1180591620717411303425n } ]: 2^70 + 1

itemToBytes(new BigNumItem(-5n)); // Uint8Array(2) [ 5, 128 ]

Without SCRIPT_FORK1_OPCODES, OP_BIN2BIGNUM and OP_SETBMD fail with BAD_OPCODE, and OP_LSHIFT and OP_RSHIFT with DISABLED_OPCODE.

Limitations​

The interpreter covers the opcodes the compiler emits for most contracts, but not all of Nexa script. Check results that matter against a node.

Opcodes that fail with BAD_OPCODE. The machine does not implement:

  • Native introspection: OP_INPUTINDEX, OP_ACTIVEBYTECODE, OP_TXVERSION, OP_TXINPUTCOUNT, OP_TXOUTPUTCOUNT, OP_TXLOCKTIME, OP_UTXOVALUE, OP_UTXOBYTECODE, OP_OUTPOINTHASH, OP_INPUTBYTECODE, OP_INPUTSEQUENCENUMBER, OP_OUTPUTVALUE, OP_OUTPUTBYTECODE, OP_INPUTTYPE, OP_OUTPUTTYPE, OP_INPUTVALUE. Contracts that use tx.inputs, tx.outputs, this.activeBytecode and the other tx globals stop at the first of these.
  • OP_PARSE, so token introspection (tokenGroupId, tokenAmount, ...) does not run either.
  • OP_CHECKMULTISIG and OP_CHECKMULTISIGVERIFY.
  • OP_STORE, OP_LOAD, OP_PLACE, OP_EXEC and OP_MERKLEROOT.
  • OP_JUMP.
  • OP_NOP1 and OP_NOP4 to OP_NOP10, which the node treats as no-ops.
  • OP_2MUL, OP_2DIV and OP_INVERT, which are disabled on the node too.

Transaction state is supplied, not computed. OP_PUSH_TX_STATE reads the transaction-level values and group balances you pass in importedState; nothing derives them from a transaction. Without them it fails with DATA_REQUIRED.

Limits. maxOps defaults to 201, the node's limit for a plain script. A script template may count up to 50,000 ops, so pass a larger maxOps for big contracts, or they fail with OP_COUNT. Stack size is not limited.

Checks the node makes that the interpreter does not:

  • data pushes are not checked for minimal encoding (only numbers read from the stack are);
  • signature and public key encoding are not checked before OP_CHECKSIG, and there is no limit on signature checks;
  • a template's empty final stack is not checked (see succeeded()).