diff --git a/packages/runar-cli/src/bin.ts b/packages/runar-cli/src/bin.ts
index 6dab5afc..f0116c9e 100644
--- a/packages/runar-cli/src/bin.ts
+++ b/packages/runar-cli/src/bin.ts
@@ -32,6 +32,7 @@ program
.option('-o, --output
', 'output directory', './artifacts')
.option('--ir', 'include IR in artifact')
.option('--asm', 'print ASM to stdout')
+ .option('--sx', 'emit BitcoinSX (.sx) text output')
.option('--disable-constant-folding', 'disable ANF constant folding pass')
.action(compileCommand);
diff --git a/packages/runar-cli/src/commands/compile.ts b/packages/runar-cli/src/commands/compile.ts
index d3385875..8bdd28ab 100644
--- a/packages/runar-cli/src/commands/compile.ts
+++ b/packages/runar-cli/src/commands/compile.ts
@@ -10,6 +10,7 @@ interface CompileOptions {
output: string;
ir?: boolean;
asm?: boolean;
+ sx?: boolean;
disableConstantFolding?: boolean;
}
@@ -56,7 +57,7 @@ export async function compileCommand(
// Dynamically import the compiler to avoid hard failures if it's not
// yet fully built (the compiler package may still be under development).
- type CompileFn = (source: string, options?: { fileName?: string; disableConstantFolding?: boolean }) => unknown;
+ type CompileFn = (source: string, options?: { fileName?: string; disableConstantFolding?: boolean; emitSX?: boolean }) => unknown;
let compile: CompileFn | null = null;
try {
// In monorepo/dev mode, prefer the source entry so conformance and CLI
@@ -111,7 +112,7 @@ export async function compileCommand(
let compileResult: CompileResultLike;
try {
- compileResult = compile(source, { fileName: resolvedPath, disableConstantFolding: options.disableConstantFolding }) as CompileResultLike;
+ compileResult = compile(source, { fileName: resolvedPath, disableConstantFolding: options.disableConstantFolding, emitSX: options.sx }) as CompileResultLike;
} catch (err) {
console.error(` Compilation error: ${(err as Error).message}`);
errorCount++;
@@ -154,6 +155,13 @@ export async function compileCommand(
);
console.log(` Artifact written: ${artifactPath}`);
+ // Write SX file if requested
+ if (options.sx && typeof artifact['sx'] === 'string') {
+ const sxPath = path.join(outputDir, `${baseName}.sx`);
+ fs.writeFileSync(sxPath, artifact['sx'] + '\n');
+ console.log(` SX written: ${sxPath}`);
+ }
+
// Print ASM if requested
if (options.asm && typeof artifact['asm'] === 'string') {
console.log('');
diff --git a/packages/runar-compiler/src/__tests__/07-sx-emit.test.ts b/packages/runar-compiler/src/__tests__/07-sx-emit.test.ts
new file mode 100644
index 00000000..dc739f5d
--- /dev/null
+++ b/packages/runar-compiler/src/__tests__/07-sx-emit.test.ts
@@ -0,0 +1,472 @@
+import { describe, it, expect } from 'vitest';
+import { compile } from '../index.js';
+
+// ---------------------------------------------------------------------------
+// Contract sources
+// ---------------------------------------------------------------------------
+
+const P2PKH_SOURCE = `
+class P2PKH extends SmartContract {
+ readonly pk: PubKey;
+
+ constructor(pk: PubKey) {
+ super(pk);
+ this.pk = pk;
+ }
+
+ public unlock(sig: Sig) {
+ assert(checkSig(sig, this.pk));
+ }
+}
+`;
+
+const HASH_LOCK_SOURCE = `
+class HashLock extends SmartContract {
+ readonly hashValue: Sha256;
+
+ constructor(hashValue: Sha256) {
+ super(hashValue);
+ this.hashValue = hashValue;
+ }
+
+ public unlock(preimage: ByteString) {
+ assert(sha256(preimage) === this.hashValue);
+ }
+}
+`;
+
+const ESCROW_SOURCE = `
+class Escrow extends SmartContract {
+ readonly buyer: PubKey;
+ readonly seller: PubKey;
+
+ constructor(buyer: PubKey, seller: PubKey) {
+ super(buyer, seller);
+ this.buyer = buyer;
+ this.seller = seller;
+ }
+
+ public release(sig: Sig) {
+ assert(checkSig(sig, this.buyer));
+ }
+
+ public refund(sig: Sig) {
+ assert(checkSig(sig, this.seller));
+ }
+}
+`;
+
+const IF_ELSE_SOURCE = `
+class Conditional extends SmartContract {
+ readonly threshold: bigint;
+
+ constructor(threshold: bigint) {
+ super(threshold);
+ this.threshold = threshold;
+ }
+
+ public check(value: bigint) {
+ if (value > this.threshold) {
+ assert(value > 0n);
+ } else {
+ assert(value === 0n);
+ }
+ }
+}
+`;
+
+const PRIVATE_METHOD_SOURCE = `
+class WithHelper extends SmartContract {
+ readonly owner: PubKey;
+
+ constructor(owner: PubKey) {
+ super(owner);
+ this.owner = owner;
+ }
+
+ private requireOwner(sig: Sig) {
+ assert(checkSig(sig, this.owner));
+ }
+
+ public spend(sig: Sig) {
+ this.requireOwner(sig);
+ assert(true);
+ }
+}
+`;
+
+const LOOP_SOURCE = `
+class Summer extends SmartContract {
+ readonly expected: bigint;
+
+ constructor(expected: bigint) {
+ super(expected);
+ this.expected = expected;
+ }
+
+ public verify(start: bigint) {
+ let sum: bigint = 0n;
+ for (let i = 0n; i < 3n; i++) {
+ sum = sum + start + i;
+ }
+ assert(sum === this.expected);
+ }
+}
+`;
+
+const COUNTER_SOURCE = `
+class Counter extends StatefulSmartContract {
+ count: bigint;
+
+ constructor(count: bigint) {
+ super(count);
+ this.count = count;
+ }
+
+ public increment() {
+ this.count++;
+ }
+}
+`;
+
+const MESSAGE_BOARD_SOURCE = `
+class MessageBoard extends StatefulSmartContract {
+ message: ByteString;
+ readonly owner: PubKey;
+
+ constructor(message: ByteString, owner: PubKey) {
+ super(message, owner);
+ this.message = message;
+ this.owner = owner;
+ }
+
+ public post(newMessage: ByteString) {
+ this.message = newMessage;
+ }
+
+ public burn(sig: Sig) {
+ assert(checkSig(sig, this.owner));
+ }
+}
+`;
+
+const INITIALIZER_SOURCE = `
+class WithDefault extends SmartContract {
+ readonly limit: bigint = 100n;
+ readonly pk: PubKey;
+
+ constructor(pk: PubKey) {
+ super(pk);
+ this.pk = pk;
+ }
+
+ public check(value: bigint) {
+ assert(value <= this.limit);
+ assert(checkSig(toByteString('00'), this.pk));
+ }
+}
+`;
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe('Pass 7: SX Emit', () => {
+
+ describe('emitSX option', () => {
+ it('does not produce SX by default', () => {
+ const result = compile(P2PKH_SOURCE);
+ expect(result.sx).toBeUndefined();
+ });
+
+ it('produces SX when emitSX is true', () => {
+ const result = compile(P2PKH_SOURCE, { emitSX: true });
+ expect(result.success).toBe(true);
+ expect(result.sx).toBeDefined();
+ expect(typeof result.sx).toBe('string');
+ expect(result.sx!.length).toBeGreaterThan(0);
+ });
+
+ it('attaches SX to artifact', () => {
+ const result = compile(P2PKH_SOURCE, { emitSX: true });
+ expect(result.artifact).toBeDefined();
+ expect(result.artifact!.sx).toBeDefined();
+ expect(result.artifact!.sx).toBe(result.sx);
+ });
+
+ it('still produces hex output alongside SX', () => {
+ const result = compile(P2PKH_SOURCE, { emitSX: true });
+ expect(result.scriptHex).toBeDefined();
+ expect(result.scriptAsm).toBeDefined();
+ });
+ });
+
+ describe('P2PKH — stateless, single method, property placeholder', () => {
+ it('includes contract header', () => {
+ const result = compile(P2PKH_SOURCE, { emitSX: true });
+ expect(result.sx).toContain('P2PKH.sx');
+ expect(result.sx).toContain('generated by Runar');
+ });
+
+ it('includes constructor variable for pk', () => {
+ const result = compile(P2PKH_SOURCE, { emitSX: true });
+ expect(result.sx).toContain('.pk');
+ });
+
+ it('uses SX camelCase opcodes', () => {
+ const result = compile(P2PKH_SOURCE, { emitSX: true });
+ // P2PKH should contain checkSig
+ expect(result.sx).toContain('checkSig');
+ });
+
+ it('does not contain OP_ prefixed names', () => {
+ const result = compile(P2PKH_SOURCE, { emitSX: true });
+ // SX output should use camelCase, not OP_ prefix
+ expect(result.sx).not.toMatch(/OP_[A-Z]/);
+ });
+
+ it('does not contain hex-encoded opcodes', () => {
+ const result = compile(P2PKH_SOURCE, { emitSX: true });
+ // SX output should be text, not hex
+ expect(result.sx).not.toMatch(/^[0-9a-f]{4,}$/m);
+ });
+ });
+
+ describe('HashLock — builtin call (sha256) + equality check', () => {
+ it('compiles with SX', () => {
+ const result = compile(HASH_LOCK_SOURCE, { emitSX: true });
+ expect(result.success).toBe(true);
+ expect(result.sx).toBeDefined();
+ });
+
+ it('contains sha256 opcode', () => {
+ const result = compile(HASH_LOCK_SOURCE, { emitSX: true });
+ expect(result.sx).toContain('sha256');
+ });
+
+ it('contains .hashValue variable', () => {
+ const result = compile(HASH_LOCK_SOURCE, { emitSX: true });
+ expect(result.sx).toContain('.hashValue');
+ });
+
+ it('contains equal (bytes comparison)', () => {
+ const result = compile(HASH_LOCK_SOURCE, { emitSX: true });
+ // sha256 result compared with === on ByteString types → OP_EQUAL
+ expect(result.sx).toContain('equal');
+ });
+ });
+
+ describe('Escrow — multi-method dispatch', () => {
+ it('compiles with SX', () => {
+ const result = compile(ESCROW_SOURCE, { emitSX: true });
+ expect(result.success).toBe(true);
+ expect(result.sx).toBeDefined();
+ });
+
+ it('contains dispatch structure', () => {
+ const result = compile(ESCROW_SOURCE, { emitSX: true });
+ // Multi-method → if/else dispatch
+ expect(result.sx).toContain('numEqual');
+ expect(result.sx).toContain('if');
+ expect(result.sx).toContain('else');
+ expect(result.sx).toContain('endIf');
+ });
+
+ it('contains both method comments', () => {
+ const result = compile(ESCROW_SOURCE, { emitSX: true });
+ expect(result.sx).toContain('release');
+ expect(result.sx).toContain('refund');
+ });
+
+ it('contains .buyer and .seller variables', () => {
+ const result = compile(ESCROW_SOURCE, { emitSX: true });
+ expect(result.sx).toContain('.buyer');
+ expect(result.sx).toContain('.seller');
+ });
+ });
+
+ describe('If/else — conditional branches', () => {
+ it('compiles with SX', () => {
+ const result = compile(IF_ELSE_SOURCE, { emitSX: true });
+ expect(result.success).toBe(true);
+ expect(result.sx).toBeDefined();
+ });
+
+ it('contains if/else/endIf', () => {
+ const result = compile(IF_ELSE_SOURCE, { emitSX: true });
+ expect(result.sx).toContain('if');
+ expect(result.sx).toContain('else');
+ expect(result.sx).toContain('endIf');
+ });
+
+ it('contains .threshold variable', () => {
+ const result = compile(IF_ELSE_SOURCE, { emitSX: true });
+ expect(result.sx).toContain('.threshold');
+ });
+ });
+
+ describe('Private methods — macros', () => {
+ it('compiles with SX', () => {
+ const result = compile(PRIVATE_METHOD_SOURCE, { emitSX: true });
+ expect(result.success).toBe(true);
+ expect(result.sx).toBeDefined();
+ });
+
+ it('contains #macro definition for private method', () => {
+ const result = compile(PRIVATE_METHOD_SOURCE, { emitSX: true });
+ expect(result.sx).toContain('#requireOwner');
+ expect(result.sx).toMatch(/#requireOwner[\s\S]*?end/);
+ });
+
+ it('contains macro call in public method body', () => {
+ const result = compile(PRIVATE_METHOD_SOURCE, { emitSX: true });
+ // The macro should be called by bare name in the public method
+ const lines = result.sx!.split('\n');
+ const macroDefLine = lines.findIndex(l => l.includes('#requireOwner'));
+ const endLine = lines.findIndex((l, i) => i > macroDefLine && l.trim() === 'end');
+ // After the macro definition, the public method body should call it
+ const afterMacro = lines.slice(endLine + 1);
+ const callLine = afterMacro.find(l => l.trim() === 'requireOwner');
+ expect(callLine).toBeDefined();
+ });
+
+ it('macro contains checkSig and .owner', () => {
+ const result = compile(PRIVATE_METHOD_SOURCE, { emitSX: true });
+ // Extract macro body
+ const match = result.sx!.match(/#requireOwner([\s\S]*?)end/);
+ expect(match).not.toBeNull();
+ const macroBody = match![1]!;
+ expect(macroBody).toContain('checkSig');
+ expect(macroBody).toContain('.owner');
+ });
+ });
+
+ describe('Bounded loop — repeat', () => {
+ it('compiles with SX', () => {
+ const result = compile(LOOP_SOURCE, { emitSX: true });
+ expect(result.success).toBe(true);
+ expect(result.sx).toBeDefined();
+ });
+
+ it('contains repeat ... end block', () => {
+ const result = compile(LOOP_SOURCE, { emitSX: true });
+ expect(result.sx).toContain('repeat 3n');
+ expect(result.sx).toContain('end');
+ });
+
+ it('does not unroll the loop', () => {
+ const result = compile(LOOP_SOURCE, { emitSX: true });
+ // Should have exactly one repeat block, not 3 copies of the body
+ const repeatCount = (result.sx!.match(/repeat/g) || []).length;
+ expect(repeatCount).toBe(1);
+ });
+
+ it('contains .expected variable', () => {
+ const result = compile(LOOP_SOURCE, { emitSX: true });
+ expect(result.sx).toContain('.expected');
+ });
+ });
+
+ describe('Property initializers', () => {
+ it('compiles with SX', () => {
+ const result = compile(INITIALIZER_SOURCE, { emitSX: true });
+ expect(result.success).toBe(true);
+ expect(result.sx).toBeDefined();
+ });
+
+ it('uses literal value for initialized property', () => {
+ const result = compile(INITIALIZER_SOURCE, { emitSX: true });
+ // limit = 100n should appear as a literal, not as .limit variable
+ expect(result.sx).toContain('100n');
+ });
+
+ it('uses .variable for non-initialized property', () => {
+ const result = compile(INITIALIZER_SOURCE, { emitSX: true });
+ expect(result.sx).toContain('.pk');
+ });
+ });
+
+ describe('Counter — stateful contract', () => {
+ it('compiles with SX', () => {
+ const result = compile(COUNTER_SOURCE, { emitSX: true });
+ expect(result.success).toBe(true);
+ expect(result.sx).toBeDefined();
+ });
+
+ it('contains codeSeparator for checkPreimage', () => {
+ const result = compile(COUNTER_SOURCE, { emitSX: true });
+ expect(result.sx).toContain('codeSeparator');
+ });
+
+ it('contains checkSigVerify for OP_PUSH_TX', () => {
+ const result = compile(COUNTER_SOURCE, { emitSX: true });
+ expect(result.sx).toContain('checkSigVerify');
+ });
+
+ it('contains generator point G', () => {
+ const result = compile(COUNTER_SOURCE, { emitSX: true });
+ // Compressed secp256k1 generator
+ expect(result.sx).toContain('0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798');
+ });
+
+ it('contains state deserialization opcodes', () => {
+ const result = compile(COUNTER_SOURCE, { emitSX: true });
+ // State extraction splits at 104 bytes (BIP-143 header)
+ expect(result.sx).toContain('104n');
+ expect(result.sx).toContain('split');
+ expect(result.sx).toContain('bin2num');
+ });
+
+ it('contains hash256 for output verification', () => {
+ const result = compile(COUNTER_SOURCE, { emitSX: true });
+ expect(result.sx).toContain('hash256');
+ });
+ });
+
+ describe('MessageBoard — stateful with readonly + mutable', () => {
+ it('compiles with SX', () => {
+ const result = compile(MESSAGE_BOARD_SOURCE, { emitSX: true });
+ expect(result.success).toBe(true);
+ expect(result.sx).toBeDefined();
+ });
+
+ it('contains dispatch for post and burn', () => {
+ const result = compile(MESSAGE_BOARD_SOURCE, { emitSX: true });
+ expect(result.sx).toContain('post');
+ expect(result.sx).toContain('burn');
+ });
+
+ it('contains .owner for readonly property', () => {
+ const result = compile(MESSAGE_BOARD_SOURCE, { emitSX: true });
+ expect(result.sx).toContain('.owner');
+ });
+ });
+
+ describe('SX output is readable', () => {
+ it('P2PKH output snapshot', () => {
+ const result = compile(P2PKH_SOURCE, { emitSX: true });
+ // Print for manual inspection during development
+ // console.log(result.sx);
+ expect(result.sx).toBeDefined();
+ // Each line should be a valid SX token or comment
+ const lines = result.sx!.split('\n').filter(l => l.trim().length > 0);
+ for (const line of lines) {
+ const trimmed = line.trim();
+ // Should be a comment, SX token, or structural keyword
+ const isValid = trimmed.startsWith('//')
+ || trimmed.startsWith('#')
+ || trimmed.startsWith('.')
+ || trimmed === 'end'
+ || trimmed === 'if'
+ || trimmed === 'else'
+ || trimmed === 'endIf'
+ || /^[a-z0-9]/.test(trimmed)
+ || /^-?\d+n/.test(trimmed)
+ || /^0x[0-9a-f]+/.test(trimmed)
+ || trimmed === 'true'
+ || trimmed === 'false';
+ expect(isValid).toBe(true);
+ }
+ });
+ });
+});
diff --git a/packages/runar-compiler/src/artifact/assembler.ts b/packages/runar-compiler/src/artifact/assembler.ts
index 401d4ac6..317151c9 100644
--- a/packages/runar-compiler/src/artifact/assembler.ts
+++ b/packages/runar-compiler/src/artifact/assembler.ts
@@ -119,6 +119,9 @@ export interface RunarArtifact {
/** Per-method OP_CODESEPARATOR byte offsets (index 0 = first public method, etc.). */
codeSeparatorIndices?: number[];
+ /** BitcoinSX text representation (available when emitSX option is true) */
+ sx?: string;
+
/** ISO-8601 build timestamp */
buildTimestamp: string;
}
diff --git a/packages/runar-compiler/src/index.ts b/packages/runar-compiler/src/index.ts
index f0744e4c..16068610 100644
--- a/packages/runar-compiler/src/index.ts
+++ b/packages/runar-compiler/src/index.ts
@@ -27,12 +27,14 @@ export { typecheck } from './passes/03-typecheck.js';
export type { TypeCheckResult } from './passes/03-typecheck.js';
export { lowerToANF } from './passes/04-anf-lower.js';
-export { lowerToStack } from './passes/05-stack-lower.js';
+export { lowerToStack, lowerBindingsToOps } from './passes/05-stack-lower.js';
+export type { LowerBindingsResult } from './passes/05-stack-lower.js';
export { emit } from './passes/06-emit.js';
export { optimizeStackIR } from './optimizer/peephole.js';
export { optimizeEC } from './optimizer/anf-ec.js';
export { foldConstants } from './optimizer/constant-fold.js';
export { assembleArtifact } from './artifact/assembler.js';
+export { emitSX } from './passes/07-sx-emit.js';
export type { CompilerDiagnostic, Severity } from './errors.js';
export { CompilerError, ParseError, ValidationError, TypeError, makeDiagnostic } from './errors.js';
@@ -49,6 +51,7 @@ import { optimizeStackIR } from './optimizer/peephole.js';
import { optimizeEC } from './optimizer/anf-ec.js';
import { foldConstants } from './optimizer/constant-fold.js';
import { assembleArtifact } from './artifact/assembler.js';
+import { emitSX as emitSXPass } from './passes/07-sx-emit.js';
import type { CompilerDiagnostic } from './errors.js';
import type { ContractNode, ANFProgram, RunarArtifact } from './ir/index.js';
@@ -75,6 +78,9 @@ export interface CompileOptions {
/** If true, skip the ANF constant folding pass. Default: false (folding enabled). */
disableConstantFolding?: boolean;
+ /** If true, also emit BitcoinSX (.sx) text via the ANF-to-SX backend (Pass 7). */
+ emitSX?: boolean;
+
/** Called between compilation passes with the current stage name and progress percentage (0-100). */
onProgress?: (stage: string, percent: number) => void;
}
@@ -100,6 +106,9 @@ export interface CompileResult {
/** Human-readable ASM representation (available if passes 5-6 succeed). */
scriptAsm?: string;
+
+ /** BitcoinSX text representation (available if emitSX option is true). */
+ sx?: string;
}
// ---------------------------------------------------------------------------
@@ -275,6 +284,20 @@ export function compile(source: string, options?: CompileOptions): CompileResult
onProgress?.('EC optimization', 50);
const optimizedAnf = optimizeEC(anf);
+ // Pass 7 (parallel): SX emission from ANF (if requested)
+ let sxText: string | undefined;
+ if (opts.emitSX) {
+ try {
+ onProgress?.('Emitting SX', 55);
+ const sxResult = emitSXPass(optimizedAnf);
+ sxText = sxResult.sx;
+ } catch (e: unknown) {
+ // SX emission failure is non-fatal — report as warning
+ const msg = e instanceof Error ? e.message : String(e);
+ diagnostics.push({ message: `SX emission failed: ${msg}`, severity: 'warning' } as CompilerDiagnostic);
+ }
+ }
+
// Pass 5-6: Stack lower + Peephole optimize + Emit
try {
onProgress?.('Stack lowering', 60);
@@ -307,6 +330,11 @@ export function compile(source: string, options?: CompileOptions): CompileResult
},
);
+ // Attach SX text to artifact if available
+ if (sxText && artifact) {
+ artifact.sx = sxText;
+ }
+
return {
anf: optimizedAnf,
contract: parseResult.contract,
@@ -315,6 +343,7 @@ export function compile(source: string, options?: CompileOptions): CompileResult
artifact,
scriptHex: emitResult.scriptHex,
scriptAsm: emitResult.scriptAsm,
+ sx: sxText,
};
} catch (e: unknown) {
// Stack lowering or emit failed — report as a compilation error
diff --git a/packages/runar-compiler/src/ir/artifact.ts b/packages/runar-compiler/src/ir/artifact.ts
index 0fefb7ad..e94b2800 100644
--- a/packages/runar-compiler/src/ir/artifact.ts
+++ b/packages/runar-compiler/src/ir/artifact.ts
@@ -127,6 +127,9 @@ export interface RunarArtifact {
/** Per-method OP_CODESEPARATOR byte offsets (index 0 = first public method, etc.). */
codeSeparatorIndices?: number[];
+ /** BitcoinSX text representation (available when emitSX option is true) */
+ sx?: string;
+
/** ISO-8601 build timestamp */
buildTimestamp: string;
}
diff --git a/packages/runar-compiler/src/ir/index.ts b/packages/runar-compiler/src/ir/index.ts
index 36d7668c..28028b29 100644
--- a/packages/runar-compiler/src/ir/index.ts
+++ b/packages/runar-compiler/src/ir/index.ts
@@ -81,6 +81,12 @@ export type {
PlaceholderOp,
} from './stack-ir.js';
+// SX IR types
+export type {
+ SXEmitResult,
+ SXSection,
+} from './sx-ir.js';
+
// Artifact types
export type {
ABIParam,
diff --git a/packages/runar-compiler/src/ir/sx-ir.ts b/packages/runar-compiler/src/ir/sx-ir.ts
new file mode 100644
index 00000000..939d122c
--- /dev/null
+++ b/packages/runar-compiler/src/ir/sx-ir.ts
@@ -0,0 +1,28 @@
+/**
+ * SX IR -- BitcoinSX output types.
+ *
+ * Produced by Pass 7 (07-sx-emit), which takes ANF IR and emits
+ * human-readable BitcoinSX (.sx) text preserving high-level structure
+ * (macros, repeat blocks, named variables).
+ */
+
+// ---------------------------------------------------------------------------
+// Sections
+// ---------------------------------------------------------------------------
+
+export interface SXSection {
+ kind: 'comment' | 'macro' | 'body' | 'dispatch';
+ name: string;
+ sx: string;
+}
+
+// ---------------------------------------------------------------------------
+// Emit result
+// ---------------------------------------------------------------------------
+
+export interface SXEmitResult {
+ /** The complete BitcoinSX source text */
+ sx: string;
+ /** Per-section breakdown (for tooling) */
+ sections: SXSection[];
+}
diff --git a/packages/runar-compiler/src/passes/05-stack-lower.ts b/packages/runar-compiler/src/passes/05-stack-lower.ts
index 11bb3e4d..8277415a 100644
--- a/packages/runar-compiler/src/passes/05-stack-lower.ts
+++ b/packages/runar-compiler/src/passes/05-stack-lower.ts
@@ -352,6 +352,16 @@ class LoweringContext {
return { ops: this.ops, maxStackDepth: this.maxDepth };
}
+ /** Current stack depth (for external inspection). */
+ get stackDepth(): number {
+ return this.stackMap.depth;
+ }
+
+ /** Peek at a stack slot by depth from top (for external inspection). */
+ peekStack(depthFromTop: number): string | null {
+ return this.stackMap.peekAtDepth(depthFromTop);
+ }
+
/**
* Clean up excess stack items below the top-of-stack result.
* Used after method body lowering to ensure a clean stack for Bitcoin Script.
@@ -4567,3 +4577,50 @@ function lowerMethod(
maxStackDepth,
};
}
+
+// ---------------------------------------------------------------------------
+// Exported helper: lower individual bindings to StackOps
+// ---------------------------------------------------------------------------
+
+/**
+ * Result of lowering bindings to StackOps.
+ */
+export interface LowerBindingsResult {
+ /** The emitted stack operations. */
+ ops: StackOp[];
+ /** Named values on the stack after lowering (bottom to top). */
+ finalStack: (string | null)[];
+}
+
+/**
+ * Lower a sequence of ANF bindings to StackOps using the existing hex backend.
+ *
+ * This is used by the SX backend to delegate complex stateful intrinsics
+ * (checkPreimage, deserializeState, addOutput, extractors, etc.) to the
+ * proven hex backend, then convert the resulting StackOps to SX text.
+ *
+ * @param bindings The ANF bindings to lower.
+ * @param stackState Named values currently on the stack (bottom to top).
+ * @param properties Contract property definitions.
+ * @param privateMethods Map of private method names to their ANF definitions.
+ * @param terminalAssert If true, the last assert leaves its value on stack.
+ */
+export function lowerBindingsToOps(
+ bindings: ANFBinding[],
+ stackState: string[],
+ properties: ANFProperty[],
+ privateMethods: Map = new Map(),
+ terminalAssert = false,
+): LowerBindingsResult {
+ const ctx = new LoweringContext(stackState, properties, privateMethods);
+ ctx.lowerBindings(bindings, terminalAssert);
+
+ // 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));
+ }
+
+ return { ops: ctx.result.ops, finalStack };
+}
diff --git a/packages/runar-compiler/src/passes/07-sx-codegen-bridge.ts b/packages/runar-compiler/src/passes/07-sx-codegen-bridge.ts
new file mode 100644
index 00000000..44e0ecbd
--- /dev/null
+++ b/packages/runar-compiler/src/passes/07-sx-codegen-bridge.ts
@@ -0,0 +1,220 @@
+/**
+ * Pass 7 helper: StackOp-to-SX bridge.
+ *
+ * Converts StackOp arrays (produced by specialized codegen modules like
+ * ec-codegen, sha256-codegen, slh-dsa-codegen) into BitcoinSX text.
+ * Used as a Phase 1 fallback for codegen that bypasses ANF.
+ */
+
+import type { StackOp } from '../ir/index.js';
+
+// ---------------------------------------------------------------------------
+// OP_ → SX camelCase name mapping
+// ---------------------------------------------------------------------------
+
+const OP_TO_SX: Record = {
+ // Stack
+ 'OP_DUP': 'dup',
+ 'OP_DROP': 'drop',
+ 'OP_SWAP': 'swap',
+ 'OP_PICK': 'pick',
+ 'OP_ROLL': 'roll',
+ 'OP_NIP': 'nip',
+ 'OP_OVER': 'over',
+ 'OP_ROT': 'rot',
+ 'OP_TUCK': 'tuck',
+ 'OP_2DROP': '2drop',
+ 'OP_2DUP': '2dup',
+ 'OP_3DUP': '3dup',
+ 'OP_2OVER': '2over',
+ 'OP_2ROT': '2rot',
+ 'OP_2SWAP': '2swap',
+ 'OP_IFDUP': 'ifDup',
+ 'OP_DEPTH': 'depth',
+ 'OP_TOALTSTACK': 'toAltStack',
+ 'OP_FROMALTSTACK': 'fromAltStack',
+
+ // Arithmetic
+ 'OP_ADD': 'add',
+ 'OP_SUB': 'sub',
+ 'OP_MUL': 'mul',
+ 'OP_DIV': 'div',
+ 'OP_MOD': 'mod',
+ 'OP_NEGATE': 'negate',
+ 'OP_ABS': 'abs',
+ 'OP_NOT': 'not',
+ 'OP_0NOTEQUAL': '0notEqual',
+ 'OP_1ADD': '1add',
+ 'OP_1SUB': '1sub',
+ 'OP_NUMEQUAL': 'numEqual',
+ 'OP_NUMEQUALVERIFY': 'numEqualVerify',
+ 'OP_NUMNOTEQUAL': 'numNotEqual',
+ 'OP_LESSTHAN': 'lessThan',
+ 'OP_GREATERTHAN': 'greaterThan',
+ 'OP_LESSTHANOREQUAL': 'lessThanOrEqual',
+ 'OP_GREATERTHANOREQUAL': 'greaterThanOrEqual',
+ 'OP_MIN': 'min',
+ 'OP_MAX': 'max',
+ 'OP_WITHIN': 'within',
+ 'OP_BOOLAND': 'boolAnd',
+ 'OP_BOOLOR': 'boolOr',
+
+ // Bitwise / String
+ 'OP_AND': 'and',
+ 'OP_OR': 'or',
+ 'OP_XOR': 'xor',
+ 'OP_INVERT': 'invert',
+ 'OP_LSHIFT': 'lshift',
+ 'OP_RSHIFT': 'rshift',
+ 'OP_EQUAL': 'equal',
+ 'OP_EQUALVERIFY': 'equalVerify',
+ 'OP_CAT': 'cat',
+ 'OP_SPLIT': 'split',
+ 'OP_NUM2BIN': 'num2bin',
+ 'OP_BIN2NUM': 'bin2num',
+ 'OP_SIZE': 'size',
+
+ // Crypto
+ 'OP_SHA256': 'sha256',
+ 'OP_SHA1': 'sha1',
+ 'OP_RIPEMD160': 'ripemd160',
+ 'OP_HASH160': 'hash160',
+ 'OP_HASH256': 'hash256',
+ 'OP_CHECKSIG': 'checkSig',
+ 'OP_CHECKSIGVERIFY': 'checkSigVerify',
+ 'OP_CHECKMULTISIG': 'checkMultiSig',
+ 'OP_CHECKMULTISIGVERIFY': 'checkMultiSigVerify',
+ 'OP_CODESEPARATOR': 'codeSeparator',
+
+ // Control
+ 'OP_IF': 'if',
+ 'OP_NOTIF': 'notIf',
+ 'OP_ELSE': 'else',
+ 'OP_ENDIF': 'endIf',
+ 'OP_VERIFY': 'verify',
+ 'OP_RETURN': 'return',
+ 'OP_NOP': 'nop',
+
+ // Constants
+ 'OP_0': 'false',
+ 'OP_FALSE': 'false',
+ 'OP_1': 'true',
+ 'OP_TRUE': 'true',
+ 'OP_1NEGATE': '-1n',
+};
+
+/**
+ * Convert an OP_X name to its SX camelCase equivalent.
+ * Falls back to the raw name if unmapped (e.g., OP_2..OP_16 are handled separately).
+ */
+export function opcodeToSXName(opCode: string): string {
+ const mapped = OP_TO_SX[opCode];
+ if (mapped !== undefined) return mapped;
+
+ // OP_2 through OP_16 → literal bigint
+ const numMatch = opCode.match(/^OP_(\d+)$/);
+ if (numMatch) {
+ return `${numMatch[1]}n`;
+ }
+
+ // Unknown — emit as-is (shouldn't happen for valid scripts)
+ return opCode;
+}
+
+// ---------------------------------------------------------------------------
+// Push value formatting
+// ---------------------------------------------------------------------------
+
+function formatPushValue(value: Uint8Array | bigint | boolean): string {
+ if (typeof value === 'boolean') {
+ return value ? 'true' : 'false';
+ }
+
+ if (typeof value === 'bigint') {
+ return `${value}n`;
+ }
+
+ // Uint8Array → hex literal
+ if (value.length === 0) {
+ return 'false'; // OP_0 = empty push
+ }
+ let hex = '';
+ for (const b of value) {
+ hex += b.toString(16).padStart(2, '0');
+ }
+ return `0x${hex}`;
+}
+
+// ---------------------------------------------------------------------------
+// StackOp → SX text
+// ---------------------------------------------------------------------------
+
+/**
+ * Convert a single StackOp to SX text token(s).
+ */
+export function stackOpToSX(op: StackOp, indent: string = ''): string {
+ switch (op.op) {
+ case 'push':
+ return `${indent}${formatPushValue(op.value)}`;
+
+ case 'dup':
+ return `${indent}dup`;
+
+ case 'swap':
+ return `${indent}swap`;
+
+ case 'roll':
+ // ROLL needs depth on stack first: push depth, then roll
+ return `${indent}${op.depth}n roll`;
+
+ case 'pick':
+ return `${indent}${op.depth}n pick`;
+
+ case 'drop':
+ return `${indent}drop`;
+
+ case 'nip':
+ return `${indent}nip`;
+
+ case 'over':
+ return `${indent}over`;
+
+ case 'rot':
+ return `${indent}rot`;
+
+ case 'tuck':
+ return `${indent}tuck`;
+
+ case 'opcode':
+ return `${indent}${opcodeToSXName(op.code)}`;
+
+ case 'if': {
+ const lines: string[] = [];
+ lines.push(`${indent}if`);
+ for (const thenOp of op.then) {
+ lines.push(stackOpToSX(thenOp, indent + ' '));
+ }
+ if (op.else && op.else.length > 0) {
+ lines.push(`${indent}else`);
+ for (const elseOp of op.else) {
+ lines.push(stackOpToSX(elseOp, indent + ' '));
+ }
+ }
+ lines.push(`${indent}endIf`);
+ return lines.join('\n');
+ }
+
+ case 'placeholder':
+ return `${indent}.${op.paramName}`;
+
+ case 'push_codesep_index':
+ return `${indent}.codeSepIndex`;
+ }
+}
+
+/**
+ * Convert an array of StackOps to a multi-line SX text block.
+ */
+export function stackOpsToSX(ops: StackOp[], indent: string = ''): string {
+ return ops.map(op => stackOpToSX(op, indent)).join('\n');
+}
diff --git a/packages/runar-compiler/src/passes/07-sx-emit.ts b/packages/runar-compiler/src/passes/07-sx-emit.ts
new file mode 100644
index 00000000..6dd88dff
--- /dev/null
+++ b/packages/runar-compiler/src/passes/07-sx-emit.ts
@@ -0,0 +1,280 @@
+/**
+ * Pass 7: SX Emit — converts ANF IR to BitcoinSX (.sx) text.
+ *
+ * This is a parallel backend to passes 05+06 (stack-lower + emit-hex).
+ * It operates on the same ANF IR input but preserves high-level structure:
+ *
+ * - Private methods → SX `#macros`
+ * - Loops → SX `repeat Nn ... end`
+ * - If/else → SX `if ... else ... endIf`
+ * - Constructor params → SX `.variables`
+ * - Opcodes → SX camelCase names
+ */
+
+import type { ANFProgram, ANFMethod, ANFBinding, ANFProperty } from '../ir/index.js';
+import type { SXEmitResult, SXSection } from '../ir/sx-ir.js';
+import { SXStackScheduler, analyzeMethodStackEffect } from './07-sx-stack.js';
+
+// ---------------------------------------------------------------------------
+// Public API
+// ---------------------------------------------------------------------------
+
+/**
+ * Emit an ANFProgram as BitcoinSX text.
+ */
+export function emitSX(program: ANFProgram): SXEmitResult {
+ const sections: SXSection[] = [];
+ const lines: string[] = [];
+
+ // -----------------------------------------------------------------------
+ // Header
+ // -----------------------------------------------------------------------
+
+ const headerLines: string[] = [];
+ headerLines.push(`// ${program.contractName}.sx — generated by Runar compiler`);
+
+ // Constructor variables
+ const ctorProps = program.properties.filter(p => p.initialValue === undefined);
+ if (ctorProps.length > 0) {
+ headerLines.push('//');
+ headerLines.push('// Constructor:');
+ for (const prop of ctorProps) {
+ headerLines.push(`// .${prop.name}: ${prop.type}`);
+ }
+ }
+
+ const headerSx = headerLines.join('\n');
+ sections.push({ kind: 'comment', name: 'header', sx: headerSx });
+ lines.push(headerSx);
+ lines.push('');
+
+ // -----------------------------------------------------------------------
+ // Collect private methods
+ // -----------------------------------------------------------------------
+
+ const privateMethods = new Map();
+ const publicMethods: ANFMethod[] = [];
+
+ for (const method of program.methods) {
+ if (method.name === 'constructor') continue;
+ if (method.isPublic) {
+ publicMethods.push(method);
+ } else {
+ privateMethods.set(method.name, method);
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // Emit private methods as #macros
+ // -----------------------------------------------------------------------
+
+ for (const [name, method] of privateMethods) {
+ const macroLines: string[] = [];
+ macroLines.push(`#${name}`);
+
+ // Document params
+ if (method.params.length > 0) {
+ const paramList = method.params.map(p => `${p.name}: ${p.type}`).join(', ');
+ macroLines.push(` // params: ${paramList}`);
+ }
+
+ // Lower the method body
+ // Macro calling convention: args are on stack in parameter order,
+ // param[0] at bottom, param[N-1] at top
+ const paramNames = method.params.map(p => p.name);
+ const scheduler = new SXStackScheduler(
+ paramNames,
+ program.properties,
+ privateMethods,
+ ' ',
+ );
+
+ const effect = analyzeMethodStackEffect(method);
+ const isTerminal = effect.produces === 0;
+ scheduler.lowerBindings(method.body, !isTerminal);
+
+ if (!isTerminal) {
+ scheduler.cleanupExcessStack();
+ }
+
+ const bodyText = scheduler.getOutput();
+ if (bodyText) macroLines.push(bodyText);
+ macroLines.push('end');
+
+ const macroSx = macroLines.join('\n');
+ sections.push({ kind: 'macro', name, sx: macroSx });
+ lines.push(macroSx);
+ lines.push('');
+ }
+
+ // -----------------------------------------------------------------------
+ // Emit public methods
+ // -----------------------------------------------------------------------
+
+ if (publicMethods.length === 0) {
+ // No public methods — empty contract
+ return { sx: lines.join('\n'), sections };
+ }
+
+ if (publicMethods.length === 1) {
+ // Single public method — emit body directly
+ const method = publicMethods[0]!;
+ const bodySx = emitMethodBody(method, program.properties, privateMethods);
+ sections.push({ kind: 'body', name: method.name, sx: bodySx });
+ lines.push(`// Public method: ${method.name}(${formatParams(method)})`);
+ lines.push(bodySx);
+ } else {
+ // Multiple public methods — emit dispatch
+ const dispatchSx = emitDispatch(publicMethods, program.properties, privateMethods);
+ sections.push({ kind: 'dispatch', name: 'dispatch', sx: dispatchSx });
+ lines.push(dispatchSx);
+ }
+
+ return { sx: lines.join('\n'), sections };
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function formatParams(method: ANFMethod): string {
+ return method.params
+ .filter(p => !p.name.startsWith('_')) // skip implicit params
+ .map(p => `${p.name}: ${p.type}`)
+ .join(', ');
+}
+
+/**
+ * Build the parameter list for a public method's initial stack.
+ * In Bitcoin Script, scriptSig args are pushed in order: first arg at bottom,
+ * last arg at top. Implicit params (prefixed with _) are included because
+ * they're part of the scriptSig.
+ *
+ * For stateful contracts, the hex backend injects implicit params:
+ * - _opPushTxSig: ECDSA sig for OP_PUSH_TX verification
+ * - _codePart: full code script (when method uses add_output/add_raw_output/computeStateOutput)
+ * These must be on the stack for the delegated stateful intrinsics to work.
+ */
+function buildMethodParams(method: ANFMethod): string[] {
+ const paramNames = method.params.map(p => p.name);
+
+ if (methodUsesCheckPreimage(method.body)) {
+ paramNames.unshift('_opPushTxSig');
+ if (methodUsesCodePart(method.body)) {
+ paramNames.unshift('_codePart');
+ }
+ }
+
+ return paramNames;
+}
+
+function methodUsesCheckPreimage(bindings: ANFBinding[]): boolean {
+ for (const b of bindings) {
+ if (b.value.kind === 'check_preimage') return true;
+ }
+ return false;
+}
+
+function methodUsesCodePart(bindings: ANFBinding[]): boolean {
+ for (const b of bindings) {
+ if (b.value.kind === 'add_output' || b.value.kind === 'add_raw_output') return true;
+ if (b.value.kind === 'call' && (b.value.func === 'computeStateOutput' || b.value.func === 'computeStateOutputHash')) return true;
+ if (b.value.kind === 'if') {
+ if (methodUsesCodePart(b.value.then) || methodUsesCodePart(b.value.else)) return true;
+ }
+ if (b.value.kind === 'loop' && methodUsesCodePart(b.value.body)) return true;
+ }
+ return false;
+}
+
+/**
+ * Emit a single method body as SX text.
+ */
+function emitMethodBody(
+ method: ANFMethod,
+ properties: ANFProperty[],
+ privateMethods: Map,
+): string {
+ const paramNames = buildMethodParams(method);
+ const scheduler = new SXStackScheduler(
+ paramNames,
+ properties,
+ privateMethods,
+ );
+
+ scheduler.lowerBindings(method.body, true);
+ scheduler.cleanupExcessStack();
+
+ return scheduler.getOutput();
+}
+
+/**
+ * Emit a method dispatch structure for multiple public methods.
+ *
+ * The spending transaction pushes the method index as the topmost
+ * scriptSig element. Pattern:
+ *
+ * dup 0n numEqual if drop else
+ * dup 1n numEqual if drop else
+ * Nn numEqualVerify
+ * endIf endIf ...
+ */
+function emitDispatch(
+ methods: ANFMethod[],
+ properties: ANFProperty[],
+ privateMethods: Map,
+): string {
+ const lines: string[] = [];
+ lines.push('// Method dispatch');
+
+ for (let i = 0; i < methods.length; i++) {
+ const method = methods[i]!;
+ const isLast = i === methods.length - 1;
+
+ lines.push('');
+ lines.push(`// [${i}] ${method.name}(${formatParams(method)})`);
+
+ if (!isLast) {
+ lines.push('dup');
+ lines.push(`${i}n numEqual`);
+ lines.push('if');
+ lines.push(' drop');
+
+ // Emit method body indented
+ const paramNames = buildMethodParams(method);
+ const scheduler = new SXStackScheduler(
+ paramNames,
+ properties,
+ privateMethods,
+ ' ',
+ );
+ scheduler.lowerBindings(method.body, true);
+ scheduler.cleanupExcessStack();
+ const bodyText = scheduler.getOutput();
+ if (bodyText) lines.push(bodyText);
+
+ lines.push('else');
+ } else {
+ // Last method — verify and emit directly
+ lines.push(`${i}n numEqualVerify`);
+
+ const paramNames = buildMethodParams(method);
+ const scheduler = new SXStackScheduler(
+ paramNames,
+ properties,
+ privateMethods,
+ );
+ scheduler.lowerBindings(method.body, true);
+ scheduler.cleanupExcessStack();
+ const bodyText = scheduler.getOutput();
+ if (bodyText) lines.push(bodyText);
+ }
+ }
+
+ // Close all the nested if/else blocks
+ for (let i = 0; i < methods.length - 1; i++) {
+ lines.push('endIf');
+ }
+
+ return lines.join('\n');
+}
diff --git a/packages/runar-compiler/src/passes/07-sx-stack.ts b/packages/runar-compiler/src/passes/07-sx-stack.ts
new file mode 100644
index 00000000..cb37e832
--- /dev/null
+++ b/packages/runar-compiler/src/passes/07-sx-stack.ts
@@ -0,0 +1,1254 @@
+/**
+ * Pass 7 helper: SX Stack Scheduler.
+ *
+ * Tracks named values on a virtual stack and emits BitcoinSX text tokens.
+ * Modeled on the StackMap + LoweringContext from 05-stack-lower.ts, but
+ * outputs SX text instead of StackOp objects.
+ *
+ * Key differences from 05-stack-lower:
+ * - method_call to private methods emits bare macro name (NOT inline)
+ * - loop emits `repeat Nn ... end` with stack-managed counter
+ * - Specialized codegen (EC, SHA-256, etc.) goes through the bridge
+ */
+
+import type {
+ ANFBinding,
+ ANFMethod,
+ ANFProperty,
+ ANFValue,
+} from '../ir/index.js';
+import type { StackOp } from '../ir/index.js';
+import { stackOpsToSX } from './07-sx-codegen-bridge.js';
+import { lowerBindingsToOps } from './05-stack-lower.js';
+import { emitSha256Compress, emitSha256Finalize } from './sha256-codegen.js';
+import { emitBlake3Compress, emitBlake3Hash } from './blake3-codegen.js';
+import {
+ emitEcAdd, emitEcMul, emitEcMulGen, emitEcNegate,
+ emitEcOnCurve, emitEcModReduce, emitEcEncodeCompressed,
+ emitEcMakePoint, emitEcPointX, emitEcPointY,
+} from './ec-codegen.js';
+import { emitVerifySLHDSA } from './slh-dsa-codegen.js';
+import {
+ emitBBFieldAdd, emitBBFieldSub, emitBBFieldMul, emitBBFieldInv,
+ emitBBExt4Mul0, emitBBExt4Mul1, emitBBExt4Mul2, emitBBExt4Mul3,
+ emitBBExt4Inv0, emitBBExt4Inv1, emitBBExt4Inv2, emitBBExt4Inv3,
+} from './babybear-codegen.js';
+import { emitMerkleRootSha256, emitMerkleRootHash256 } from './merkle-codegen.js';
+
+// ---------------------------------------------------------------------------
+// Builtin function → SX opcode mapping
+// ---------------------------------------------------------------------------
+
+const BUILTIN_SX: Record = {
+ sha256: ['sha256'],
+ ripemd160: ['ripemd160'],
+ hash160: ['hash160'],
+ hash256: ['hash256'],
+ checkSig: ['checkSig'],
+ checkMultiSig: ['checkMultiSig'],
+ len: ['size'],
+ cat: ['cat'],
+ num2bin: ['num2bin'],
+ bin2num: ['bin2num'],
+ abs: ['abs'],
+ min: ['min'],
+ max: ['max'],
+ within: ['within'],
+ split: ['split'],
+ left: ['split', 'drop'],
+ int2str: ['num2bin'],
+ bool: ['0notEqual'],
+ unpack: ['bin2num'],
+};
+
+const BINOP_SX: Record = {
+ '+': ['add'],
+ '-': ['sub'],
+ '*': ['mul'],
+ '/': ['div'],
+ '%': ['mod'],
+ '===': ['numEqual'],
+ '!==': ['numEqual', 'not'],
+ '<': ['lessThan'],
+ '>': ['greaterThan'],
+ '<=': ['lessThanOrEqual'],
+ '>=': ['greaterThanOrEqual'],
+ '&&': ['boolAnd'],
+ '||': ['boolOr'],
+ '&': ['and'],
+ '|': ['or'],
+ '^': ['xor'],
+ '<<': ['lshift'],
+ '>>': ['rshift'],
+};
+
+const UNARYOP_SX: Record = {
+ '!': ['not'],
+ '-': ['negate'],
+ '~': ['invert'],
+};
+
+// ---------------------------------------------------------------------------
+// Stack map — tracks named values on the stack
+// ---------------------------------------------------------------------------
+
+class StackMap {
+ private slots: (string | null)[];
+
+ constructor(initial: string[] = []) {
+ this.slots = [...initial];
+ }
+
+ get depth(): number {
+ return this.slots.length;
+ }
+
+ push(name: string | null): void {
+ this.slots.push(name);
+ }
+
+ pop(): string | null {
+ if (this.slots.length === 0) throw new Error('Stack underflow');
+ return this.slots.pop()!;
+ }
+
+ findDepth(name: string): number {
+ for (let i = this.slots.length - 1; i >= 0; i--) {
+ if (this.slots[i] === name) return this.slots.length - 1 - i;
+ }
+ throw new Error(`Value '${name}' not found on stack`);
+ }
+
+ has(name: string): boolean {
+ return this.slots.includes(name);
+ }
+
+ removeAtDepth(depthFromTop: number): string | null {
+ const index = this.slots.length - 1 - depthFromTop;
+ if (index < 0 || index >= this.slots.length) throw new Error(`Invalid stack depth: ${depthFromTop}`);
+ const [removed] = this.slots.splice(index, 1);
+ return removed ?? null;
+ }
+
+ peekAtDepth(depthFromTop: number): string | null {
+ const index = this.slots.length - 1 - depthFromTop;
+ if (index < 0 || index >= this.slots.length) throw new Error(`Invalid stack depth: ${depthFromTop}`);
+ return this.slots[index] ?? null;
+ }
+
+ clone(): StackMap {
+ const m = new StackMap();
+ m.slots = [...this.slots];
+ return m;
+ }
+
+ swap(): void {
+ const len = this.slots.length;
+ if (len < 2) throw new Error('Stack underflow on swap');
+ const tmp = this.slots[len - 1]!;
+ this.slots[len - 1] = this.slots[len - 2]!;
+ this.slots[len - 2] = tmp;
+ }
+
+ dup(): void {
+ if (this.slots.length < 1) throw new Error('Stack underflow on dup');
+ this.slots.push(this.slots[this.slots.length - 1]!);
+ }
+
+ namedSlots(): Set {
+ const names = new Set();
+ for (const s of this.slots) {
+ if (s !== null) names.add(s);
+ }
+ return names;
+ }
+
+ renameAtDepth(depthFromTop: number, newName: string | null): void {
+ const index = this.slots.length - 1 - depthFromTop;
+ if (index < 0 || index >= this.slots.length) throw new Error(`Invalid stack depth for rename: ${depthFromTop}`);
+ this.slots[index] = newName;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Use analysis
+// ---------------------------------------------------------------------------
+
+function computeLastUses(bindings: ANFBinding[]): Map {
+ const lastUse = new Map();
+ for (let i = 0; i < bindings.length; i++) {
+ const refs = collectRefs(bindings[i]!.value);
+ for (const ref of refs) {
+ lastUse.set(ref, i);
+ }
+ }
+ return lastUse;
+}
+
+function collectRefs(value: ANFValue): string[] {
+ const refs: string[] = [];
+ switch (value.kind) {
+ case 'load_param':
+ refs.push(value.name);
+ break;
+ case 'load_prop':
+ case 'get_state_script':
+ break;
+ case 'load_const':
+ if (typeof value.value === 'string' && value.value.startsWith('@ref:')) {
+ refs.push(value.value.slice(5));
+ }
+ break;
+ case 'add_output':
+ refs.push(value.satoshis, ...value.stateValues);
+ if (value.preimage) refs.push(value.preimage);
+ break;
+ case 'add_raw_output':
+ refs.push(value.satoshis, value.scriptBytes);
+ break;
+ case 'bin_op':
+ refs.push(value.left, value.right);
+ break;
+ case 'unary_op':
+ refs.push(value.operand);
+ break;
+ case 'call':
+ refs.push(...value.args);
+ break;
+ case 'method_call':
+ refs.push(value.object, ...value.args);
+ break;
+ case 'if':
+ refs.push(value.cond);
+ for (const b of value.then) refs.push(...collectRefs(b.value));
+ for (const b of value.else) refs.push(...collectRefs(b.value));
+ break;
+ case 'loop':
+ for (const b of value.body) refs.push(...collectRefs(b.value));
+ break;
+ case 'assert':
+ refs.push(value.value);
+ break;
+ case 'update_prop':
+ refs.push(value.value);
+ break;
+ case 'check_preimage':
+ refs.push(value.preimage);
+ break;
+ case 'deserialize_state':
+ refs.push(value.preimage);
+ break;
+ case 'array_literal':
+ refs.push(...value.elements);
+ break;
+ }
+ return refs;
+}
+
+// ---------------------------------------------------------------------------
+// SX value formatting
+// ---------------------------------------------------------------------------
+
+function formatSXValue(value: string | bigint | boolean): string {
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
+ if (typeof value === 'bigint') return `${value}n`;
+ // String — hex-encoded ByteString
+ if (value === '') return 'false'; // empty = OP_0
+ return `0x${value}`;
+}
+
+// ---------------------------------------------------------------------------
+// Private method stack effect
+// ---------------------------------------------------------------------------
+
+export interface MethodStackEffect {
+ consumes: number; // number of args consumed
+ produces: number; // 0 or 1 result values
+}
+
+/**
+ * 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 };
+}
+
+// ---------------------------------------------------------------------------
+// SX Stack Scheduler
+// ---------------------------------------------------------------------------
+
+export class SXStackScheduler {
+ private stackMap: StackMap;
+ private tokens: string[] = [];
+ private _properties: ANFProperty[];
+ private privateMethods: Map;
+ private methodEffects: Map;
+ private localBindings: Set = new Set();
+ private outerProtectedRefs: Set | null = null;
+ private _insideBranch = false;
+ private arrayLengths: Map = new Map();
+ private constValues: Map = new Map();
+ private _indent: string;
+
+ constructor(
+ params: string[],
+ properties: ANFProperty[],
+ privateMethods: Map = new Map(),
+ indent: string = '',
+ ) {
+ this.stackMap = new StackMap(params);
+ this._properties = properties;
+ this.privateMethods = privateMethods;
+ this._indent = indent;
+
+ // Pre-analyze stack effects for all private methods
+ this.methodEffects = new Map();
+ for (const [name, method] of privateMethods) {
+ this.methodEffects.set(name, analyzeMethodStackEffect(method));
+ }
+ }
+
+ /** Get accumulated SX text as a single string, with peephole cleanup. */
+ getOutput(): string {
+ return peepholeSX(this.tokens).join('\n');
+ }
+
+ /** Emit an SX text token. */
+ private emit(token: string): void {
+ this.tokens.push(`${this._indent}${token}`);
+ }
+
+ /** Emit a raw token without indent (for nested structures). */
+ private emitRaw(token: string): void {
+ this.tokens.push(token);
+ }
+
+ // -----------------------------------------------------------------------
+ // Stack manipulation → SX text
+ // -----------------------------------------------------------------------
+
+ private bringToTop(name: string, consume: boolean): void {
+ const depth = this.stackMap.findDepth(name);
+
+ if (depth === 0) {
+ if (!consume) {
+ this.emit('dup');
+ this.stackMap.dup();
+ }
+ return;
+ }
+
+ if (depth === 1 && consume) {
+ this.emit('swap');
+ this.stackMap.swap();
+ return;
+ }
+
+ if (consume) {
+ if (depth === 2) {
+ this.emit('rot');
+ const name2 = this.stackMap.removeAtDepth(2);
+ this.stackMap.push(name2);
+ } else {
+ this.emit(`${depth}n roll`);
+ const rolled = this.stackMap.removeAtDepth(depth);
+ this.stackMap.push(rolled);
+ }
+ } else {
+ if (depth === 1) {
+ this.emit('over');
+ const name2 = this.stackMap.peekAtDepth(1);
+ this.stackMap.push(name2);
+ } else {
+ this.emit(`${depth}n pick`);
+ const picked = this.stackMap.peekAtDepth(depth);
+ this.stackMap.push(picked);
+ }
+ }
+ }
+
+ private isLastUse(ref: string, currentIndex: number, lastUses: Map): boolean {
+ const last = lastUses.get(ref);
+ return last === undefined || last <= currentIndex;
+ }
+
+ // -----------------------------------------------------------------------
+ // Main lowering entry points
+ // -----------------------------------------------------------------------
+
+ /**
+ * Lower a sequence of ANF bindings to SX text.
+ * When terminalAssert is true, the final assert leaves its value on stack.
+ */
+ lowerBindings(bindings: ANFBinding[], terminalAssert = false): void {
+ this.localBindings = new Set(bindings.map(b => b.name));
+ const lastUses = computeLastUses(bindings);
+
+ if (this.outerProtectedRefs) {
+ for (const ref of this.outerProtectedRefs) {
+ lastUses.set(ref, bindings.length);
+ }
+ }
+
+ let lastAssertIdx = -1;
+ let terminalIfIdx = -1;
+ if (terminalAssert) {
+ const lastBinding = bindings[bindings.length - 1];
+ if (lastBinding && lastBinding.value.kind === 'if') {
+ terminalIfIdx = bindings.length - 1;
+ } else {
+ for (let i = bindings.length - 1; i >= 0; i--) {
+ if (bindings[i]!.value.kind === 'assert') {
+ lastAssertIdx = i;
+ break;
+ }
+ }
+ }
+ }
+
+ for (let i = 0; i < bindings.length; i++) {
+ const binding = bindings[i]!;
+ if (binding.value.kind === 'assert' && i === lastAssertIdx) {
+ this.lowerAssert(binding.value.value, i, lastUses, true);
+ } else if (binding.value.kind === 'if' && i === terminalIfIdx) {
+ this.lowerIf(binding.name, binding.value.cond, binding.value.then, binding.value.else, i, lastUses, true);
+ } else {
+ this.lowerBinding(binding, i, lastUses);
+ }
+ }
+ }
+
+ /** Clean up excess stack items below the result. */
+ cleanupExcessStack(): void {
+ if (this.stackMap.depth > 1) {
+ const excess = this.stackMap.depth - 1;
+ for (let i = 0; i < excess; i++) {
+ this.emit('nip');
+ this.stackMap.removeAtDepth(1);
+ }
+ }
+ }
+
+ private lowerBinding(
+ binding: ANFBinding,
+ bindingIndex: number,
+ lastUses: Map,
+ ): void {
+ const { name, value } = binding;
+
+ switch (value.kind) {
+ case 'load_param':
+ this.lowerLoadParam(name, value.name, bindingIndex, lastUses);
+ break;
+ case 'load_prop':
+ this.lowerLoadProp(name, value.name);
+ break;
+ case 'load_const':
+ this.lowerLoadConst(name, value.value, bindingIndex, lastUses);
+ break;
+ case 'bin_op':
+ this.lowerBinOp(name, value.op, value.left, value.right, bindingIndex, lastUses, value.result_type);
+ break;
+ case 'unary_op':
+ this.lowerUnaryOp(name, value.op, value.operand, bindingIndex, lastUses);
+ break;
+ case 'call':
+ this.lowerCall(name, value.func, value.args, bindingIndex, lastUses);
+ break;
+ case 'method_call':
+ this.lowerMethodCall(name, value.object, value.method, value.args, bindingIndex, lastUses);
+ break;
+ case 'if':
+ this.lowerIf(name, value.cond, value.then, value.else, bindingIndex, lastUses);
+ break;
+ case 'loop':
+ this.lowerLoop(name, value.count, value.body, value.iterVar);
+ break;
+ case 'assert':
+ this.lowerAssert(value.value, bindingIndex, lastUses);
+ break;
+ case 'update_prop':
+ this.lowerUpdateProp(value.name, value.value, bindingIndex, lastUses);
+ break;
+ case 'get_state_script':
+ this.lowerGetStateScript(name);
+ break;
+ case 'check_preimage':
+ this.lowerCheckPreimage(name, value.preimage, bindingIndex, lastUses);
+ break;
+ case 'deserialize_state':
+ this.lowerDeserializeState(name, value.preimage, bindingIndex, lastUses);
+ break;
+ case 'add_output':
+ this.lowerAddOutput(name, value.satoshis, value.stateValues, value.preimage, bindingIndex, lastUses);
+ break;
+ case 'add_raw_output':
+ this.lowerAddRawOutput(name, value.satoshis, value.scriptBytes, bindingIndex, lastUses);
+ break;
+ case 'array_literal':
+ this.lowerArrayLiteral(name, value.elements, bindingIndex, lastUses);
+ break;
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // Individual lowering methods
+ // -----------------------------------------------------------------------
+
+ private lowerLoadParam(
+ bindingName: string,
+ paramName: string,
+ bindingIndex: number,
+ lastUses: Map,
+ ): void {
+ if (this.stackMap.has(paramName)) {
+ const isLast = this.isLastUse(paramName, bindingIndex, lastUses);
+ this.bringToTop(paramName, isLast);
+ this.stackMap.pop();
+ this.stackMap.push(bindingName);
+ } else {
+ this.emit('false');
+ this.stackMap.push(bindingName);
+ }
+ }
+
+ private lowerLoadProp(bindingName: string, propName: string): void {
+ const prop = this._properties.find(p => p.name === propName);
+ if (this.stackMap.has(propName)) {
+ this.bringToTop(propName, false);
+ this.stackMap.pop();
+ } else if (prop && prop.initialValue !== undefined) {
+ this.emit(formatSXValue(prop.initialValue));
+ } else {
+ // Constructor parameter → SX .variable
+ this.emit(`.${propName}`);
+ }
+ this.stackMap.push(bindingName);
+ }
+
+ private lowerLoadConst(
+ bindingName: string,
+ value: string | bigint | boolean,
+ bindingIndex: number = 0,
+ lastUses: Map = new Map(),
+ ): void {
+ if (typeof value === 'string' && value.startsWith('@ref:')) {
+ const refName = value.slice(5);
+ if (this.stackMap.has(refName)) {
+ const consume = this.localBindings.has(refName)
+ && this.isLastUse(refName, bindingIndex, lastUses);
+ this.bringToTop(refName, consume);
+ this.stackMap.pop();
+ this.stackMap.push(bindingName);
+ } else {
+ this.emit('false');
+ this.stackMap.push(bindingName);
+ }
+ return;
+ }
+ if (typeof value === 'string' && (value === '@this' || value === 'this')) {
+ this.emit('false');
+ this.stackMap.push(bindingName);
+ return;
+ }
+ this.emit(formatSXValue(value));
+ this.stackMap.push(bindingName);
+ this.constValues.set(bindingName, value);
+ }
+
+ private lowerBinOp(
+ bindingName: string,
+ op: string,
+ left: string,
+ right: string,
+ bindingIndex: number,
+ lastUses: Map,
+ resultType?: string,
+ ): void {
+ this.bringToTop(left, this.isLastUse(left, bindingIndex, lastUses));
+ this.bringToTop(right, this.isLastUse(right, bindingIndex, lastUses));
+ this.stackMap.pop();
+ this.stackMap.pop();
+
+ if (resultType === 'bytes' && (op === '===' || op === '!==')) {
+ this.emit('equal');
+ if (op === '!==') this.emit('not');
+ } else if (resultType === 'bytes' && op === '+') {
+ this.emit('cat');
+ } else {
+ const sxOps = BINOP_SX[op];
+ if (!sxOps) throw new Error(`Unknown binary operator: ${op}`);
+ for (const s of sxOps) this.emit(s);
+ }
+
+ this.stackMap.push(bindingName);
+ }
+
+ private lowerUnaryOp(
+ bindingName: string,
+ op: string,
+ operand: string,
+ bindingIndex: number,
+ lastUses: Map,
+ ): void {
+ this.bringToTop(operand, this.isLastUse(operand, bindingIndex, lastUses));
+ this.stackMap.pop();
+
+ const sxOps = UNARYOP_SX[op];
+ if (!sxOps) throw new Error(`Unknown unary operator: ${op}`);
+ for (const s of sxOps) this.emit(s);
+
+ this.stackMap.push(bindingName);
+ }
+
+ private lowerCall(
+ bindingName: string,
+ func: string,
+ args: string[],
+ bindingIndex: number,
+ lastUses: Map,
+ ): void {
+ // assert / exit → value + verify
+ if (func === 'assert' || func === 'exit') {
+ if (args.length >= 1) {
+ this.bringToTop(args[0]!, this.isLastUse(args[0]!, bindingIndex, lastUses));
+ this.stackMap.pop();
+ this.emit('verify');
+ this.stackMap.push(bindingName);
+ }
+ return;
+ }
+
+ // No-op type casts
+ if (func === 'pack' || func === 'toByteString') {
+ if (args.length >= 1) {
+ this.bringToTop(args[0]!, this.isLastUse(args[0]!, bindingIndex, lastUses));
+ this.stackMap.pop();
+ this.stackMap.push(bindingName);
+ }
+ return;
+ }
+
+ if (func === 'super') {
+ this.stackMap.push(bindingName);
+ return;
+ }
+
+ if (func === '__array_access') {
+ this.delegateToHexBackend([{ name: bindingName, value: { kind: 'call', func, args } }]);
+ return;
+ }
+
+ // Specialized codegen — collect StackOps via bridge
+ if (this.trySpecializedCodegen(bindingName, func, args, bindingIndex, lastUses)) {
+ return;
+ }
+
+ // Complex string/math builtins — delegate to hex backend
+ if (func === 'reverseBytes' || func === 'substr' || func === 'right' ||
+ func === 'safediv' || func === 'safemod') {
+ this.delegateToHexBackend([{ name: bindingName, value: { kind: 'call', func, args } }]);
+ return;
+ }
+
+ // len() needs special handling (OP_SIZE + OP_NIP)
+ if (func === 'len') {
+ this.bringToTop(args[0]!, this.isLastUse(args[0]!, bindingIndex, lastUses));
+ this.stackMap.pop();
+ this.emit('size');
+ this.emit('nip');
+ this.stackMap.push(bindingName);
+ return;
+ }
+
+ // split() produces two stack values
+ if (func === 'split') {
+ for (const arg of args) {
+ this.bringToTop(arg, this.isLastUse(arg, bindingIndex, lastUses));
+ }
+ for (let j = 0; j < args.length; j++) this.stackMap.pop();
+ this.emit('split');
+ this.stackMap.push(null); // left part
+ this.stackMap.push(bindingName); // right part (top)
+ return;
+ }
+
+ // Complex builtins — delegate to hex backend to avoid code duplication.
+ // This covers: math builtins, preimage extractors, output builders,
+ // multi-sig, and post-quantum signature verification.
+ if (func === 'computeStateOutputHash' || func === 'computeStateOutput' ||
+ func === 'buildChangeOutput' || func.startsWith('extract') ||
+ func === 'verifyWOTS' || func === 'verifyRabinSig' ||
+ func === 'pow' || func === 'sqrt' || func === 'gcd' ||
+ func === 'divmod' || func === 'log2' ||
+ func === 'clamp' || func === 'mulDiv' || func === 'percentOf' || func === 'sign' ||
+ func === 'checkMultiSig') {
+ this.delegateToHexBackend([{
+ name: bindingName,
+ value: { kind: 'call', func, args },
+ }]);
+ return;
+ }
+
+ // General builtin — lookup in the SX opcode table
+ for (const arg of args) {
+ this.bringToTop(arg, this.isLastUse(arg, bindingIndex, lastUses));
+ }
+ for (let j = 0; j < args.length; j++) this.stackMap.pop();
+
+ const sxOps = BUILTIN_SX[func];
+ if (!sxOps) {
+ // Unknown builtin — emit as comment rather than crashing
+ this.emit(`// ${func}() — unknown builtin`);
+ this.stackMap.push(bindingName);
+ return;
+ }
+ for (const s of sxOps) this.emit(s);
+
+ this.stackMap.push(bindingName);
+ }
+
+
+ // -----------------------------------------------------------------------
+ // Method call → macro invocation
+ // -----------------------------------------------------------------------
+
+ private lowerMethodCall(
+ bindingName: string,
+ object: string,
+ method: string,
+ args: string[],
+ bindingIndex: number,
+ lastUses: Map,
+ ): void {
+ if (method === 'getStateScript') {
+ if (this.stackMap.has(object)) {
+ this.bringToTop(object, true);
+ this.emit('drop');
+ this.stackMap.pop();
+ }
+ this.lowerGetStateScript(bindingName);
+ return;
+ }
+
+ const privateMethod = this.privateMethods.get(method);
+ if (privateMethod) {
+ // Consume @this object reference
+ if (this.stackMap.has(object)) {
+ this.bringToTop(object, true);
+ this.emit('drop');
+ this.stackMap.pop();
+ }
+
+ // Push all args onto stack in parameter order
+ for (const arg of args) {
+ this.bringToTop(arg, this.isLastUse(arg, bindingIndex, lastUses));
+ }
+
+ // Emit the macro name (bare call in SX)
+ this.emit(method);
+
+ // Update stack tracking: pop args, push result (if any)
+ const effect = this.methodEffects.get(method)!;
+ for (let i = 0; i < effect.consumes; i++) this.stackMap.pop();
+ if (effect.produces > 0) {
+ this.stackMap.push(bindingName);
+ }
+ // Void methods (ending in assert/verify) don't push a result —
+ // don't create a phantom binding that would desync the StackMap.
+ return;
+ }
+
+ // Fallback — treat as builtin
+ this.lowerCall(bindingName, method, args, bindingIndex, lastUses);
+ }
+
+ // -----------------------------------------------------------------------
+ // Loop → repeat Nn ... end
+ // -----------------------------------------------------------------------
+
+ private lowerLoop(
+ _bindingName: string,
+ count: number,
+ body: ANFBinding[],
+ iterVar: string,
+ ): void {
+ // Check if iterVar is referenced in the body
+ const bodyRefs = new Set();
+ for (const b of body) {
+ for (const r of collectRefs(b.value)) bodyRefs.add(r);
+ }
+ const usesIterVar = bodyRefs.has(iterVar);
+
+ if (usesIterVar) {
+ // Stack-managed counter: push 0 before repeat, body dups + uses it,
+ // increments at end of each iteration
+ this.emit(`0n`);
+ this.stackMap.push(iterVar);
+ }
+
+ this.emit(`repeat ${count}n`);
+
+ // Lower the body with a nested scheduler at increased indent
+ const bodyScheduler = new SXStackScheduler(
+ [], // no params — the parent stack is implicit
+ this._properties,
+ this.privateMethods,
+ this._indent + ' ',
+ );
+ // Copy parent stack state into body scheduler
+ bodyScheduler.stackMap = this.stackMap.clone();
+ bodyScheduler.localBindings = new Set(body.map(b => b.name));
+ bodyScheduler.outerProtectedRefs = new Set();
+ bodyScheduler.arrayLengths = new Map(this.arrayLengths);
+ bodyScheduler.constValues = new Map(this.constValues);
+
+ // Protect outer-scope refs from being consumed
+ for (const name of this.stackMap.namedSlots()) {
+ if (!body.some(b => b.name === name)) {
+ bodyScheduler.outerProtectedRefs!.add(name);
+ }
+ }
+
+ const lastUses = computeLastUses(body);
+ // Protect outer refs
+ if (bodyScheduler.outerProtectedRefs) {
+ for (const ref of bodyScheduler.outerProtectedRefs) {
+ lastUses.set(ref, body.length);
+ }
+ }
+
+ for (let j = 0; j < body.length; j++) {
+ bodyScheduler.lowerBinding(body[j]!, j, lastUses);
+ }
+
+ // Clean up iterVar if still on stack
+ if (usesIterVar && bodyScheduler.stackMap.has(iterVar)) {
+ // After body, increment counter for next iteration
+ bodyScheduler.bringToTop(iterVar, true);
+ bodyScheduler.emit('1add');
+ bodyScheduler.stackMap.pop();
+ bodyScheduler.stackMap.push(iterVar);
+ }
+
+ for (const t of bodyScheduler.tokens) this.emitRaw(t);
+ this.emit('end');
+
+ // After the repeat, update our stack state
+ // The body may have produced/consumed values — sync from body scheduler
+ this.stackMap = bodyScheduler.stackMap;
+ this.arrayLengths = bodyScheduler.arrayLengths;
+ this.constValues = bodyScheduler.constValues;
+
+ if (usesIterVar && this.stackMap.has(iterVar)) {
+ // Drop the final counter value
+ this.bringToTop(iterVar, true);
+ this.emit('drop');
+ this.stackMap.pop();
+ }
+
+ // Loop is a statement, not an expression — don't push a result
+ }
+
+ // -----------------------------------------------------------------------
+ // If / else → SX control flow
+ // -----------------------------------------------------------------------
+
+ private lowerIf(
+ bindingName: string,
+ cond: string,
+ thenBindings: ANFBinding[],
+ elseBindings: ANFBinding[],
+ bindingIndex: number,
+ lastUses: Map,
+ terminalAssert = false,
+ ): void {
+ // Bring condition to top and consume it
+ this.bringToTop(cond, this.isLastUse(cond, bindingIndex, lastUses));
+ this.stackMap.pop();
+
+ this.emit('if');
+
+ // Then branch
+ const thenScheduler = new SXStackScheduler(
+ [], this._properties, this.privateMethods, this._indent + ' ',
+ );
+ thenScheduler.stackMap = this.stackMap.clone();
+ thenScheduler._insideBranch = true;
+ thenScheduler.outerProtectedRefs = new Set();
+ thenScheduler.arrayLengths = new Map(this.arrayLengths);
+ thenScheduler.constValues = new Map(this.constValues);
+ for (const ref of lastUses.keys()) {
+ const lastIdx = lastUses.get(ref)!;
+ if (lastIdx > bindingIndex && this.stackMap.has(ref)) {
+ thenScheduler.outerProtectedRefs!.add(ref);
+ }
+ }
+ thenScheduler.lowerBindings(thenBindings, terminalAssert);
+ for (const t of thenScheduler.tokens) this.emitRaw(t);
+
+ // Else branch
+ if (elseBindings.length > 0) {
+ this.emit('else');
+ const elseScheduler = new SXStackScheduler(
+ [], this._properties, this.privateMethods, this._indent + ' ',
+ );
+ elseScheduler.stackMap = this.stackMap.clone();
+ elseScheduler._insideBranch = true;
+ elseScheduler.outerProtectedRefs = new Set();
+ elseScheduler.arrayLengths = new Map(this.arrayLengths);
+ elseScheduler.constValues = new Map(this.constValues);
+ for (const ref of lastUses.keys()) {
+ const lastIdx = lastUses.get(ref)!;
+ if (lastIdx > bindingIndex && this.stackMap.has(ref)) {
+ elseScheduler.outerProtectedRefs!.add(ref);
+ }
+ }
+ elseScheduler.lowerBindings(elseBindings, terminalAssert);
+ for (const t of elseScheduler.tokens) this.emitRaw(t);
+
+ // Reconcile: use else branch stack state (both should be symmetric)
+ this.stackMap = elseScheduler.stackMap;
+ } else {
+ // No else — use then branch stack state
+ this.stackMap = thenScheduler.stackMap;
+ }
+
+ this.emit('endIf');
+
+ // If both branches produce a value, rename top to bindingName
+ if (this.stackMap.depth > 0) {
+ const top = this.stackMap.peekAtDepth(0);
+ if (top !== null && top !== bindingName) {
+ this.stackMap.renameAtDepth(0, bindingName);
+ }
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // Assert
+ // -----------------------------------------------------------------------
+
+ private lowerAssert(
+ valueRef: string,
+ bindingIndex: number,
+ lastUses: Map,
+ terminal = false,
+ ): void {
+ this.bringToTop(valueRef, this.isLastUse(valueRef, bindingIndex, lastUses));
+ if (!terminal) {
+ this.stackMap.pop();
+ this.emit('verify');
+ this.stackMap.push(null);
+ }
+ // terminal: leave value on stack (Bitcoin Script checks TOS)
+ }
+
+ // -----------------------------------------------------------------------
+ // Property update
+ // -----------------------------------------------------------------------
+
+ private lowerUpdateProp(
+ propName: string,
+ valueRef: string,
+ bindingIndex: number,
+ lastUses: Map,
+ ): void {
+ this.bringToTop(valueRef, this.isLastUse(valueRef, bindingIndex, lastUses));
+ this.stackMap.pop();
+
+ // 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 (oldDepth === 0) {
+ this.emit('nip');
+ } else {
+ this.emit(`${oldDepth + 1}n roll`);
+ this.emit('drop');
+ }
+ }
+
+ this.stackMap.push(propName);
+ }
+
+ // -----------------------------------------------------------------------
+ // Stateful contract intrinsics + complex builtins — delegated to hex backend
+ // -----------------------------------------------------------------------
+
+ /**
+ * Delegate a set of ANF bindings to the hex backend's stack lowerer,
+ * convert the resulting StackOps to SX text via the bridge, and sync
+ * the SX scheduler's StackMap from the hex backend's final state.
+ *
+ * This avoids duplicating the complex opcode sequences for stateful
+ * intrinsics (checkPreimage, deserializeState, addOutput, extractors, etc.)
+ * and complex math builtins (pow, sqrt, gcd, log2, etc.).
+ */
+ private delegateToHexBackend(bindings: ANFBinding[]): void {
+ // Build the current stack state as a string[] (bottom to top)
+ const stackState: string[] = [];
+ for (let i = this.stackMap.depth - 1; i >= 0; i--) {
+ stackState.unshift(this.stackMap.peekAtDepth(i) ?? `__anon_${i}`);
+ }
+
+ const result = lowerBindingsToOps(
+ bindings,
+ stackState,
+ this._properties,
+ this.privateMethods,
+ false,
+ );
+
+ // Convert StackOps to SX text
+ const sxText = stackOpsToSX(result.ops, this._indent);
+ if (sxText) this.emitRaw(sxText);
+
+ // Sync our StackMap from the hex backend's final state
+ // Clear current and rebuild from finalStack
+ while (this.stackMap.depth > 0) this.stackMap.pop();
+ for (const name of result.finalStack) {
+ this.stackMap.push(name);
+ }
+ }
+
+ private lowerGetStateScript(bindingName: string): void {
+ this.delegateToHexBackend([{ name: bindingName, value: { kind: 'get_state_script' } }]);
+ }
+
+ private lowerCheckPreimage(
+ bindingName: string,
+ preimageRef: string,
+ _bindingIndex: number,
+ _lastUses: Map,
+ ): void {
+ this.delegateToHexBackend([{ name: bindingName, value: { kind: 'check_preimage', preimage: preimageRef } }]);
+ }
+
+ private lowerDeserializeState(
+ bindingName: string,
+ preimageRef: string,
+ _bindingIndex: number,
+ _lastUses: Map,
+ ): void {
+ this.delegateToHexBackend([{ name: bindingName, value: { kind: 'deserialize_state', preimage: preimageRef } }]);
+ }
+
+ private lowerAddOutput(
+ bindingName: string,
+ satoshisRef: string,
+ stateValues: string[],
+ preimageRef: string,
+ _bindingIndex: number,
+ _lastUses: Map,
+ ): void {
+ this.delegateToHexBackend([{
+ name: bindingName,
+ value: { kind: 'add_output', satoshis: satoshisRef, stateValues, preimage: preimageRef },
+ }]);
+ }
+
+ private lowerAddRawOutput(
+ bindingName: string,
+ satoshisRef: string,
+ scriptBytesRef: string,
+ _bindingIndex: number,
+ _lastUses: Map,
+ ): void {
+ this.delegateToHexBackend([{
+ name: bindingName,
+ value: { kind: 'add_raw_output', satoshis: satoshisRef, scriptBytes: scriptBytesRef },
+ }]);
+ }
+
+ // -----------------------------------------------------------------------
+ // Array literal
+ // -----------------------------------------------------------------------
+
+ private lowerArrayLiteral(
+ bindingName: string,
+ elements: string[],
+ bindingIndex: number,
+ lastUses: Map,
+ ): void {
+ for (const elem of elements) {
+ this.bringToTop(elem, this.isLastUse(elem, bindingIndex, lastUses));
+ }
+ for (let i = 0; i < elements.length; i++) this.stackMap.pop();
+ this.stackMap.push(bindingName);
+ this.arrayLengths.set(bindingName, elements.length);
+ }
+
+ // -----------------------------------------------------------------------
+ // Specialized codegen via bridge
+ // -----------------------------------------------------------------------
+
+ private trySpecializedCodegen(
+ bindingName: string,
+ func: string,
+ args: string[],
+ bindingIndex: number,
+ lastUses: Map,
+ ): boolean {
+ // Map function names to their codegen emitters
+ type CodegenEntry = {
+ emitter: (emit: (op: StackOp) => void) => void;
+ argCount: number;
+ };
+
+ let entry: CodegenEntry | null = null;
+
+ if (func === 'sha256Compress' && args.length === 2) {
+ entry = { emitter: emitSha256Compress, argCount: 2 };
+ } else if (func === 'sha256Finalize' && args.length === 3) {
+ entry = { emitter: emitSha256Finalize, argCount: 3 };
+ } else if (func === 'blake3Compress') {
+ entry = { emitter: emitBlake3Compress, argCount: args.length };
+ } else if (func === 'blake3Hash') {
+ entry = { emitter: emitBlake3Hash, argCount: args.length };
+ } else if (func === 'ecAdd') {
+ entry = { emitter: emitEcAdd, argCount: 2 };
+ } else if (func === 'ecMul') {
+ entry = { emitter: emitEcMul, argCount: 2 };
+ } else if (func === 'ecMulGen') {
+ entry = { emitter: emitEcMulGen, argCount: 1 };
+ } else if (func === 'ecNegate') {
+ entry = { emitter: emitEcNegate, argCount: 1 };
+ } else if (func === 'ecOnCurve') {
+ entry = { emitter: emitEcOnCurve, argCount: 1 };
+ } else if (func === 'ecModReduce') {
+ entry = { emitter: emitEcModReduce, argCount: 1 };
+ } else if (func === 'ecEncodeCompressed') {
+ entry = { emitter: emitEcEncodeCompressed, argCount: 1 };
+ } else if (func === 'ecMakePoint') {
+ entry = { emitter: emitEcMakePoint, argCount: 2 };
+ } else if (func === 'ecPointX') {
+ entry = { emitter: emitEcPointX, argCount: 1 };
+ } else if (func === 'ecPointY') {
+ entry = { emitter: emitEcPointY, argCount: 1 };
+ } else if (func === 'bbFieldAdd') {
+ entry = { emitter: emitBBFieldAdd, argCount: 2 };
+ } else if (func === 'bbFieldSub') {
+ entry = { emitter: emitBBFieldSub, argCount: 2 };
+ } else if (func === 'bbFieldMul') {
+ entry = { emitter: emitBBFieldMul, argCount: 2 };
+ } else if (func === 'bbFieldInv') {
+ entry = { emitter: emitBBFieldInv, argCount: 1 };
+ } else if (func === 'bbExt4Mul0') {
+ entry = { emitter: emitBBExt4Mul0, argCount: 8 };
+ } else if (func === 'bbExt4Mul1') {
+ entry = { emitter: emitBBExt4Mul1, argCount: 8 };
+ } else if (func === 'bbExt4Mul2') {
+ entry = { emitter: emitBBExt4Mul2, argCount: 8 };
+ } else if (func === 'bbExt4Mul3') {
+ entry = { emitter: emitBBExt4Mul3, argCount: 8 };
+ } else if (func === 'bbExt4Inv0') {
+ entry = { emitter: emitBBExt4Inv0, argCount: 4 };
+ } else if (func === 'bbExt4Inv1') {
+ entry = { emitter: emitBBExt4Inv1, argCount: 4 };
+ } else if (func === 'bbExt4Inv2') {
+ entry = { emitter: emitBBExt4Inv2, argCount: 4 };
+ } else if (func === 'bbExt4Inv3') {
+ entry = { emitter: emitBBExt4Inv3, argCount: 4 };
+ } else if (func === 'merkleRootSha256') {
+ entry = { emitter: (emit) => emitMerkleRootSha256(emit, Number(this.constValues.get(args[2]!) ?? 0n)), argCount: 3 };
+ } else if (func === 'merkleRootHash256') {
+ entry = { emitter: (emit) => emitMerkleRootHash256(emit, Number(this.constValues.get(args[2]!) ?? 0n)), argCount: 3 };
+ } else if (func.startsWith('verifySLHDSA_SHA2_')) {
+ const paramKey = func.replace('verifySLHDSA_', '');
+ entry = { emitter: (emit) => emitVerifySLHDSA(emit, paramKey), argCount: args.length };
+ }
+
+ if (!entry) return false;
+
+ // Push args onto stack
+ for (const arg of args) {
+ this.bringToTop(arg, this.isLastUse(arg, bindingIndex, lastUses));
+ }
+ for (let i = 0; i < args.length; i++) this.stackMap.pop();
+
+ // Collect StackOps from the codegen emitter
+ const ops: StackOp[] = [];
+ entry.emitter((op) => ops.push(op));
+
+ // Convert to SX text via bridge
+ this.emit(`// ${func}() — specialized codegen`);
+ const sxText = stackOpsToSX(ops, this._indent);
+ if (sxText) this.emitRaw(sxText);
+
+ this.stackMap.push(bindingName);
+ return true;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// SX peephole optimizer — removes redundant token patterns
+// ---------------------------------------------------------------------------
+
+function peepholeSX(tokens: string[]): string[] {
+ let changed = true;
+ let result = tokens;
+
+ while (changed) {
+ changed = false;
+ const next: string[] = [];
+ let i = 0;
+
+ while (i < result.length) {
+ const t1 = result[i]!.trim();
+ const t2 = i + 1 < result.length ? result[i + 1]!.trim() : '';
+
+ // Remove swap swap (no-op)
+ if (t1 === 'swap' && t2 === 'swap') {
+ i += 2;
+ changed = true;
+ continue;
+ }
+
+ // Remove dup drop (no-op)
+ if (t1 === 'dup' && t2 === 'drop') {
+ i += 2;
+ changed = true;
+ continue;
+ }
+
+ // Remove push-then-drop (no-op: false drop, true drop, Nn drop, 0xHH drop)
+ if (t2 === 'drop' && (t1 === 'false' || t1 === 'true' || /^-?\d+n$/.test(t1) || /^0x[0-9a-f]+$/i.test(t1))) {
+ i += 2;
+ changed = true;
+ continue;
+ }
+
+ // Remove rot rot rot (no-op: 3 rotations = identity for 3 items)
+ if (t1 === 'rot' && t2 === 'rot' && i + 2 < result.length && result[i + 2]!.trim() === 'rot') {
+ i += 3;
+ changed = true;
+ continue;
+ }
+
+ next.push(result[i]!);
+ i++;
+ }
+
+ result = next;
+ }
+
+ return result;
+}