diff --git a/conformance/runner/script-metrics.ts b/conformance/runner/script-metrics.ts index 76acb6b8..376744ba 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 2626d233..d51ffd35 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 506f1a64..fc519c4f 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 97a71051..852077f6 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 298b69c3..d69703e5 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 4f2f55b1..16019a51 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 566c5d00..3562bfea 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 929136a2..533ea8dc 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 f4a6387a..e2b6e60f 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 00000000..dc6530d0 --- /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); + } + }); +});