From d352e017683eb6cd66cfd53bcf00cf21d2df167a Mon Sep 17 00:00:00 2001 From: Siggi Date: Wed, 8 Apr 2026 14:58:34 +0200 Subject: [PATCH] feat: add BitcoinSX (.sx) output backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- packages/runar-cli/src/bin.ts | 1 + packages/runar-cli/src/commands/compile.ts | 12 +- .../src/__tests__/07-sx-emit.test.ts | 472 +++++++ .../runar-compiler/src/artifact/assembler.ts | 3 + packages/runar-compiler/src/index.ts | 31 +- packages/runar-compiler/src/ir/artifact.ts | 3 + packages/runar-compiler/src/ir/index.ts | 6 + packages/runar-compiler/src/ir/sx-ir.ts | 28 + .../src/passes/05-stack-lower.ts | 57 + .../src/passes/07-sx-codegen-bridge.ts | 220 +++ .../runar-compiler/src/passes/07-sx-emit.ts | 280 ++++ .../runar-compiler/src/passes/07-sx-stack.ts | 1254 +++++++++++++++++ 12 files changed, 2364 insertions(+), 3 deletions(-) create mode 100644 packages/runar-compiler/src/__tests__/07-sx-emit.test.ts create mode 100644 packages/runar-compiler/src/ir/sx-ir.ts create mode 100644 packages/runar-compiler/src/passes/07-sx-codegen-bridge.ts create mode 100644 packages/runar-compiler/src/passes/07-sx-emit.ts create mode 100644 packages/runar-compiler/src/passes/07-sx-stack.ts 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; +}