Skip to content

feat: add BitcoinSX (.sx) output backend#28

Open
icellan wants to merge 1 commit intomainfrom
feature/sx-backend
Open

feat: add BitcoinSX (.sx) output backend#28
icellan wants to merge 1 commit intomainfrom
feature/sx-backend

Conversation

@icellan
Copy link
Copy Markdown
Owner

@icellan icellan commented Apr 8, 2026

Summary

Adds a parallel compilation backend that emits BitcoinSX (.sx) text from the ANF IR, preserving high-level structure that the hex backend destroys during stack lowering.

  • Private methods become SX #macros with bare-name invocation
  • Bounded loops become SX repeat Nn ... end blocks (not unrolled)
  • If/else becomes SX if ... else ... endIf
  • Constructor parameters become SX .variable placeholders
  • All opcodes use SX camelCase names (dup, checkSig, hash160)
  • Stateful contract intrinsics (checkPreimage, deserializeState, addOutput, extractors) are fully supported
  • Specialized codegen (EC, SHA-256, SLH-DSA, BabyBear, Merkle) bridges through StackOp→SX conversion

Example output

P2PKH:

// P2PKH.sx — generated by Runar compiler
//
// Constructor:
//   .pubKeyHash: Addr

// Public method: unlock(sig: Sig, pubKey: PubKey)
dup
hash160
.pubKeyHash
equal
verify
checkSig

Contract with private method:

#requireOwner
  // params: sig: Sig
  .owner
  checkSig
  verify
end

// Public method: spend(sig: Sig)
requireOwner
true

Contract with loop:

0n
repeat 3n
  2n pick
  rot
  add
  over
  add
  swap
  1add
end
drop
.expected
numEqual

Architecture

The SX backend is a new pass 7 that operates on the same ANF IR as the existing hex backend (passes 5+6):

                                    ┌─ 05-stack-lower → 06-emit → hex/asm (existing)
ANF IR (Pass 4 output) ──┤
                                    └─ 07-sx-emit (NEW) → .sx text

New files:

  • packages/runar-compiler/src/passes/07-sx-emit.ts — Main emitter: orchestrates macros, dispatch, method bodies
  • packages/runar-compiler/src/passes/07-sx-stack.ts — SX stack scheduler: tracks named values on virtual stack, emits SX tokens, handles all 17 ANF value kinds
  • packages/runar-compiler/src/passes/07-sx-codegen-bridge.ts — OP→SX camelCase name table (80+ opcodes) + StackOp-to-SX text converter
  • packages/runar-compiler/src/ir/sx-ir.ts — SX output types
  • packages/runar-compiler/src/__tests__/07-sx-emit.test.ts — 41 tests

Modified files:

  • packages/runar-compiler/src/passes/05-stack-lower.ts — Exported lowerBindingsToOps() helper + stackDepth/peekStack accessors, allowing the SX backend to delegate complex intrinsics to the proven hex backend without code duplication
  • packages/runar-compiler/src/index.ts — Added emitSX compile option, wired pass 7 into pipeline
  • packages/runar-compiler/src/ir/artifact.ts + assembler.ts — Added sx?: string to RunarArtifact
  • packages/runar-compiler/src/ir/index.ts — Exported SX types
  • packages/runar-cli/src/bin.ts + commands/compile.ts — Added --sx CLI flag

Key design decisions

Delegation over duplication: Stateful contract intrinsics (checkPreimage, deserializeState, addOutput, output hash verification, preimage field extractors) and complex math builtins (pow, sqrt, gcd, log2, etc.) delegate to the existing hex backend via lowerBindingsToOps()stackOpsToSX() bridge. This avoids duplicating ~1000 lines of carefully tested opcode sequences while producing correct SX output for all contract types.

SX peephole optimizer: Removes redundant token patterns (swap swap, dup drop, false drop) from the SX output.

Macro calling convention: Before calling a #macro, all arguments are pushed onto the stack in parameter order. The macro body assumes args at known positions relative to stack top, and leaves its result (if any) on top.

