neo-decompiler 0.11.0

Neo N3 NEF decompiler: parse, disassemble, lift bytecode to high-level pseudocode and C# skeletons, with a CLI, JSON reports, and optional WebAssembly bindings.
Documentation
import { SYSCALLS } from "./generated/syscalls.js";
import { jumpTarget, stripOuterParens } from "./high-level-utils.js";
import { hex16, hex32, hexOffset, upperHex } from "./util.js";
import { lookup as lookupNativeContract } from "./native-contracts.js";

// Hashes lifted from `src/syscalls_generated.rs`. Mirrors the constants in
// `src/syscalls.rs` and lets the high-level emitter recognise the devpack's
// cross-contract-call syscalls.
const CONTRACT_CALL_HASH = 0x525b7d62;
const CONTRACT_CALL_NATIVE_HASH = 0x677bf71a;

export function tryInternalCall(state, instruction) {
  const mnemonic = instruction.opcode.mnemonic;
  if (mnemonic !== "CALL" && mnemonic !== "CALL_L") {
    return false;
  }
  const target = jumpTarget(instruction);
  if (target === null) {
    return false;
  }

  // Match Rust's `call_0x{target:04X}` fallback when the target isn't
  // resolvable through `methodLabelsByOffset`: distinct prefix
  // (`call_` vs `sub_`) signals "unresolved internal call", and
  // uppercase hex matches both the offset suffix and Rust's format.
  // Earlier this used `sub_0x` with lowercase digits, conflating
  // OOB/unknown calls with regular helper definitions.
  const callee =
    state.context.methodLabelsByOffset.get(target) ?? `call_0x${hexOffset(target)}`;
  const argCount = state.context.methodArgCountsByOffset.get(target) ?? 0;
  const args = popCallArguments(state, instruction, callee, argCount);
  const temp = `t${state.nextTempId}`;
  state.nextTempId += 1;
  state.statements.push(`let ${temp} = ${callee}(${args.join(", ")});`);
  state.stack.push(temp);
  return true;
}

export function tryIndirectCall(state, instruction) {
  if (instruction.opcode.mnemonic !== "CALLA") {
    return false;
  }

  const targetExpr = stripOuterParens(state.stack.pop() ?? "???");
  const resolvedTarget =
    state.pointerTargetsByExpression.get(targetExpr) ??
    state.pointerTargetsBySlot.get(targetExpr) ??
    null;

  if (resolvedTarget !== null) {
    const callee =
      state.context.methodLabelsByOffset.get(resolvedTarget) ??
      `sub_0x${hexOffset(resolvedTarget)}`;
    const argCount = state.context.methodArgCountsByOffset.get(resolvedTarget) ?? 0;
    const args = popCallArguments(state, instruction, callee, argCount);
    state.stack.push(`${callee}(${args.join(", ")})`);
  } else {
    state.stack.push(`calla(${targetExpr})`);
  }
  return true;
}

export function tryTokenCall(state, instruction) {
  if (instruction.opcode.mnemonic !== "CALLT") {
    return false;
  }
  const index = instruction.operand?.kind === "U16" ? instruction.operand.value : null;
  if (index === null) {
    return false;
  }
  const resolved = state.context.calltLabels[index];
  if (resolved === undefined) {
    // Unresolved/out-of-range token: mirror Rust (jumps.rs emit_indirect_call) —
    // bind the bare `callt(0xHEX)` token call to a temp WITHOUT consuming or
    // appending arguments. The fallback label already reads as a call, so the
    // previous `${label}(${args})` produced an invalid double-call
    // `callt(0xHEX)()`.
    const temp = `t${state.nextTempId}`;
    state.nextTempId += 1;
    state.statements.push(`let ${temp} = callt(0x${hex16(index)});`);
    state.stack.push(temp);
    return true;
  }
  const argCount = state.context.calltParamCounts[index] ?? 0;
  const returnsValue = state.context.calltReturnsValue[index] ?? true;
  const args = popCallArguments(state, instruction, resolved, argCount);
  const expression = `${resolved}(${args.join(", ")})`;
  if (returnsValue) {
    state.stack.push(expression);
  } else {
    state.statements.push(`${expression};`);
  }
  return true;
}

// Pop `argCount` values off the stack to use as call arguments. When the
// stack underflows we substitute `???` (matching the syscall path) and
// emit a structured warning + trace-style note so the user sees the
// hazard in both the rendered output and the `warnings` array. The
// previous fallback string `/* stack_underflow */` rendered as a
// C-style comment in argument position, which was awkward and
// inconsistent with the syscall path.
function popCallArguments(state, instruction, calleeLabel, argCount) {
  const args = [];
  let missingArgument = false;
  for (let index = 0; index < argCount; index += 1) {
    const value = state.stack.pop();
    if (value === undefined) {
      missingArgument = true;
      args.push("???");
    } else {
      args.push(stripOuterParens(value));
    }
  }
  if (missingArgument) {
    const message = `missing call argument values for ${calleeLabel} (substituted ???)`;
    state.statements.push(`// warning: ${message}`);
    state.warnings.push(
      `high-level: 0x${instruction.offset.toString(16).padStart(4, "0").toUpperCase()}: ${message}`,
    );
  }
  return args;
}

export function trySyscall(state, instruction) {
  if (instruction.opcode.mnemonic !== "SYSCALL") {
    return false;
  }
  const hash =
    instruction.operand?.kind === "Syscall" ? instruction.operand.value : null;
  if (hash === null) {
    return false;
  }

  // `System.Contract.Call(scriptHash, method, callFlags, args)` is the
  // devpack's cross-contract-call path. Lift it into
  // `ContractName.method(args)` form so the rendered code reads as a
  // direct call instead of the opaque `syscall("System.Contract.Call", …)`
  // wrapper. Mirrors `emit_contract_call` in Rust's
  // `high_level/emitter/stack/expressions/flow.rs`.
  if (hash === CONTRACT_CALL_HASH) {
    return tryContractCall(state, instruction);
  }
  if (hash === CONTRACT_CALL_NATIVE_HASH) {
    // Deprecated integer-indexed native path; modern devpacks use
    // CONTRACT_CALL_HASH with the native hash instead, so this only
    // fires on legacy bytecode.
    state.stack.pop();
    state.statements.push('System.Contract.CallNative();');
    return true;
  }

  const info = SYSCALLS.get(hash) ?? null;
  const argCount = info?.param_count ?? 0;
  const returnsValue = info?.returns_value ?? true;
  // Syscall arguments are pushed right-to-left (Cdecl) by the devpack,
  // so parameters[0] sits on top of the stack at SYSCALL and
  // `ApplicationEngine.OnSysCall` pops it first: pop order already
  // equals declaration order — do NOT reverse (matches the
  // internal-call path in popCallArguments).
  const args = [];
  let missingArgument = false;
  for (let index = 0; index < argCount; index += 1) {
    const value = state.stack.pop();
    if (value === undefined) {
      missingArgument = true;
      args.push("???");
    } else {
      args.push(stripOuterParens(value));
    }
  }

  const call = info
    ? args.length > 0
      ? `${info.name}(${args.join(", ")})`
      : `${info.name}()`
    : `syscall(0x${hex32(hash)})`;

  if (missingArgument && info) {
    let message = `missing syscall argument values for ${info.name} (substituted ???)`;
    const context = missingSyscallArgumentContext(state, info.name);
    if (context) {
      message += `; ${context}`;
    }
    state.statements.push(`// warning: ${message}`);
    state.warnings.push(
      `high-level: 0x${instruction.offset.toString(16).padStart(4, "0").toUpperCase()}: ${message}`,
    );
  }
  if (!info) {
    // Surface the fact that the syscall hash isn't in our generated
    // table — without this annotation the user just sees a bare hex
    // call and has to guess. Mirrors the Rust port's
    // `// unknown syscall` trailing comment.
    const message = `unknown syscall 0x${hex32(hash)}`;
    state.statements.push(`// warning: ${message}`);
    state.warnings.push(
      `high-level: 0x${instruction.offset.toString(16).padStart(4, "0").toUpperCase()}: ${message}`,
    );
  }

  if (returnsValue) {
    const temp = `t${state.nextTempId}`;
    state.nextTempId += 1;
    state.statements.push(`let ${temp} = ${call};`);
    state.stack.push(temp);
  } else {
    state.statements.push(`${call};`);
  }
  return true;
}

