From 3c1260d27294c591d6a7567687cb8f5ff9d85e22 Mon Sep 17 00:00:00 2001 From: Siggi Date: Fri, 28 Aug 2026 23:47:07 +0200 Subject: [PATCH] feat(codegen): sign lattice + EC reduction sinking behind --ec-reduction-sinking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `fieldMod` costs 10 bytes and runs ~20,000 times in a P-256 verify. Six of them are a sign fix-up that exists only because OP_MOD takes the sign of the dividend. Where the dividend is provably non-negative they are dead. p256-wallet 958,792 -> 304,463 (pool) -> 179,890 (-81.2%) p384-wallet 1,963,300 -> 463,435 -> 272,678 (-86.1%) ec-primitives 1,332,782 -> 433,880 -> 258,303 (-80.6%) ec-unit 479,716 -> 157,129 -> 93,678 (-80.5%) That is within 94 bytes of the measured ceiling for this transformation (179,796 on p256-wallet, docs/experiments §3.7), so the lattice recovers essentially all of the available win. Opt-in and inert by default: all 72 goldens still reproduce byte-for-byte and script-size-check is 72/72 ok. WHY A LATTICE AND NOT A REWRITE The two paths need different facts, and conflating them is not hypothetical — the ceiling measurement did exactly that, passed 256 EC oracle assertions, and was still wrong: - multiply / add / mulconst need `dividend >= 0`. Unsigned coordinate decoding already gives it, so ~70% of reductions qualify immediately. - subtract's cheap `a - b + p` form needs the strictly stronger `subtrahend < p`, which OP_BIN2NUM of 32 unsigned bytes does NOT imply: a coordinate may exceed p by up to 2^32 + 977. ecAdd((0, 1), (2^256 - 1, 1)) correct : ...fffffffdfffff85f blanket : ...0001000003d0 0x1000003d0 = 2^32 + 977 = 2^256 - p IMPLEMENTATION `Dom` is a three-point lattice (Unknown < NonNegative < Reduced) carried as `ECTracker.dm`, a SLOT-parallel array to `nm` rather than a name-keyed map: names are reused (`_fmul_prod` is written by every multiply) and the same name can be resident twice, so a map would go stale in exactly the cases that matter. Every `nm` mutation mirrors into `dm` with the same splice, external mutation now goes through pushTracked/popTracked/removeSlotAt, and `domainOf` throws if the arrays ever differ in length — a silent desync would hand a transfer function a fact about the wrong slot, which is the one failure mode that yields a smaller script that quietly computes something else. Transfer functions: add/mul are non-negative iff both operands are; a square is non-negative unconditionally; mulconst keeps the operand's sign for positive c; every reduction result is Reduced; a decoded coordinate is NonNegative but never Reduced. Anything a rawBlock or an OP_IF produces stays Unknown, so an un-analysed value can only fall back to the shipping reduction. Group-order reductions are deliberately left at NonNegative rather than Reduced, so a value reduced mod n can never be mistaken for one reduced mod p. The cheap subtraction references the prime twice, so whether to use it is a cost comparison (`cheapSubPays`) against the pooled push cost, not a flag: without `--ec-constant-pool` it would make p256-wallet larger. TESTING `ec-reduction-sinking.test.ts` is a differential sweep, not a signature check — that question was already answered wrongly once. It runs every emitter over the coordinate values on the boundary (0, 1, 2, p-1, p, p+1, 2^256-1, G.x) including the full 8x8 cross product for ecAdd and p256Add, and requires the sunk script's RESULT to match the shipping one on every combination, including ones no valid curve point could produce. The counterexample above is pinned by name. An absolute OpenSSL oracle then re-checks accept plus seven rejection cases. --- conformance/runner/script-metrics.ts | 1 + .../src/__tests__/experimental-flags.test.ts | 6 + packages/runar-cli/src/bin.ts | 1 + packages/runar-cli/src/commands/compile.ts | 7 +- packages/runar-compiler/src/index.ts | 15 + .../src/passes/05-stack-lower.ts | 13 +- .../runar-compiler/src/passes/ec-codegen.ts | 287 ++++++++++++++++-- .../src/passes/oppushtx-codegen.ts | 4 +- .../src/passes/p256-p384-codegen.ts | 105 +++++-- .../__tests__/ec-reduction-sinking.test.ts | 231 ++++++++++++++ 10 files changed, 614 insertions(+), 56 deletions(-) create mode 100644 packages/runar-testing/src/__tests__/ec-reduction-sinking.test.ts diff --git a/conformance/runner/script-metrics.ts b/conformance/runner/script-metrics.ts index 76acb6b81..376744ba0 100644 --- a/conformance/runner/script-metrics.ts +++ b/conformance/runner/script-metrics.ts @@ -72,6 +72,7 @@ export function tsSourcePath(fixture: string, testsDir: string = TESTS_DIR): str export const VARIANTS: Record = { current: {}, 'ec-pool': { ecConstantPool: true }, + 'ec-sink': { ecConstantPool: true, ecReductionSinking: true }, liveness: { schedulerMode: 'liveness' }, both: { ecConstantPool: true, schedulerMode: 'liveness' }, }; diff --git a/packages/runar-cli/src/__tests__/experimental-flags.test.ts b/packages/runar-cli/src/__tests__/experimental-flags.test.ts index 2626d233f..d51ffd358 100644 --- a/packages/runar-cli/src/__tests__/experimental-flags.test.ts +++ b/packages/runar-cli/src/__tests__/experimental-flags.test.ts @@ -128,4 +128,10 @@ describe('experimental size-optimizer flags', () => { const pooled = await hexWith({ ecConstantPool: true }, 'pool'); expect(pooled).toBe(bare); }); + + it('--ec-reduction-sinking is inert on a contract with no EC operations', async () => { + const bare = await hexWith({}, 'nosink'); + const sunk = await hexWith({ ecConstantPool: true, ecReductionSinking: true }, 'sink'); + expect(sunk).toBe(bare); + }); }); diff --git a/packages/runar-cli/src/bin.ts b/packages/runar-cli/src/bin.ts index 506f1a645..fc519c4f5 100644 --- a/packages/runar-cli/src/bin.ts +++ b/packages/runar-cli/src/bin.ts @@ -44,6 +44,7 @@ program .option('--asm', 'print ASM to stdout') .option('--disable-constant-folding', 'disable ANF constant folding pass') .option('--ec-constant-pool', 'EXPERIMENTAL: pool repeated EC curve constants (changes emitted bytes)') + .option('--ec-reduction-sinking', 'EXPERIMENTAL: drop provably-dead sign fix-ups from EC modular reductions') .option('--stack-scheduler ', 'EXPERIMENTAL: operand scheduling — current|liveness', 'current') .option('--from-ir ', 'compile from an ANF IR JSON file (skips parse/validate/typecheck/anf-lower)') .option('--hex', 'print only the script hex to stdout (no artifact JSON)') diff --git a/packages/runar-cli/src/commands/compile.ts b/packages/runar-cli/src/commands/compile.ts index 97a710511..852077f6e 100644 --- a/packages/runar-cli/src/commands/compile.ts +++ b/packages/runar-cli/src/commands/compile.ts @@ -13,6 +13,7 @@ interface CompileOptions { asm?: boolean; disableConstantFolding?: boolean; ecConstantPool?: boolean; + ecReductionSinking?: boolean; stackScheduler?: string; fromIr?: string; hex?: boolean; @@ -96,10 +97,10 @@ 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; ecConstantPool?: boolean; schedulerMode?: 'current' | 'liveness'; parseOnly?: boolean }) => unknown; + type CompileFn = (source: string, options?: { fileName?: string; disableConstantFolding?: boolean; ecConstantPool?: boolean; ecReductionSinking?: boolean; schedulerMode?: 'current' | 'liveness'; parseOnly?: boolean }) => unknown; type CompileFromANFFn = ( program: unknown, - options?: { disableConstantFolding?: boolean; ecConstantPool?: boolean; schedulerMode?: 'current' | 'liveness' }, + options?: { disableConstantFolding?: boolean; ecConstantPool?: boolean; ecReductionSinking?: boolean; schedulerMode?: 'current' | 'liveness' }, ) => { scriptHex: string; scriptAsm: string }; type LoadANFFn = (json: string) => unknown; @@ -192,6 +193,7 @@ export async function compileCommand( result = compileFromANF(program, { disableConstantFolding: options.disableConstantFolding, ecConstantPool: options.ecConstantPool, + ecReductionSinking: options.ecReductionSinking, schedulerMode: schedulerMode(options), }); } catch (err) { @@ -270,6 +272,7 @@ export async function compileCommand( fileName: resolvedPath, disableConstantFolding: options.disableConstantFolding, ecConstantPool: options.ecConstantPool, + ecReductionSinking: options.ecReductionSinking, schedulerMode: schedulerMode(options), parseOnly: options.parseOnly, }) as CompileResultLike; diff --git a/packages/runar-compiler/src/index.ts b/packages/runar-compiler/src/index.ts index 298b69c35..d69703e5a 100644 --- a/packages/runar-compiler/src/index.ts +++ b/packages/runar-compiler/src/index.ts @@ -182,6 +182,17 @@ export interface CompileOptions { */ ecConstantPool?: boolean; + /** + * EXPERIMENTAL. Drop the sign fix-up from EC modular reductions wherever a + * sign lattice proves the dividend non-negative, and use the cheap + * `a - b + p` form where the subtrahend is proved reduced. + * + * Only pays alongside `ecConstantPool` — the cheap subtraction references the + * prime twice — and the codegen compares emitted bytes before choosing it. + * Measured: `verifyECDSA_P256` 304,463 -> ~180,000 bytes with both on. + */ + ecReductionSinking?: boolean; + /** * EXPERIMENTAL. Operand scheduling strategy for the ANF -> Stack pass. * @@ -500,6 +511,7 @@ export function compile(source: string, options?: CompileOptions): CompileResult onProgress?.('Stack lowering', 60); const stackProgram = lowerToStack(optimizedAnf, { ecConstantPool: opts.ecConstantPool === true, + ecReductionSinking: opts.ecReductionSinking === true, schedulerMode: opts.schedulerMode, }); @@ -592,6 +604,8 @@ export interface CompileFromANFOptions { disablePeephole?: boolean; /** EXPERIMENTAL. Pool repeated EC curve constants. See CompileOptions. */ ecConstantPool?: boolean; + /** EXPERIMENTAL. Sink EC modular reductions. See CompileOptions. */ + ecReductionSinking?: boolean; /** EXPERIMENTAL. Operand scheduling strategy. See CompileOptions. */ schedulerMode?: 'current' | 'liveness'; } @@ -665,6 +679,7 @@ export function compileFromANF( const stackProgram = lowerToStack(optimizedAnf, { ecConstantPool: opts.ecConstantPool === true, + ecReductionSinking: opts.ecReductionSinking === true, schedulerMode: opts.schedulerMode, }); if (!opts.disablePeephole) { diff --git a/packages/runar-compiler/src/passes/05-stack-lower.ts b/packages/runar-compiler/src/passes/05-stack-lower.ts index 4f2f55b12..16019a513 100644 --- a/packages/runar-compiler/src/passes/05-stack-lower.ts +++ b/packages/runar-compiler/src/passes/05-stack-lower.ts @@ -81,6 +81,13 @@ export interface LoweringOptions { */ ecConstantPool?: boolean; + /** + * Drop the sign fix-up from EC modular reductions wherever a sign lattice + * proves the dividend non-negative. Only pays alongside `ecConstantPool`; + * the codegen compares emitted bytes before choosing the cheap subtraction. + */ + ecReductionSinking?: boolean; + /** * Operand scheduling strategy. * @@ -1284,7 +1291,11 @@ class LoweringContext { * identical to the shipping ones. */ private ecCodegenOptions(): EcCodegenOptions | undefined { - return this.opts.ecConstantPool ? { constantPool: true } : undefined; + if (!this.opts.ecConstantPool && !this.opts.ecReductionSinking) return undefined; + return { + constantPool: this.opts.ecConstantPool === true, + reductionSinking: this.opts.ecReductionSinking === true, + }; } bringToTop(name: string, consume: boolean): void { diff --git a/packages/runar-compiler/src/passes/ec-codegen.ts b/packages/runar-compiler/src/passes/ec-codegen.ts index 566c5d00f..3562bfea1 100644 --- a/packages/runar-compiler/src/passes/ec-codegen.ts +++ b/packages/runar-compiler/src/passes/ec-codegen.ts @@ -59,6 +59,55 @@ export interface EcCodegenOptions { * `docs/experiments/script-size-optimization-baseline.md`. */ constantPool?: boolean; + + /** + * Emit `a mod p` without the sign fix-up wherever the dividend is provably + * non-negative, and the cheap `a - b + p` form for subtraction wherever the + * subtrahend is provably reduced. + * + * `fieldMod` costs 10 bytes and runs ~20,000 times in a P-256 verify; six of + * those bytes exist only because `OP_MOD` takes the sign of the dividend. + * Which reductions qualify is decided by the sign lattice below — never + * assumed. Worth ~124 kB on p256-wallet, but only alongside `constantPool`: + * the cheap subtraction references the prime twice, so without a pooled slot + * it is a regression. + */ + reductionSinking?: boolean; +} + +// =========================================================================== +// Sign lattice +// =========================================================================== + +/** + * What is known about a tracked value's sign and range. + * + * `Reduced` implies `NonNegative`; the ordering is what the transfer functions + * meet over. `Unknown` is the default for every slot the analysis has not + * explicitly proved something about — including everything a `rawBlock` or an + * `OP_IF` produces — so an un-analysed value can only ever fall back to the + * shipping reduction. + * + * The distinction is not academic. `OP_BIN2NUM` of 32 unsigned coordinate bytes + * gives `NonNegative` but NOT `Reduced`: a coordinate may legitimately be up to + * 2^256 - 1 while p is 2^32 + 977 smaller. Multiplication and addition need only + * `NonNegative`; subtraction's cheap form needs the subtrahend `Reduced`, and + * conflating the two produces a script that passes 256 EC oracle assertions and + * is still wrong on `ecAdd((0,1), (2^256-1,1))`. See + * docs/experiments/script-size-optimizer-results.md §3.8. + */ +export const enum Dom { + /** Nothing known. May be negative. */ + Unknown = 0, + /** Provably >= 0. May be >= p. */ + NonNegative = 1, + /** Provably in [0, p). */ + Reduced = 2, +} + +/** True when `d` proves the value is >= 0. */ +export function isNonNegative(d: Dom): boolean { + return d >= Dom.NonNegative; } /** Stack slot names reserved for pooled constants. */ @@ -67,23 +116,83 @@ export const POOL_GROUP_N = '_pool$n'; export class ECTracker { nm: (string | null)[]; + /** + * Sign-lattice fact per stack SLOT, kept parallel to `nm`. + * + * Slot-parallel rather than keyed by name on purpose: names are reused + * (`_fmul_prod` is written by every multiply) and the same name can be + * resident twice, so a name-keyed map would go stale in exactly the cases + * that matter. Every mutation of `nm` below mirrors into `dm` with the same + * splice, so the two cannot drift. + */ + dm: Dom[]; + /** Lattice facts for values parked on the alt stack, bottom -> top. */ + private altDm: Dom[] = []; _e: (op: StackOp) => void; /** True when this tracker may serve constants from a pooled slot. */ readonly pooling: boolean; + /** True when this tracker may emit sunk reductions. */ + readonly sinking: boolean; constructor( init: (string | null)[], emit: (op: StackOp) => void, opts?: EcCodegenOptions, + initDomains?: Dom[], ) { this.nm = [...init]; + this.dm = init.map((_, i) => initDomains?.[i] ?? Dom.Unknown); this._e = emit; this.pooling = opts?.constantPool === true; + this.sinking = opts?.reductionSinking === true; } /** The options this tracker was built with, for handing to a nested tracker. */ get options(): EcCodegenOptions { - return { constantPool: this.pooling }; + return { constantPool: this.pooling, reductionSinking: this.sinking }; + } + + // -- sign lattice --------------------------------------------------------- + + /** What is known about the named value. Unknown when the name is absent. */ + domainOf(name: string): Dom { + // A silent desync here would hand a transfer function a fact about the + // WRONG slot, which is the one failure mode that produces a smaller script + // that quietly computes something else. Fail loudly instead. + if (this.dm.length !== this.nm.length) { + throw new Error( + `ECTracker: lattice desynchronised (${this.nm.length} slots, ${this.dm.length} facts). ` + + 'Every nm mutation must go through a tracker method or pushTracked/popTracked.', + ); + } + for (let i = this.nm.length - 1; i >= 0; i--) + if (this.nm[i] === name) return this.dm[i] ?? Dom.Unknown; + return Dom.Unknown; + } + + /** Record a fact about the named value's slot. */ + setDomain(name: string, d: Dom): void { + for (let i = this.nm.length - 1; i >= 0; i--) { + if (this.nm[i] === name) { this.dm[i] = d; return; } + } + } + + /** Push a slot the caller tracks itself (used where raw opcodes create items). */ + pushTracked(name: string | null, d: Dom = Dom.Unknown): void { + this.nm.push(name); + this.dm.push(d); + } + + /** Pop a slot the caller tracks itself. Mirror of `pushTracked`. */ + popTracked(): string | null { + this.dm.pop(); + return this.nm.pop() ?? null; + } + + /** Remove the slot at an absolute (bottom-relative) index. */ + removeSlotAt(index: number): void { + this.nm.splice(index, 1); + this.dm.splice(index, 1); } get depth(): number { return this.nm.length; } @@ -95,16 +204,29 @@ export class ECTracker { throw new Error(`ECTracker: '${name}' not on stack [${this.nm.join(',')}]`); } - pushBytes(n: string, v: Uint8Array): void { this._e({ op: 'push', value: v }); this.nm.push(n); } - pushInt(n: string, v: bigint): void { this._e({ op: 'push', value: v }); this.nm.push(n); } - dup(n: string): void { this._e({ op: 'dup' }); this.nm.push(n); } - drop(): void { this._e({ op: 'drop' }); this.nm.pop(); } + pushBytes(n: string, v: Uint8Array): void { + this._e({ op: 'push', value: v }); + // A byte blob is not a number until BIN2NUM decides how to read it. + this.pushTracked(n, Dom.Unknown); + } + pushInt(n: string, v: bigint): void { + this._e({ op: 'push', value: v }); + this.pushTracked(n, v >= 0n ? Dom.NonNegative : Dom.Unknown); + } + dup(n: string): void { + this._e({ op: 'dup' }); + this.pushTracked(n, this.dm[this.dm.length - 1] ?? Dom.Unknown); + } + drop(): void { this._e({ op: 'drop' }); this.nm.pop(); this.dm.pop(); } nip(): void { this._e({ op: 'nip' }); const L = this.nm.length; - if (L >= 2) this.nm.splice(L - 2, 1); + if (L >= 2) { this.nm.splice(L - 2, 1); this.dm.splice(L - 2, 1); } + } + over(n: string): void { + this._e({ op: 'over' }); + this.pushTracked(n, this.dm[this.dm.length - 2] ?? Dom.Unknown); } - over(n: string): void { this._e({ op: 'over' }); this.nm.push(n); } swap(): void { this._e({ op: 'swap' }); const L = this.nm.length; @@ -112,6 +234,9 @@ export class ECTracker { const t = this.nm[L - 1]; this.nm[L - 1] = this.nm[L - 2]!; this.nm[L - 2] = t!; + const d = this.dm[L - 1]!; + this.dm[L - 1] = this.dm[L - 2]!; + this.dm[L - 2] = d; } } rot(): void { @@ -119,7 +244,9 @@ export class ECTracker { const L = this.nm.length; if (L >= 3) { const r = this.nm.splice(L - 3, 1)[0]!; + const d = this.dm.splice(L - 3, 1)[0]!; this.nm.push(r); + this.dm.push(d); } } op(code: string): void { this._e({ op: 'opcode', code }); } @@ -128,21 +255,24 @@ export class ECTracker { if (d === 1) { this.swap(); return; } if (d === 2) { this.rot(); return; } this._e({ op: 'push', value: BigInt(d) }); - this.nm.push(null); + this.pushTracked(null, Dom.NonNegative); this._e({ op: 'roll', depth: d }); - this.nm.pop(); + this.nm.pop(); this.dm.pop(); const idx = this.nm.length - 1 - d; const r = this.nm.splice(idx, 1)[0] ?? null; + const rd = this.dm.splice(idx, 1)[0] ?? Dom.Unknown; this.nm.push(r); + this.dm.push(rd); } pick(d: number, n: string): void { if (d === 0) { this.dup(n); return; } if (d === 1) { this.over(n); return; } this._e({ op: 'push', value: BigInt(d) }); - this.nm.push(null); + this.pushTracked(null, Dom.NonNegative); this._e({ op: 'pick', depth: d }); - this.nm.pop(); - this.nm.push(n); + this.nm.pop(); this.dm.pop(); + // Once the depth literal is gone the copied slot sits at depth d. + this.pushTracked(n, this.dm[this.dm.length - 1 - d] ?? Dom.Unknown); } toTop(name: string): void { this.roll(this.findDepth(name)); } copyToTop(name: string, n?: string): void { this.pick(this.findDepth(name), n ?? name); } @@ -177,6 +307,16 @@ export class ECTracker { * d costs `sizeOfPushValue(d) + 1`; depths 0 and 1 are OP_DUP / OP_OVER, 1 * byte each. */ + /** Emitted bytes a `pushConst` of this constant would cost right now. */ + constCost(slot: string, value: bigint): number { + if (this.pooling && this.nm.includes(slot)) { + const d = this.findDepth(slot); + const pickCost = d <= 1 ? 1 : sizeOfPushValue(BigInt(d)) + 1; + if (pickCost < sizeOfPushValue(value)) return pickCost; + } + return sizeOfPushValue(value); + } + pushConst(slot: string, value: bigint, name: string): void { if (this.pooling && this.nm.includes(slot)) { const d = this.findDepth(slot); @@ -188,8 +328,15 @@ export class ECTracker { } this.pushInt(name, value); } - toAlt(): void { this.op('OP_TOALTSTACK'); this.nm.pop(); } - fromAlt(n: string): void { this.op('OP_FROMALTSTACK'); this.nm.push(n); } + toAlt(): void { + this.op('OP_TOALTSTACK'); + this.nm.pop(); + this.altDm.push(this.dm.pop() ?? Dom.Unknown); + } + fromAlt(n: string): void { + this.op('OP_FROMALTSTACK'); + this.pushTracked(n, this.altDm.pop() ?? Dom.Unknown); + } rename(n: string): void { if (this.nm.length > 0) this.nm[this.nm.length - 1] = n; @@ -197,24 +344,31 @@ export class ECTracker { /** Emit raw opcodes tracking only net stack effect. */ rawBlock(consume: string[], produce: string | null, fn: (e: (op: StackOp) => void) => void): void { - for (let i = consume.length - 1; i >= 0; i--) + for (let i = consume.length - 1; i >= 0; i--) { this.nm.pop(); + this.dm.pop(); + } fn(this._e); - if (produce !== null) - this.nm.push(produce); + if (produce !== null) { + // Opaque opcodes: nothing is known about the result unless the caller + // proves it and records that with setDomain afterwards. + this.pushTracked(produce, Dom.Unknown); + } } /** Emit if/else with tracked stack effect. */ emitIf(condName: string, thenFn: (e: (op: StackOp) => void) => void, elseFn: (e: (op: StackOp) => void) => void, resultName: string | null): void { this.toTop(condName); - this.nm.pop(); // condition consumed + this.nm.pop(); this.dm.pop(); // condition consumed const thenOps: StackOp[] = []; const elseOps: StackOp[] = []; thenFn((op) => thenOps.push(op)); elseFn((op) => elseOps.push(op)); this._e({ op: 'if', then: thenOps, else: elseOps }); - if (resultName !== null) - this.nm.push(resultName); + if (resultName !== null) { + // A join over two arms this tracker did not analyse: nothing is known. + this.pushTracked(resultName, Dom.Unknown); + } } } @@ -256,11 +410,30 @@ function emitScalarReduce(t: ECTracker, kName: string, resultName: string): void }); } +/** + * `a mod p` with no sign fix-up: 1 opcode instead of 7. + * + * Sound only when the dividend is provably >= 0, because `OP_MOD` takes the + * sign of the dividend. The caller proves that; this function does not check. + */ +function fieldModShort(t: ECTracker, aName: string, resultName: string): void { + t.toTop(aName); + pushFieldP(t, '_fmods_p'); + t.rawBlock([aName, '_fmods_p'], resultName, (e) => { + e({ op: 'opcode', code: 'OP_MOD' }); + }); + t.setDomain(resultName, Dom.Reduced); +} + /** * fieldMod: reduce TOS mod p, ensure non-negative. * Expects 'aName' to be on the tracker stack. */ function fieldMod(t: ECTracker, aName: string, resultName: string): void { + if (t.sinking && isNonNegative(t.domainOf(aName))) { + fieldModShort(t, aName, resultName); + return; + } t.toTop(aName); pushFieldP(t, '_fmod_p'); // (a % p + p) % p @@ -274,40 +447,90 @@ function fieldMod(t: ECTracker, aName: string, resultName: string): void { e({ op: 'swap' }); // (a%p+p) p e({ op: 'opcode', code: 'OP_MOD' }); // ((a%p+p)%p) }); + t.setDomain(resultName, Dom.Reduced); } /** fieldAdd: (a + b) mod p */ function fieldAdd(t: ECTracker, aName: string, bName: string, resultName: string): void { + // Read the operand facts BEFORE rawBlock consumes their slots. + const sumNonNeg = isNonNegative(t.domainOf(aName)) && isNonNegative(t.domainOf(bName)); t.toTop(aName); t.toTop(bName); t.rawBlock([aName, bName], '_fadd_sum', (e) => { e({ op: 'opcode', code: 'OP_ADD' }); }); + if (sumNonNeg) t.setDomain('_fadd_sum', Dom.NonNegative); fieldMod(t, '_fadd_sum', resultName); } +/** + * Does the cheap subtraction shape pay here? + * + * `a - b + p` then one OP_MOD references the prime TWICE; the shipping shape + * references it once and pays six more opcodes. So it only wins when the prime + * is cheap to materialise — i.e. when it is pooled. Without a pool this + * rewrite makes p256-wallet LARGER (958,792 -> 999,371 measured), which is why + * it is a cost comparison and not a flag. + */ +function cheapSubPays(t: ECTracker): boolean { + const c = t.constCost(POOL_FIELD_P, FIELD_P); + return 2 * c + 2 < c + 8; +} + /** fieldSub: (a - b) mod p (non-negative) */ function fieldSub(t: ECTracker, aName: string, bName: string, resultName: string): void { t.toTop(aName); t.toTop(bName); + // The cheap shape needs a >= 0 AND b in [0, p): then a - b > -p, so a single + // shifted reduction is exact. `b >= 0` alone is NOT enough — a coordinate + // decoded from 32 unsigned bytes can exceed p by up to 2^32 + 977, which is + // precisely the ecAdd((0,1), (2^256-1,1)) counterexample. + const cheap = t.sinking + && isNonNegative(t.domainOf(aName)) + && t.domainOf(bName) === Dom.Reduced + && cheapSubPays(t); + t.rawBlock([aName, bName], '_fsub_diff', (e) => { e({ op: 'opcode', code: 'OP_SUB' }); }); + + if (cheap) { + pushFieldP(t, '_fsub_p'); + t.rawBlock(['_fsub_diff', '_fsub_p'], '_fsub_shift', (e) => { + e({ op: 'opcode', code: 'OP_ADD' }); + }); + t.setDomain('_fsub_shift', Dom.NonNegative); + fieldModShort(t, '_fsub_shift', resultName); + return; + } fieldMod(t, '_fsub_diff', resultName); } -/** fieldMul: (a * b) mod p */ -function fieldMul(t: ECTracker, aName: string, bName: string, resultName: string): void { +/** + * fieldMul: (a * b) mod p + * + * `productNonNegative` lets a caller assert the product's sign independently of + * the operands — `fieldSqr` uses it, since a*a >= 0 for any a whatsoever. + */ +function fieldMul( + t: ECTracker, aName: string, bName: string, resultName: string, + productNonNegative = false, +): void { + const nonNeg = productNonNegative + || (isNonNegative(t.domainOf(aName)) && isNonNegative(t.domainOf(bName))); t.toTop(aName); t.toTop(bName); t.rawBlock([aName, bName], '_fmul_prod', (e) => { e({ op: 'opcode', code: 'OP_MUL' }); }); + if (nonNeg) t.setDomain('_fmul_prod', Dom.NonNegative); fieldMod(t, '_fmul_prod', resultName); } /** fieldMulConst: (a * c) mod p where c is a small constant. Uses OP_2MUL for c=2. */ function fieldMulConst(t: ECTracker, aName: string, c: bigint, resultName: string): void { + // Every call site passes a small positive c, so the product keeps a's sign. + const nonNeg = c > 0n && isNonNegative(t.domainOf(aName)); t.toTop(aName); t.rawBlock([aName], '_fmc_prod', (e) => { if (c === 2n) { @@ -317,13 +540,14 @@ function fieldMulConst(t: ECTracker, aName: string, c: bigint, resultName: strin e({ op: 'opcode', code: 'OP_MUL' }); } }); + if (nonNeg) t.setDomain('_fmc_prod', Dom.NonNegative); fieldMod(t, '_fmc_prod', resultName); } -/** fieldSqr: (a * a) mod p */ +/** fieldSqr: (a * a) mod p. A square is non-negative whatever a's sign is. */ function fieldSqr(t: ECTracker, aName: string, resultName: string): void { t.copyToTop(aName, '_fsqr_copy'); - fieldMul(t, aName, '_fsqr_copy', resultName); + fieldMul(t, aName, '_fsqr_copy', resultName, true); } /** @@ -382,8 +606,8 @@ function decomposePoint(t: ECTracker, pointName: string, xName: string, yName: s e({ op: 'opcode', code: 'OP_SPLIT' }); }); // Manually track the two new items - t.nm.push('_dp_xb'); - t.nm.push('_dp_yb'); + t.pushTracked('_dp_xb', Dom.Unknown); + t.pushTracked('_dp_yb', Dom.Unknown); // Convert y_bytes (on top) to num // Reverse from BE to LE, append 0x00 sign byte to ensure unsigned, then BIN2NUM @@ -393,6 +617,10 @@ function decomposePoint(t: ECTracker, pointName: string, xName: string, yName: s e({ op: 'opcode', code: 'OP_CAT' }); e({ op: 'opcode', code: 'OP_BIN2NUM' }); }); + // A 0x00 sign byte is appended before BIN2NUM, so the coordinate decodes + // UNSIGNED: >= 0, but it may be up to 2^256 - 1 and therefore >= p. That gap + // is exactly what the subtraction precondition turns on. + t.setDomain(yName, Dom.NonNegative); // Convert x_bytes to num t.toTop('_dp_xb'); @@ -402,6 +630,7 @@ function decomposePoint(t: ECTracker, pointName: string, xName: string, yName: s e({ op: 'opcode', code: 'OP_CAT' }); e({ op: 'opcode', code: 'OP_BIN2NUM' }); }); + t.setDomain(xName, Dom.NonNegative); // Stack: [yName, xName] — swap to standard order [xName, yName] t.swap(); @@ -704,7 +933,7 @@ function jacobianToAffine(t: ECTracker, rxName: string, ryName: string): void { */ function buildJacobianAddAffineInline(e: (op: StackOp) => void, t: ECTracker): void { // Create inner tracker with cloned stack state - jacobianAddAffineBody(new ECTracker([...t.nm], e, t.options), false); + jacobianAddAffineBody(new ECTracker([...t.nm], e, t.options, [...t.dm]), false); } /** @@ -858,7 +1087,7 @@ function selectCoord(t: ECTracker, addName: string, dblName: string, condName: s * Stack layout: [..., ax, ay, _k, jx, jy, jz] — same in and out. */ function buildJacobianAddOrDoubleInline(e: (op: StackOp) => void, t: ECTracker): void { - const it = new ECTracker([...t.nm], e, t.options); + const it = new ECTracker([...t.nm], e, t.options, [...t.dm]); // Keep the pre-add accumulator: it is what must be DOUBLED in the // exceptional case, and the add below consumes jx/jy/jz. @@ -999,7 +1228,7 @@ export function emitEcMul(emit: (op: StackOp) => void, opts?: EcCodegenOptions): // Move _bit to TOS and remove from tracker BEFORE generating add ops, // because OP_IF consumes _bit and the add ops run with _bit already gone. t.toTop('_bit'); - t.nm.pop(); // _bit consumed by IF + t.popTracked(); // _bit consumed by IF const addOps: StackOp[] = []; const addEmit = (op: StackOp) => addOps.push(op); // Only the final step can be handed two equal operands — see diff --git a/packages/runar-compiler/src/passes/oppushtx-codegen.ts b/packages/runar-compiler/src/passes/oppushtx-codegen.ts index 929136a24..533ea8dca 100644 --- a/packages/runar-compiler/src/passes/oppushtx-codegen.ts +++ b/packages/runar-compiler/src/passes/oppushtx-codegen.ts @@ -148,9 +148,9 @@ export function emitCheckPreimageBinding( // --- DER content of s = last SIZE(s) bytes of reverse32(NUM2BIN(s,32)) ---- t.toTop('_ppt_slow'); t._e({ op: 'opcode', code: 'OP_SIZE' }); // [slow, L] - t.nm.push('_ppt_L'); + t.pushTracked('_ppt_L'); t._e({ op: 'opcode', code: 'OP_TOALTSTACK' }); // alt=[L]; [slow] - t.nm.pop(); + t.popTracked(); t.rawBlock(['_ppt_slow'], '_ppt_sbe', (e) => { e({ op: 'push', value: 32n }); e({ op: 'opcode', code: 'OP_NUM2BIN' }); // 32-byte LE diff --git a/packages/runar-compiler/src/passes/p256-p384-codegen.ts b/packages/runar-compiler/src/passes/p256-p384-codegen.ts index f4a6387ab..e2b6e60f2 100644 --- a/packages/runar-compiler/src/passes/p256-p384-codegen.ts +++ b/packages/runar-compiler/src/passes/p256-p384-codegen.ts @@ -14,7 +14,7 @@ */ import type { StackOp } from '../ir/index.js'; -import { ECTracker, POOL_FIELD_P, POOL_GROUP_N } from './ec-codegen.js'; +import { ECTracker, POOL_FIELD_P, POOL_GROUP_N, Dom, isNonNegative } from './ec-codegen.js'; import type { EcCodegenOptions } from './ec-codegen.js'; // =========================================================================== @@ -133,7 +133,30 @@ function pushFieldP(t: ECTracker, name: string, c: CurveParams): void { t.pushConst(POOL_FIELD_P, c.fieldP, name); } +/** + * `a mod p` with no sign fix-up: 1 opcode instead of 7. Sound only when the + * dividend is provably >= 0 — the caller proves that, this does not check. + */ +function cFieldModShort(t: ECTracker, aName: string, resultName: string, c: CurveParams): void { + t.toTop(aName); + pushFieldP(t, '_fmods_p', c); + t.rawBlock([aName, '_fmods_p'], resultName, (e) => { + e({ op: 'opcode', code: 'OP_MOD' }); + }); + t.setDomain(resultName, Dom.Reduced); +} + +/** Does the cheap `a - b + p` subtraction pay? Only when p is pooled. */ +function cCheapSubPays(t: ECTracker, c: CurveParams): boolean { + const cost = t.constCost(POOL_FIELD_P, c.fieldP); + return 2 * cost + 2 < cost + 8; +} + function cFieldMod(t: ECTracker, aName: string, resultName: string, c: CurveParams): void { + if (t.sinking && isNonNegative(t.domainOf(aName))) { + cFieldModShort(t, aName, resultName, c); + return; + } t.toTop(aName); pushFieldP(t, '_fmod_p', c); t.rawBlock([aName, '_fmod_p'], resultName, (e) => { @@ -146,36 +169,68 @@ function cFieldMod(t: ECTracker, aName: string, resultName: string, c: CurvePara e({ op: 'swap' }); e({ op: 'opcode', code: 'OP_MOD' }); }); + t.setDomain(resultName, Dom.Reduced); } function cFieldAdd(t: ECTracker, aName: string, bName: string, resultName: string, c: CurveParams): void { + // Read the operand facts before rawBlock consumes their slots. + const sumNonNeg = isNonNegative(t.domainOf(aName)) && isNonNegative(t.domainOf(bName)); t.toTop(aName); t.toTop(bName); t.rawBlock([aName, bName], '_fadd_sum', (e) => { e({ op: 'opcode', code: 'OP_ADD' }); }); + if (sumNonNeg) t.setDomain('_fadd_sum', Dom.NonNegative); cFieldMod(t, '_fadd_sum', resultName, c); } function cFieldSub(t: ECTracker, aName: string, bName: string, resultName: string, c: CurveParams): void { t.toTop(aName); t.toTop(bName); + // Needs a >= 0 AND b in [0, p): then a - b > -p and one shifted reduction is + // exact. `b >= 0` alone is not enough — a coordinate decoded from 32 unsigned + // bytes may exceed p by up to 2^32 + 977. + const cheap = t.sinking + && isNonNegative(t.domainOf(aName)) + && t.domainOf(bName) === Dom.Reduced + && cCheapSubPays(t, c); + t.rawBlock([aName, bName], '_fsub_diff', (e) => { e({ op: 'opcode', code: 'OP_SUB' }); }); + + if (cheap) { + pushFieldP(t, '_fsub_p', c); + t.rawBlock(['_fsub_diff', '_fsub_p'], '_fsub_shift', (e) => { + e({ op: 'opcode', code: 'OP_ADD' }); + }); + t.setDomain('_fsub_shift', Dom.NonNegative); + cFieldModShort(t, '_fsub_shift', resultName, c); + return; + } cFieldMod(t, '_fsub_diff', resultName, c); } -function cFieldMul(t: ECTracker, aName: string, bName: string, resultName: string, c: CurveParams): void { +function cFieldMul( + t: ECTracker, aName: string, bName: string, resultName: string, c: CurveParams, + productNonNegative = false, +): void { + // `productNonNegative` lets cFieldSqr assert the sign independently of the + // operand: a*a >= 0 for any a whatsoever. + const nonNeg = productNonNegative + || (isNonNegative(t.domainOf(aName)) && isNonNegative(t.domainOf(bName))); t.toTop(aName); t.toTop(bName); t.rawBlock([aName, bName], '_fmul_prod', (e) => { e({ op: 'opcode', code: 'OP_MUL' }); }); + if (nonNeg) t.setDomain('_fmul_prod', Dom.NonNegative); cFieldMod(t, '_fmul_prod', resultName, c); } function cFieldMulConst(t: ECTracker, aName: string, cv: bigint, resultName: string, c: CurveParams): void { + // Every call site passes a small positive cv, so the product keeps a's sign. + const nonNeg = cv > 0n && isNonNegative(t.domainOf(aName)); t.toTop(aName); t.rawBlock([aName], '_fmc_prod', (e) => { if (cv === 2n) { @@ -185,12 +240,13 @@ function cFieldMulConst(t: ECTracker, aName: string, cv: bigint, resultName: str e({ op: 'opcode', code: 'OP_MUL' }); } }); + if (nonNeg) t.setDomain('_fmc_prod', Dom.NonNegative); cFieldMod(t, '_fmc_prod', resultName, c); } function cFieldSqr(t: ECTracker, aName: string, resultName: string, c: CurveParams): void { t.copyToTop(aName, '_fsqr_copy'); - cFieldMul(t, aName, '_fsqr_copy', resultName, c); + cFieldMul(t, aName, '_fsqr_copy', resultName, c, true); } /** @@ -325,8 +381,8 @@ function cDecomposePoint(t: ECTracker, pointName: string, xName: string, yName: e({ op: 'push', value: BigInt(c.coordBytes) }); e({ op: 'opcode', code: 'OP_SPLIT' }); }); - t.nm.push('_dp_xb'); - t.nm.push('_dp_yb'); + t.pushTracked('_dp_xb'); + t.pushTracked('_dp_yb'); // Convert y_bytes (on top) to num: reverse BE→LE, append sign byte, BIN2NUM t.rawBlock(['_dp_yb'], yName, (e) => { @@ -335,6 +391,10 @@ function cDecomposePoint(t: ECTracker, pointName: string, xName: string, yName: e({ op: 'opcode', code: 'OP_CAT' }); e({ op: 'opcode', code: 'OP_BIN2NUM' }); }); + // A 0x00 sign byte is appended before BIN2NUM, so the coordinate decodes + // UNSIGNED: >= 0, but possibly >= p by up to 2^32 + 977. That gap is exactly + // what the subtraction precondition turns on. + t.setDomain(yName, Dom.NonNegative); // Convert x_bytes to num t.toTop('_dp_xb'); @@ -344,6 +404,7 @@ function cDecomposePoint(t: ECTracker, pointName: string, xName: string, yName: e({ op: 'opcode', code: 'OP_CAT' }); e({ op: 'opcode', code: 'OP_BIN2NUM' }); }); + t.setDomain(xName, Dom.NonNegative); // Swap to standard order [xName, yName] t.swap(); @@ -649,7 +710,7 @@ function cJacobianToAffine(t: ECTracker, rxName: string, ryName: string, c: Curv * After: [..., ax, ay, _k, jx', jy', jz'] */ function buildJacobianAddAffineInline(e: (op: StackOp) => void, t: ECTracker, c: CurveParams): void { - jacobianAddAffineBody(new ECTracker([...t.nm], e, t.options), false, c); + jacobianAddAffineBody(new ECTracker([...t.nm], e, t.options, [...t.dm]), false, c); } /** @@ -796,7 +857,7 @@ function cSelectCoord( * Stack layout: [..., ax, ay, _k, jx, jy, jz] — same in and out. */ function buildJacobianAddOrDoubleInline(e: (op: StackOp) => void, t: ECTracker, c: CurveParams): void { - const it = new ECTracker([...t.nm], e, t.options); + const it = new ECTracker([...t.nm], e, t.options, [...t.dm]); // Keep the pre-add accumulator: it is what must be DOUBLED in the // exceptional case, and the add below consumes jx/jy/jz. @@ -929,7 +990,7 @@ function cEmitMul( // Conditional add t.toTop('_bit'); - t.nm.pop(); // _bit consumed by IF + t.popTracked(); // _bit consumed by IF const addOps: StackOp[] = []; const addEmit = (op: StackOp) => addOps.push(op); // Only the final step can be handed two equal operands — see @@ -1024,8 +1085,8 @@ function decompressPubKey( e({ op: 'push', value: 1n }); e({ op: 'opcode', code: 'OP_SPLIT' }); }); - t.nm.push('_dk_prefix'); - t.nm.push('_dk_xbytes'); + t.pushTracked('_dk_prefix'); + t.pushTracked('_dk_xbytes'); // SEC1 §2.3.4 requires the prefix to be exactly 0x02 or 0x03. The parity // reduction below is `BIN2NUM, 2 MOD`, which accepts far more than that: @@ -1120,7 +1181,7 @@ function decompressPubKey( // Use OP_IF to select: if match, use y_cand (drop neg_y), else use neg_y (drop y_cand) // First, bring match to top for the IF t.toTop('_dk_match'); - t.nm.pop(); // condition consumed by IF + t.popTracked(); // condition consumed by IF // After IF consumes _dk_match, stack is: [..., _dk_x_save, _dk_y_cand, _dk_neg_y] // Then branch (match): we want y_cand → drop neg_y (TOS) @@ -1134,7 +1195,7 @@ function decompressPubKey( // Tracker still has both _dk_y_cand and _dk_neg_y; one was consumed. // Remove one and rename the remaining to qyName. const negIdx = t.nm.lastIndexOf('_dk_neg_y'); - if (negIdx >= 0) t.nm.splice(negIdx, 1); + if (negIdx >= 0) t.removeSlotAt(negIdx); // The surviving item is _dk_y_cand — rename it const ycIdx = t.nm.lastIndexOf('_dk_y_cand'); if (ycIdx >= 0) t.nm[ycIdx] = qyName; @@ -1210,8 +1271,8 @@ function cEmitLengthGate(t: ECTracker, name: string, want: number, flagName: str e({ op: 'opcode', code: 'OP_SPLIT' }); e({ op: 'drop' }); }); - t.nm.push(flagName); - t.nm.push(name); + t.pushTracked(flagName); + t.pushTracked(name); } /** @@ -1345,8 +1406,8 @@ function cEmitVerifyECDSA( e({ op: 'push', value: BigInt(c.coordBytes) }); e({ op: 'opcode', code: 'OP_SPLIT' }); }); - t.nm.push('_r_bytes'); - t.nm.push('_s_bytes'); + t.pushTracked('_r_bytes'); + t.pushTracked('_s_bytes'); // Convert r_bytes to integer t.toTop('_r_bytes'); @@ -1419,14 +1480,14 @@ function cEmitVerifyECDSA( // cEmitMul creates its own ECTracker with ['_pt', '_k'] — items below // the top two are invisible to it. Remove _G and _u1 from our tracker. - t.nm.pop(); // _u1 - t.nm.pop(); // _G + t.popTracked(); // _u1 + t.popTracked(); // _G // Emit the mul (it manages its own tracker internally) cEmitMul(emit, c, g, opts); // After mul, one result point is on the stack - t.nm.push('_R1_point'); + t.pushTracked('_R1_point'); // Altstack (top→bottom): _qx, _qy, _u2, _r_save // Pop qx/qy/u2 FIRST while _qx is still altstack top (LIFO order) @@ -1444,10 +1505,10 @@ function cEmitVerifyECDSA( // Stack: [..., _Q_point, _u2] // Pop from tracker, emit mul, push result - t.nm.pop(); // _u2 - t.nm.pop(); // _Q_point + t.popTracked(); // _u2 + t.popTracked(); // _Q_point cEmitMul(emit, c, g, opts); - t.nm.push('_R2_point'); + t.pushTracked('_R2_point'); // Restore R1 point t.fromAlt('_R1_point'); diff --git a/packages/runar-testing/src/__tests__/ec-reduction-sinking.test.ts b/packages/runar-testing/src/__tests__/ec-reduction-sinking.test.ts new file mode 100644 index 000000000..dc6530d02 --- /dev/null +++ b/packages/runar-testing/src/__tests__/ec-reduction-sinking.test.ts @@ -0,0 +1,231 @@ +/** + * Reduction sinking — soundness, by differential sweep over the boundary. + * + * `fieldMod` costs 10 bytes and is emitted ~20,000 times in a P-256 verify. Six + * of those bytes are a sign fix-up that exists only because `OP_MOD` takes the + * sign of the dividend; where the dividend is provably non-negative they are + * dead weight. Dropping them is worth ~124 kB on p256-wallet + * (docs/experiments/script-size-optimizer-results.md §3.7). + * + * The danger is precise and was found by construction before this was built + * (§3.8): the multiply / add paths need only `dividend >= 0`, which unsigned + * coordinate decoding already gives — but the SUBTRACT path needs the strictly + * stronger `subtrahend < p`, and `OP_BIN2NUM` of 32 unsigned bytes does not + * imply it. A blanket rewrite passes 256 EC oracle assertions and is still + * wrong on: + * + * ecAdd((0, 1), (2^256 - 1, 1)) + * + * where the two differ by exactly 2^256 - p = 2^32 + 977. + * + * So this file does not test "does it still verify a signature" — that question + * was already answered wrongly once. It sweeps the coordinate values that sit + * on the boundary (0, 1, p-1, p, p+1, 2^256-1) and requires the sunk script to + * be byte-for-byte identical in RESULT to the shipping one for every single + * combination, including the ones no valid curve point could ever produce. + */ + +import { describe, it, expect } from 'vitest'; +import { createSign, generateKeyPairSync } from 'node:crypto'; +import { + emitMethod, + emitEcAdd, emitEcNegate, emitEcOnCurve, emitEcMul, + emitP256Add, emitP256Negate, emitP256OnCurve, + emitVerifyECDSA_P256, +} from 'runar-compiler'; +import type { StackOp } from 'runar-ir-schema'; +import { ScriptVM } from '../index.js'; + +type Opts = { constantPool?: boolean; reductionSinking?: boolean }; +type Emitter = (emit: (op: StackOp) => void, opts?: Opts) => void; + +const SECP_P = 0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2fn; +const P256_P = 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffffn; +const MAX256 = (1n << 256n) - 1n; + +const SECP_G = { + x: 0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798n, + y: 0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8n, +}; +const P256_G = { + x: 0x6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296n, + y: 0x4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5n, +}; + +const hx = (v: bigint) => v.toString(16).padStart(64, '0'); +const pt = (x: bigint, y: bigint) => hx(x) + hx(y); + +/** Compile the emitter under `opts`, run it on `inputs`, return the final stack. */ +function run(emitter: Emitter, inputs: string[], opts: Opts): string[] { + const ops: StackOp[] = inputs.map( + h => ({ op: 'push', value: Uint8Array.from(Buffer.from(h, 'hex')) } as StackOp), + ); + emitter(op => ops.push(op), opts); + const { scriptHex } = emitMethod({ name: 't', ops } as never) as { scriptHex: string }; + const r = new ScriptVM().executeHex(scriptHex) as never as { + stack: Uint8Array[]; error?: string; + }; + if (r.error) return [`ERR:${r.error}`]; + return r.stack.map(b => Buffer.from(b).toString('hex')); +} + +/** Sinking is compared against pooling alone, so only the reduction changes. */ +const BASE: Opts = { constantPool: true }; +const SUNK: Opts = { constantPool: true, reductionSinking: true }; + +function expectAgrees(emitter: Emitter, inputs: string[], label: string): void { + expect(run(emitter, inputs, SUNK), label).toEqual(run(emitter, inputs, BASE)); +} + +/** + * Coordinate values that sit on every boundary the analysis has to respect. + * `p`, `p+1` and `2^256-1` are NON-CANONICAL — no valid point has them — but + * the bare builtins accept raw coordinates, so the emitted script must still + * agree with the shipping one on them. + */ +const SECP_EDGE = [0n, 1n, 2n, SECP_P - 1n, SECP_P, SECP_P + 1n, MAX256, SECP_G.x]; +const P256_EDGE = [0n, 1n, 2n, P256_P - 1n, P256_P, P256_P + 1n, MAX256, P256_G.x]; + +describe('reduction sinking agrees with the shipping reduction', () => { + describe('secp256k1', () => { + it.each(SECP_EDGE)('ecOnCurve at x = %s', (x) => { + expectAgrees(emitEcOnCurve, [pt(x, 1n)], `onCurve x=${x}`); + expectAgrees(emitEcOnCurve, [pt(1n, x)], `onCurve y=${x}`); + }); + + it.each(SECP_EDGE)('ecNegate at y = %s', (y) => { + expectAgrees(emitEcNegate, [pt(1n, y)], `negate y=${y}`); + }); + + // The cross product is where the subtraction precondition lives: the cheap + // form breaks only when the SUBTRAHEND is non-canonical and the minuend is + // smaller than 2^256 - p. + const PAIRS: Array<[bigint, bigint]> = []; + for (const a of SECP_EDGE) for (const b of SECP_EDGE) PAIRS.push([a, b]); + + it.each(PAIRS)('ecAdd((%s,1), (%s,1))', (ax, bx) => { + expectAgrees(emitEcAdd, [pt(ax, 1n), pt(bx, 1n)], `ecAdd ${ax} ${bx}`); + }); + + it('ecAdd((0,1), (2^256-1,1)) — the counterexample that motivated this', () => { + // A blanket short reduction returns a value differing by exactly + // 2^256 - p = 0x1000003d0. This must now agree. + expectAgrees(emitEcAdd, [pt(0n, 1n), pt(MAX256, 1n)], 'counterexample'); + }); + + it.each([0n, 1n, 2n, 7n, SECP_P])('ecMul(G, %s)', (k) => { + const ops: StackOp[] = [ + { op: 'push', value: Uint8Array.from(Buffer.from(pt(SECP_G.x, SECP_G.y), 'hex')) } as StackOp, + { op: 'push', value: k } as StackOp, + ]; + const go = (opts: Opts): string[] => { + const list = [...ops]; + emitEcMul(op => list.push(op), opts); + const { scriptHex } = emitMethod({ name: 't', ops: list } as never) as { scriptHex: string }; + const r = new ScriptVM().executeHex(scriptHex) as never as { stack: Uint8Array[]; error?: string }; + return r.error ? [`ERR:${r.error}`] : r.stack.map(b => Buffer.from(b).toString('hex')); + }; + expect(go(SUNK)).toEqual(go(BASE)); + }); + }); + + describe('P-256', () => { + it.each(P256_EDGE)('p256OnCurve at x = %s', (x) => { + expectAgrees(emitP256OnCurve, [pt(x, 1n)], `p256OnCurve x=${x}`); + expectAgrees(emitP256OnCurve, [pt(1n, x)], `p256OnCurve y=${x}`); + }); + + it.each(P256_EDGE)('p256Negate at y = %s', (y) => { + expectAgrees(emitP256Negate, [pt(1n, y)], `p256Negate y=${y}`); + }); + + const PAIRS: Array<[bigint, bigint]> = []; + for (const a of P256_EDGE) for (const b of P256_EDGE) PAIRS.push([a, b]); + + it.each(PAIRS)('p256Add((%s,1), (%s,1))', (ax, bx) => { + expectAgrees(emitP256Add, [pt(ax, 1n), pt(bx, 1n)], `p256Add ${ax} ${bx}`); + }); + }); + + describe('verifyECDSA_P256', () => { + const CASES: Array<[string, string, string, string]> = [ + ['all-zero', '00'.repeat(4), '00'.repeat(64), '02' + hx(0n)], + ['non-canonical pubkey x', 'aabbccdd', '11'.repeat(64), '02' + hx(P256_P + 1n)], + ['max pubkey x', 'aabbccdd', '11'.repeat(64), '02' + hx(MAX256)], + ['max r and s', 'aabbccdd', hx(MAX256) + hx(MAX256), '02' + hx(P256_G.x)], + ['r = p, s = 1', 'aabbccdd', hx(P256_P) + hx(1n), '02' + hx(P256_G.x)], + ]; + it.each(CASES)('%s', (_label, msg, sig, pk) => { + expectAgrees(emitVerifyECDSA_P256, [msg, sig, pk], _label); + }); + }); +}); + +describe('reduction sinking under an absolute oracle', () => { + // The differential sweep above proves "same as before". This proves "still + // right", against a signature this repo did not produce — the check that a + // blanket rewrite would also have passed, which is why it is not the only one. + const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }); + const der = publicKey.export({ format: 'der', type: 'spki' }) as Buffer; + const uncompressed = der.subarray(der.length - 65).toString('hex'); + const qx = BigInt('0x' + uncompressed.slice(2, 66)); + const qy = BigInt('0x' + uncompressed.slice(66)); + const compressed = ((qy & 1n) === 0n ? '02' : '03') + hx(qx); + + const msgHex = '52c3ad6172206d657373616765'; + const signer = createSign('sha256'); + signer.update(Buffer.from(msgHex, 'hex')); + const sigDer = signer.sign(privateKey) as Buffer; + let i = 0; + if (sigDer[i++] !== 0x30) throw new Error('not DER'); + if (sigDer[i]! & 0x80) i += 1 + (sigDer[i]! & 0x7f); else i += 1; + const readInt = (): bigint => { + if (sigDer[i++] !== 0x02) throw new Error('not a DER integer'); + const len = sigDer[i++]!; + const v = BigInt('0x' + sigDer.subarray(i, i + len).toString('hex')); + i += len; + return v; + }; + const sigHex = hx(readInt()) + hx(readInt()); + + const verify = (msg: string, sig: string, pk: string): boolean => { + const st = run(emitVerifyECDSA_P256, [msg, sig, pk], SUNK); + expect(st.length, `expected one boolean out, got ${st.join(',')}`).toBe(1); + return st[0] !== '' && st[0] !== '00'; + }; + + it('accepts a genuine OpenSSL signature', () => { + expect(verify(msgHex, sigHex, compressed)).toBe(true); + }); + + it.each([ + ['wrong message', msgHex + '00', () => sigHex, () => compressed], + ['wrong pubkey parity', msgHex, () => sigHex, + () => (compressed.slice(0, 2) === '02' ? '03' : '02') + compressed.slice(2)], + ['all-zero signature', msgHex, () => '0'.repeat(128), () => compressed], + ['r = 0', msgHex, () => '0'.repeat(64) + sigHex.slice(64), () => compressed], + ['s = 0', msgHex, () => sigHex.slice(0, 64) + '0'.repeat(64), () => compressed], + ['r = n', msgHex, () => hx(0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551n) + sigHex.slice(64), () => compressed], + ['non-canonical pubkey x', msgHex, () => sigHex, () => '02' + hx(P256_P + 1n)], + ] as Array<[string, string, () => string, () => string]>)('rejects %s', (_l, msg, sig, pk) => { + expect(verify(msg, sig(), pk())).toBe(false); + }); +}); + +describe('reduction sinking is not a no-op', () => { + it('shrinks every emitter it applies to', () => { + const bytes = (e: Emitter, o: Opts): number => { + const ops: StackOp[] = []; + e(op => ops.push(op), o); + return (emitMethod({ name: 't', ops } as never) as { scriptHex: string }).scriptHex.length / 2; + }; + for (const [name, e] of [ + ['emitEcAdd', emitEcAdd], ['emitEcOnCurve', emitEcOnCurve], + ['emitP256Add', emitP256Add], ['emitVerifyECDSA_P256', emitVerifyECDSA_P256], + ] as Array<[string, Emitter]>) { + const base = bytes(e, BASE); + const sunk = bytes(e, SUNK); + expect(sunk, `${name} did not shrink (${base} -> ${sunk})`).toBeLessThan(base); + } + }); +});