Loop counter strategy: SX repeat doesn't provide an implicit loop counter. When the loop body references iterVar, the scheduler pushes a counter before the repeat block, increments at the end of each iteration body, and drops after end.

Non-fatal emission: SX emission failure produces a warning diagnostic, not an error. The hex output is always produced regardless of SX success.

CLI usage

runar compile MyContract.runar.ts --sx              # writes .sx + artifact JSON
runar compile MyContract.runar.ts --sx -o build/    # custom output dir
runar compile MyContract.runar.ts --sx --asm        # SX file + ASM to stdout

Test plan

  • 41 new tests in 07-sx-emit.test.ts covering all contract patterns
  • Full test suite: 1493/1493 passing (including 136 cross-compiler conformance)
  • Type-checks clean with tsc --noEmit
  • Verify SX output parses in the BitcoinSX IDE
  • Test with larger contracts from examples/ts/

Add a parallel compilation backend that emits BitcoinSX text from the
ANF IR, preserving high-level structure that the hex backend destroys:

- Private methods → SX #macros (called by bare name)
- Bounded loops → SX repeat Nn ... end blocks
- If/else → SX if ... else ... endIf
- Constructor params → SX .variable placeholders
- Opcodes → SX camelCase names (dup, checkSig, hash160, etc.)

The SX backend operates on the same ANF IR as the existing hex backend
(passes 5+6) but performs its own stack scheduling to emit human-readable
SX text instead of raw Bitcoin Script bytes.

Architecture:
- 07-sx-emit.ts: Main emitter — orchestrates macros, dispatch, method bodies
- 07-sx-stack.ts: SX stack scheduler — tracks named values, emits SX tokens
- 07-sx-codegen-bridge.ts: OP→SX name table + StackOp-to-SX converter

Stateful contract intrinsics (checkPreimage, deserializeState, addOutput,
extractors, output builders) and complex builtins (pow, sqrt, gcd, etc.)
delegate to the existing hex backend via lowerBindingsToOps(), avoiding
code duplication of ~1000 lines of opcode sequences.

Pipeline integration:
- New emitSX compile option triggers pass 7 alongside passes 5+6
- SX text attached to CompileResult.sx and RunarArtifact.sx
- SX emission failure is non-fatal (warning, not error)
- CLI: runar compile Contract.runar.ts --sx writes a .sx file

Test coverage: 41 tests covering P2PKH, HashLock, Escrow (multi-method
dispatch), if/else conditionals, private methods as macros, bounded loops
as repeat blocks, property initializers, Counter (stateful with
checkPreimage + state deserialization + output verification), and
MessageBoard (stateful with mixed readonly/mutable properties).
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a parallel BitcoinSX (.sx) text backend to the Runar compiler, emitting human-readable SX from ANF IR while preserving higher-level constructs (macros, loops, if/else) and delegating complex opcode sequences to the existing stack-lowering backend.

Changes:

  • Introduces Pass 7 SX emission pipeline (emitter + stack scheduler + StackOp→SX bridge).
  • Wires SX output into compiler options/artifact/CLI (emitSX, --sx) and exposes SX IR types.
  • Adds a dedicated SX test suite covering common contract patterns.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