// Lift `System.Contract.Call(scriptHash, method, callFlags, args)` into a
// direct `ContractName.method(args)` call.
//
// The devpack pushes the four arguments right-to-left (Cdecl), so the stack
// top-to-bottom at the SYSCALL is `scriptHash, method, callFlags, args`.
// We pop in that order so the popped values already read as the
// `ContractName.method(args)` argument order. The contract hash is looked
// up against the bundled native-contract table; when the hash isn't
// recognised (custom contracts) we fall back to the hex form
// `0xHASH.method(args)` so the reader still sees the target without having
// to grep the manifest.
//
// Fall-back behaviour: if the contract hash on the stack isn't a tracked
// 20-byte literal (synthetic test bytecode, or a hash produced by a
// runtime computation), the expansion would render the call as
// `t0.t2(t3)` — syntactically valid but unreadable. In that case we
// fall back to the legacy `syscall("System.Contract.Call", hash, …)`
// form so the reader still sees the devpack's intended syscall.
function tryContractCall(state, instruction) {
  const contractHashExpr = state.stack.pop();
  const methodExpr = state.stack.pop();
  const flagsExpr = state.stack.pop();
  const argsExpr = state.stack.pop();

  const contractHashBytes = contractHashBytesFromLiteral(state, contractHashExpr);
  if (contractHashBytes === null) {
    // Hash isn't a tracked 20-byte literal — fall back to the syscall form.
    state.statements.push(
      `System.Contract.Call(${contractHashExpr ?? "???"}, ${methodExpr ?? "???"}, ${flagsExpr ?? "???"}, ${argsExpr ?? "???"});`,
    );
    if (instruction?.operand?.value !== undefined) {
      state.warnings.push(
        `high-level: 0x${instruction.offset.toString(16).padStart(4, "0").toUpperCase()}: contract-call hash was not a tracked 20-byte literal; fell back to syscall form`,
      );
    }
    return true;
  }

  const contractLabel = contractLabelFor(contractHashBytes);
  const methodLabel = methodLabelFor(state, methodExpr);
  const argsLabel = argsExpr ?? "???";

  const callExpr = `${contractLabel}.${methodLabel}(${argsLabel})`;
  const temp = `t${state.nextTempId}`;
  state.nextTempId += 1;
  state.statements.push(`let ${temp} = ${callExpr};`);
  state.stack.push(temp);
  return true;
}

// Resolve the contract-hash label: native-contract name when the hash
// matches the bundled table, hex literal otherwise so the reader can still
// grep the manifest for the contract name.
function contractLabelFor(hashBytes) {
  const contract = lookupNativeContract(hashBytes);
  if (contract) {
    return contract.name;
  }
  return `0x${upperHex(hashBytes)}`;
}

// Resolve the method name: prefer a tracked string literal, fall back to
// the temp expression so the call site still references the actual value
// instead of an opaque `???`.
function methodLabelFor(state, methodExpr) {
  if (methodExpr === undefined) {
    return "?";
  }
  const literal = state?.literalValues?.get?.(methodExpr);
  if (literal && literal.kind === "String") {
    return literal.value;
  }
  return methodExpr;
}

function contractHashBytesFromLiteral(state, contractHashExpr) {
  if (!contractHashExpr) {
    return null;
  }
  const literal = state?.literalValues?.get?.(contractHashExpr);
  if (literal && literal.kind === "ContractHash") {
    return Array.from(literal.value);
  }
  return null;
}

function missingSyscallArgumentContext(state, syscallName) {
  const previousInstruction = state.previousInstruction;
  const previousStoreInfo = state.previousStoreInfo;
  if (
    !previousInstruction ||
    !previousStoreInfo ||
    previousInstruction.offset !== previousStoreInfo.offset
  ) {
    return null;
  }

  const storedValue = previousStoreInfo.storedPacked
    ? "a packed value"
    : "the last produced value";
  return `preceding ${previousStoreInfo.opcode} stored ${storedValue} into ${previousStoreInfo.slotLabel}; no value remains on the evaluation stack before ${syscallName}`;
}