packages/runar-compiler/src/passes/07-sx-stack.ts SX stack scheduler, control-flow emission, and delegation to hex backend for complex intrinsics
packages/runar-compiler/src/passes/07-sx-emit.ts High-level SX emitter: header, macros for private methods, public method body/dispatch
packages/runar-compiler/src/passes/07-sx-codegen-bridge.ts Converts StackOp sequences into SX tokens via opcode name mapping
packages/runar-compiler/src/passes/05-stack-lower.ts Exposes lowering helper and stack inspection to support SX delegation
packages/runar-compiler/src/ir/sx-ir.ts SX output data structures (sections + emit result)
packages/runar-compiler/src/ir/index.ts Re-exports SX IR types
packages/runar-compiler/src/ir/artifact.ts Extends artifact type with optional sx
packages/runar-compiler/src/artifact/assembler.ts Extends assembled artifact shape with optional sx
packages/runar-compiler/src/index.ts Adds emitSX compile option and attaches SX text to result/artifact
packages/runar-compiler/src/tests/07-sx-emit.test.ts Adds tests for SX output across stateless/stateful patterns
packages/runar-cli/src/commands/compile.ts Adds --sx plumbing to compiler invocation and writes .sx file
packages/runar-cli/src/bin.ts Adds --sx CLI option

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +269 to +284
/**
* Pre-analyze a private method to determine its stack effect.
* Consumes = param count. Produces = 1 unless the method is void
* (ends in assert or update_prop without producing a value).
*/
export function analyzeMethodStackEffect(method: ANFMethod): MethodStackEffect {
const consumes = method.params.length;
const body = method.body;
if (body.length === 0) return { consumes, produces: 0 };

const lastValue = body[body.length - 1]!.value;
// Methods ending in assert or update_prop are void (produce no result)
if (lastValue.kind === 'assert' || lastValue.kind === 'update_prop') {
return { consumes, produces: 0 };
}
return { consumes, produces: 1 };
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

analyzeMethodStackEffect only treats methods ending in assert/update_prop as void, but private helpers can be void while ending in if/loop (e.g., examples/ts/tic-tac-toe/TicTacToe.runar.ts:170-176 ends with an if containing only asserts). This will miscompute produces as 1, causing macro-call stack tracking to push a phantom result and desync subsequent stack scheduling. Consider computing produces by analyzing the terminal binding recursively (e.g., if => both branches produce, loop/assert/update_prop => 0), or by threading return/void info from typechecking.

Suggested change
/**
* Pre-analyze a private method to determine its stack effect.
* Consumes = param count. Produces = 1 unless the method is void
* (ends in assert or update_prop without producing a value).
*/
export function analyzeMethodStackEffect(method: ANFMethod): MethodStackEffect {
const consumes = method.params.length;
const body = method.body;
if (body.length === 0) return { consumes, produces: 0 };
const lastValue = body[body.length - 1]!.value;
// Methods ending in assert or update_prop are void (produce no result)
if (lastValue.kind === 'assert' || lastValue.kind === 'update_prop') {
return { consumes, produces: 0 };
}
return { consumes, produces: 1 };
function getNestedBindingList(
value: Record<string, unknown>,
...keys: string[]
): ANFBinding[] | null {
for (const key of keys) {
const nested = value[key];
if (Array.isArray(nested)) return nested as ANFBinding[];
}
return null;
}
function terminalBindingProducesValue(binding: ANFBinding): boolean {
const value = binding.value as ANFValue & Record<string, unknown>;
switch (value.kind) {
case 'assert':
case 'update_prop':
case 'loop':
return false;
case 'if': {
const thenBody = getNestedBindingList(value, 'thenBody', 'thenBindings', 'thenBranch');
const elseBody = getNestedBindingList(value, 'elseBody', 'elseBindings', 'elseBranch');
// If this IR variant carries branch bodies directly, both branches must
// produce for the if-expression to produce.
if (thenBody && elseBody) {
return bindingsProduceValue(thenBody) && bindingsProduceValue(elseBody);
}
// Fall back conservatively to the previous behavior for unsupported
// shapes rather than changing semantics for other IR nodes.
return true;
}
default:
return true;
}
}
function bindingsProduceValue(bindings: readonly ANFBinding[]): boolean {
if (bindings.length === 0) return false;
return terminalBindingProducesValue(bindings[bindings.length - 1]!);
}
/**
* Pre-analyze a private method to determine its stack effect.
* Consumes = param count. Produces = 1 only when the terminal binding
* recursively yields a value.
*/
export function analyzeMethodStackEffect(method: ANFMethod): MethodStackEffect {
const consumes = method.params.length;
return {
consumes,
produces: bindingsProduceValue(method.body) ? 1 : 0,
};

Copilot uses AI. Check for mistakes.
if (!terminal) {
this.stackMap.pop();
this.emit('verify');
this.stackMap.push(null);
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lowerAssert pushes null onto stackMap after emitting verify, but verify (OP_VERIFY) consumes the asserted value and leaves nothing on the physical stack. This makes stackMap.depth larger than the real stack and can produce incorrect pick/roll depths later. Align with the hex backend’s lowerAssert (05-stack-lower.ts:1880-1889) by not pushing a placeholder for non-terminal asserts.

Suggested change
this.stackMap.push(null);

Copilot uses AI. Check for mistakes.
Comment on lines +1008 to +1009
for (let i = this.stackMap.depth - 1; i >= 0; i--) {
stackState.unshift(this.stackMap.peekAtDepth(i) ?? `__anon_${i}`);
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

delegateToHexBackend builds stackState using unshift while iterating depth-1..0, which reverses the intended bottom-to-top order. LoweringContext expects params in bottom-to-top order (05-stack-lower.ts:343-346), so this reversal will cause delegated lowering to operate on the wrong stack layout. Build stackState in bottom-to-top order (e.g., iterate bottom→top and push).

Suggested change
for (let i = this.stackMap.depth - 1; i >= 0; i--) {
stackState.unshift(this.stackMap.peekAtDepth(i) ?? `__anon_${i}`);
for (let i = 0; i < this.stackMap.depth; i++) {
stackState.push(this.stackMap.peekAtDepth(i) ?? `__anon_${i}`);

Copilot uses AI. Check for mistakes.
Comment on lines +4618 to +4622
// Extract the final stack state from the context
const depth = ctx.stackDepth;
const finalStack: (string | null)[] = [];
for (let i = depth - 1; i >= 0; i--) {
finalStack.unshift(ctx.peekStack(i));
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lowerBindingsToOps documents finalStack as bottom-to-top, but the extraction loop uses unshift while iterating depth-1..0, producing a top-to-bottom array. This will invert stack reconstruction in the SX backend when syncing result.finalStack. Return finalStack in bottom-to-top order (e.g., push(ctx.peekStack(i)) when iterating bottom→top).

Suggested change
// Extract the final stack state from the context
const depth = ctx.stackDepth;
const finalStack: (string | null)[] = [];
for (let i = depth - 1; i >= 0; i--) {
finalStack.unshift(ctx.peekStack(i));
// Extract the final stack state from the context in bottom-to-top order.
const depth = ctx.stackDepth;
const finalStack: (string | null)[] = [];
for (let i = depth - 1; i >= 0; i--) {
finalStack.push(ctx.peekStack(i));

Copilot uses AI. Check for mistakes.
Comment on lines +971 to +980
// If the property already exists on the stack, remove the old value
if (this.stackMap.has(propName) && !this._insideBranch) {
const oldDepth = this.stackMap.findDepth(propName);
if (oldDepth === 0) {
// Old is on top, new was just popped — swap and nip
// Actually: we popped the value, we need to re-push and remove old
}
// Remove old entry from stack
this.stackMap.removeAtDepth(oldDepth);
// Emit cleanup
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The oldDepth === 0 branch contains a leftover comment about needing to “re-push and remove old” but no code, and the actual cleanup is handled below via nip. Please remove or clarify this block to avoid suggesting incomplete logic.

Suggested change
// If the property already exists on the stack, remove the old value
if (this.stackMap.has(propName) && !this._insideBranch) {
const oldDepth = this.stackMap.findDepth(propName);
if (oldDepth === 0) {
// Old is on top, new was just popped — swap and nip
// Actually: we popped the value, we need to re-push and remove old
}
// Remove old entry from stack
this.stackMap.removeAtDepth(oldDepth);
// Emit cleanup
// If the property already exists on the stack, remove the old value.
// The cleanup below handles both cases, including `oldDepth === 0` via `nip`.
if (this.stackMap.has(propName) && !this._insideBranch) {
const oldDepth = this.stackMap.findDepth(propName);
this.stackMap.removeAtDepth(oldDepth);

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants