diff --git a/compilers/go/codegen/comb.go b/compilers/go/codegen/comb.go new file mode 100644 index 000000000..75cb36e71 --- /dev/null +++ b/compilers/go/codegen/comb.go @@ -0,0 +1,317 @@ +package codegen + +import "math/big" + +// Fixed-base comb: compile-time table, and the soundness check that decides +// where the cheap incomplete addition may be used. +// +// Port of packages/runar-compiler/src/passes/comb.ts. The binary ladders in +// ec.go / p256_p384.go use the cheap mixed add at every step but the last, +// justified by an interval argument over c_i mod n. That comment is emphatic +// that the argument must be RE-DERIVED, not assumed, by anything which changes +// the offset, the iteration count, or the reduce — and a comb changes all +// three. combSafeRounds below is that re-derivation, written as executable +// interval arithmetic rather than prose, so a round only gets the cheap add +// when the exception is proved unreachable. Rounds it cannot prove fall back to +// the complete add-or-double form. +// +// Nothing here emits Script. It is pure arithmetic over big.Ints, run once per +// compilation, and unit-tested against published curve vectors. + +// CombPoint is an affine point. A nil *CombPoint is the point at infinity. +type CombPoint struct { + X *big.Int + Y *big.Int +} + +// CombCurve describes a short-Weierstrass curve for the compile-time table. +type CombCurve struct { + P *big.Int // field prime + A *big.Int // curve coefficient a: -3 on the NIST curves, 0 on secp256k1 + B *big.Int // curve coefficient b + N *big.Int // group order + G *CombPoint +} + +// CombParams is the comb geometry for one window width, chosen so the top +// digit is never zero. +// +// The binary ladder hardcodes k + 3n, which puts the scalar's top bit at a +// fixed position and so keeps the accumulator off the point at infinity. A comb +// needs the same guarantee, but its first round reads bit w*d - 1, so the +// offset has to be chosen against w*d rather than assumed. OffsetMultiple is +// the smallest m for which every k + m*n has bit w*d - 1 set: +// +// m*n >= 2^(w*d - 1) and (m+1)*n - 1 < 2^(w*d) +// +// m*n == 0 (mod n) so the result is unchanged. For P-256 at w=3 the search +// returns m=3, d=86 — i.e. exactly the +3n the binary ladder already uses. For +// P-384 at w=3 it returns m=5, d=129; assuming +3n there would have left the +// top digit free to be zero. +type CombParams struct { + W int + // D is the round count, and the block width. Digit i reads bits + // i, i+d, ..., i+(w-1)d. + D int + OffsetMultiple *big.Int + // Lo and Hi are the inclusive scalar domain after the offset. + Lo *big.Int + Hi *big.Int +} + +func hexBig(s string) *big.Int { + v, ok := new(big.Int).SetString(s, 16) + if !ok { + panic("comb: bad hex constant " + s) + } + return v +} + +// P256CombCurve, P384CombCurve and Secp256k1CombCurve are the three curves the +// comb is wired for. secp256k1 is NOT built from the NIST template: it is +// y² = x³ + 7, so a = 0. Getting a wrong here does not produce an obviously +// broken table — it produces a table of points on a DIFFERENT curve, which that +// other curve's on-curve check would happily accept. Hence the published 2G +// vectors pinned in comb_test.go. +// Declared as direct var initializers, NOT assigned in an init() func: Go runs +// package-level var initialization BEFORE init(), so a var in another file that +// referenced these would capture nil. Dependency-ordered initialization makes +// that impossible. +var P256CombCurve = &CombCurve{ + P: hexBig("ffffffff00000001000000000000000000000000ffffffffffffffffffffffff"), + A: big.NewInt(-3), + B: hexBig("5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b"), + N: hexBig("ffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551"), + G: &CombPoint{ + X: hexBig("6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296"), + Y: hexBig("4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5"), + }, +} + +var P384CombCurve = &CombCurve{ + P: hexBig("fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffeffffffff0000000000000000ffffffff"), + A: big.NewInt(-3), + B: hexBig("b3312fa7e23ee7e4988e056be3f82d19181d9c6efe8141120314088f5013875ac656398d8a2ed19d2a85c8edd3ec2aef"), + N: hexBig("ffffffffffffffffffffffffffffffffffffffffffffffffc7634d81f4372ddf581a0db248b0a77aecec196accc52973"), + G: &CombPoint{ + X: hexBig("aa87ca22be8b05378eb1c71ef320ad746e1d3b628ba79b9859f741e082542a385502f25dbf55296c3a545e3872760ab7"), + Y: hexBig("3617de4a96262c6f5d9e98bf9292dc29f8f41dbd289a147ce9da3113b5f0b8c00a60b1ce1d7e819d7a431d7c90ea0e5f"), + }, +} + +var Secp256k1CombCurve = &CombCurve{ + P: hexBig("fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f"), + A: big.NewInt(0), + B: big.NewInt(7), + N: hexBig("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"), + G: &CombPoint{ + X: hexBig("79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"), + Y: hexBig("483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8"), + }, +} + +// CombGeometry returns the geometry for window width w, or nil if no offset in +// the search range puts a guaranteed set bit at the top of the first digit. +// Returning nil rather than guessing keeps the caller from silently combing a +// scalar whose leading digit can vanish. +func CombGeometry(w int, c *CombCurve) *CombParams { + base := (c.N.BitLen() + w - 1) / w + for d := base; d <= base+2; d++ { + bits := uint(w * d) + top := new(big.Int).Lsh(big.NewInt(1), bits-1) + cap_ := new(big.Int).Lsh(big.NewInt(1), bits) + for m := int64(1); m <= 16; m++ { + mm := big.NewInt(m) + lo := new(big.Int).Mul(mm, c.N) + hi := new(big.Int).Mul(new(big.Int).Add(mm, big.NewInt(1)), c.N) + hi.Sub(hi, big.NewInt(1)) + if lo.Cmp(top) >= 0 && hi.Cmp(cap_) < 0 { + return &CombParams{W: w, D: d, OffsetMultiple: mm, Lo: lo, Hi: hi} + } + } + } + return nil +} + +// --------------------------------------------------------------------------- +// Affine arithmetic (compile time only) +// --------------------------------------------------------------------------- + +func combMod(v, m *big.Int) *big.Int { + r := new(big.Int).Mod(v, m) + if r.Sign() < 0 { + r.Add(r, m) + } + return r +} + +// CombAffineAdd is affine addition. A nil operand or result is the point at +// infinity. +func CombAffineAdd(p, q *CombPoint, c *CombCurve) *CombPoint { + if p == nil { + return q + } + if q == nil { + return p + } + if p.X.Cmp(q.X) == 0 { + if combMod(new(big.Int).Add(p.Y, q.Y), c.P).Sign() == 0 { + return nil // P == -Q + } + // Tangent. + num := combMod(new(big.Int).Add( + new(big.Int).Mul(big.NewInt(3), new(big.Int).Mul(p.X, p.X)), c.A), c.P) + den := new(big.Int).ModInverse(combMod(new(big.Int).Lsh(p.Y, 1), c.P), c.P) + lam := combMod(new(big.Int).Mul(num, den), c.P) + x := combMod(new(big.Int).Sub(new(big.Int).Mul(lam, lam), + new(big.Int).Lsh(p.X, 1)), c.P) + y := combMod(new(big.Int).Sub( + new(big.Int).Mul(lam, new(big.Int).Sub(p.X, x)), p.Y), c.P) + return &CombPoint{X: x, Y: y} + } + den := new(big.Int).ModInverse(combMod(new(big.Int).Sub(q.X, p.X), c.P), c.P) + lam := combMod(new(big.Int).Mul(combMod(new(big.Int).Sub(q.Y, p.Y), c.P), den), c.P) + x := combMod(new(big.Int).Sub(new(big.Int).Sub( + new(big.Int).Mul(lam, lam), p.X), q.X), c.P) + y := combMod(new(big.Int).Sub( + new(big.Int).Mul(lam, new(big.Int).Sub(p.X, x)), p.Y), c.P) + return &CombPoint{X: x, Y: y} +} + +// CombScalarMul is compile-time double-and-add. A nil result is infinity. +func CombScalarMul(k *big.Int, p *CombPoint, c *CombCurve) *CombPoint { + var r *CombPoint + base := p + e := combMod(k, c.N) + for e.Sign() > 0 { + if e.Bit(0) == 1 { + r = CombAffineAdd(r, base, c) + } + base = CombAffineAdd(base, base, c) + e = new(big.Int).Rsh(e, 1) + } + return r +} + +// --------------------------------------------------------------------------- +// Comb table +// --------------------------------------------------------------------------- + +// CombValue is the multiple of G that table entry j represents. +// +// Comb round i consumes bits {i, i+d, i+2d, ...} of the scalar — one from each +// block — so entry j stands for the sum of 2^(t*d) over the set bits t of j. +func CombValue(j, d int) *big.Int { + v := big.NewInt(0) + for t := 0; (j >> t) != 0; t++ { + if (j>>t)&1 == 1 { + v.Add(v, new(big.Int).Lsh(big.NewInt(1), uint(t*d))) + } + } + return v +} + +// CombTable returns T[j] = CombValue(j)·G. Index 0 is the point at infinity and +// is never added. +func CombTable(w, d int, c *CombCurve) []*CombPoint { + table := make([]*CombPoint, 1<= 0 { + return true // wraps a full residue class + } + t := combMod(target, n) + // Smallest value >= lo that is congruent to t (mod n). + first := new(big.Int).Add(lo, combMod(new(big.Int).Sub(t, lo), n)) + return first.Cmp(hi) <= 0 +} + +// CombSafeRounds gives the per-round verdict: may round i use the cheap +// incomplete mixed add? +// +// The exception the cheap formula cannot represent is a pre-add accumulator +// equal to the addend, its negation, or the point at infinity. After round i's +// doubling the accumulator is 2·c_{i+1}·G, and the addend is CombValue(j)·G for +// whichever digit j the scalar selects — so the round is safe exactly when, for +// every j, +// +// 2·c_{i+1} != 0, +CombValue(j), -CombValue(j) (mod n) +// +// over the whole interval of c_{i+1}. Both G and every table entry are +// compile-time constants and the curves have cofactor 1, so ord(G) = n and this +// is decidable here. Anything the checker cannot prove gets the complete +// add-or-double form instead; true is never assumed. +// +// Index d-1 is false by construction: that round initialises the accumulator +// from the table and performs no addition at all. +func CombSafeRounds(params *CombParams, c *CombCurve) []bool { + values := make([]*big.Int, 0, (1<= 0 { + t.Fatalf("%s: scalar domain escapes the digit width", c.name) + } + } +} + +func TestCombSafeRoundsProvesMostAndRefusesTheTail(t *testing.T) { + for _, c := range combCurves { + params := CombGeometry(3, c.curve) + safe := CombSafeRounds(params, c.curve) + if len(safe) != params.D { + t.Fatalf("%s: %d verdicts for %d rounds", c.name, len(safe), params.D) + } + proved := 0 + for _, s := range safe { + if s { + proved++ + } + } + if proved <= params.D-8 { + t.Fatalf("%s: only %d/%d rounds proved", c.name, proved, params.D) + } + // The interval widens as i falls; once it can wrap a full residue class + // the checker MUST give up rather than assume. A checker that proved + // every round would be broken, not clever. + if safe[0] { + t.Fatalf("%s: round 0 must not be provable", c.name) + } + if safe[params.D-1] { + t.Fatalf("%s: the initialising round performs no add", c.name) + } + } +} + +// Widening the scalar domain can only make rounds LESS provable. A checker that +// gained confidence from a looser precondition would be unsound. +func TestCombSafeRoundsIsMonotone(t *testing.T) { + params := CombGeometry(3, Secp256k1CombCurve) + strict := CombSafeRounds(params, Secp256k1CombCurve) + loose := CombSafeRounds(&CombParams{ + W: params.W, D: params.D, OffsetMultiple: params.OffsetMultiple, + Lo: params.Lo, Hi: new(big.Int).Lsh(params.Hi, 1), + }, Secp256k1CombCurve) + for i := range strict { + if loose[i] && !strict[i] { + t.Fatalf("round %d became provable under a WIDER domain", i) + } + } +} diff --git a/compilers/go/codegen/cost_model.go b/compilers/go/codegen/cost_model.go new file mode 100644 index 000000000..3c853403e --- /dev/null +++ b/compilers/go/codegen/cost_model.go @@ -0,0 +1,88 @@ +package codegen + +import "math/big" + +// Script-byte cost model for Stack IR. +// +// Port of packages/runar-compiler/src/metrics/cost-model.ts. Optimizer passes +// need to compare two candidate lowerings by the metric that actually matters — +// serialized locking-script bytes — before either one is emitted. OP_DUP and a +// 33-byte constant push are one instruction each and 1 vs 34 bytes; an +// instruction count cannot tell them apart. +// +// This is deliberately NOT an approximation: every push routes through the same +// encoders emit.go uses, so +// +// EstimateScriptBytes(ops) == len(emitted hex) / 2 +// +// holds exactly. cost_model_test.go asserts that over the conformance corpus. + +// SizeOfPushValue returns the serialized byte cost of a single push value. +// +// Mirrors encodePushValue in emit.go: booleans are the 1-byte OP_TRUE / +// OP_FALSE, big.Ints go through the small-int opcodes where possible, and byte +// slices are MINIMALDATA-aware before falling back to a length-prefixed push. +func SizeOfPushValue(value PushValue) int { + hexStr, _ := encodePushValue(value) + return len(hexStr) / 2 +} + +// SizeOfPushBigInt is SizeOfPushValue for a bare integer, which is what the +// constant pool and the comb width search compare against. +func SizeOfPushBigInt(n *big.Int) int { + hexStr, _ := encodePushBigInt(n) + return len(hexStr) / 2 +} + +// SizeOfStackOp returns the serialized byte cost of one Stack IR operation, +// including nested if arms. +// +// Note on pick / roll: they cost ONE byte here. The depth operand is a separate +// push op that the lowerer emits immediately before, so charging the depth here +// would double-count it. +// +// Panics on an unknown opcode mnemonic rather than costing it zero — a typo in +// a codegen module should surface loudly, not as a cost model that quietly +// under-reports. +func SizeOfStackOp(op StackOp) int { + switch op.Op { + case "push": + return SizeOfPushValue(op.Value) + + case "dup", "swap", "roll", "pick", "drop", "nip", "over", "rot", "tuck": + return 1 + + case "opcode": + if _, ok := opcodes[op.Code]; !ok { + panic("cost-model: unknown opcode '" + op.Code + "'") + } + return 1 + + case "if": + // OP_IF + then + [OP_ELSE + else] + OP_ENDIF. The emitter writes + // OP_ELSE only for a NON-EMPTY else arm. + total := 2 + total += EstimateScriptBytes(op.Then) + if len(op.Else) > 0 { + total += 1 + EstimateScriptBytes(op.Else) + } + return total + + case "placeholder", "push_codesep_index": + // Both emit a single 0x00 byte that the SDK rewrites later. + return 1 + + case "raw_bytes": + return len(op.RawBytes) + } + panic("cost-model: unknown stack op kind '" + op.Op + "'") +} + +// EstimateScriptBytes returns the serialized byte cost of a Stack IR sequence. +func EstimateScriptBytes(ops []StackOp) int { + total := 0 + for _, op := range ops { + total += SizeOfStackOp(op) + } + return total +} diff --git a/compilers/go/codegen/cost_model_test.go b/compilers/go/codegen/cost_model_test.go new file mode 100644 index 000000000..7c1cfda48 --- /dev/null +++ b/compilers/go/codegen/cost_model_test.go @@ -0,0 +1,102 @@ +package codegen + +import ( + "testing" +) + +// The cost model must be a CHECKED MIRROR of the emitter, not a second opinion. +// +// Every optimizer decision downstream — which constants to pool, which comb +// window to keep — is made by comparing SizeOfStackOp totals BEFORE any bytes +// exist. A model that drifts from emit.go by even one byte per push silently +// picks the wrong candidate and reports a saving that is not there. So the gate +// is exact equality against the real emitter over every crypto emitter in the +// tier, if-bodies and all. +func TestCostModelMatchesEmitter(t *testing.T) { + cases := []struct { + name string + emit func(func(StackOp)) + }{ + {"Sha256Compress", EmitSha256Compress}, + {"Sha256Finalize", EmitSha256Finalize}, + {"Blake3Compress", EmitBlake3Compress}, + {"Blake3Hash", EmitBlake3Hash}, + {"EcAdd", ecNoOpts(EmitEcAdd)}, + {"EcMul", ecNoOpts(EmitEcMul)}, + {"EcMulGen", ecNoOpts(EmitEcMulGen)}, + {"EcNegate", ecNoOpts(EmitEcNegate)}, + {"EcOnCurve", ecNoOpts(EmitEcOnCurve)}, + {"EcModReduce", EmitEcModReduce}, + {"EcEncodeCompressed", EmitEcEncodeCompressed}, + {"EcMakePoint", EmitEcMakePoint}, + {"EcPointX", EmitEcPointX}, + {"EcPointY", EmitEcPointY}, + {"P256Add", ecNoOpts(EmitP256Add)}, + {"P256Mul", ecNoOpts(EmitP256Mul)}, + {"P256MulGen", ecNoOpts(EmitP256MulGen)}, + {"VerifyECDSA_P256", ecNoOpts(EmitVerifyECDSA_P256)}, + {"P384Add", ecNoOpts(EmitP384Add)}, + {"P384Mul", ecNoOpts(EmitP384Mul)}, + {"P384MulGen", ecNoOpts(EmitP384MulGen)}, + {"VerifyECDSA_P384", ecNoOpts(EmitVerifyECDSA_P384)}, + {"VerifyWOTS", EmitVerifyWOTS}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ops := gatherOps(tc.emit) + method := &StackMethod{Name: "t", Ops: ops} + res, err := EmitMethod(method) + if err != nil { + t.Fatalf("%s: emit failed: %v", tc.name, err) + } + want := len(res.ScriptHex) / 2 + if got := EstimateScriptBytes(ops); got != want { + t.Fatalf("%s: cost model says %d bytes, emitter produced %d", tc.name, got, want) + } + }) + } +} + +// A pick/roll depth operand is a SEPARATE push op the tracker emits just +// before, so charging the depth to the pick would double-count it. Pin that. +func TestCostModelPickRollAreOneByte(t *testing.T) { + for _, op := range []StackOp{{Op: "pick", Depth: 40}, {Op: "roll", Depth: 40}} { + if got := SizeOfStackOp(op); got != 1 { + t.Fatalf("%s: want 1 byte, got %d", op.Op, got) + } + } +} + +// The emitter writes OP_ELSE only for a NON-EMPTY else arm, so an if with an +// empty else is 3 bytes, not 4. Every conditional add in the ladders and the +// comb has an empty else arm, so a wrong constant here mis-scores tens of +// thousands of branches. +func TestCostModelEmptyElseArmHasNoOpElse(t *testing.T) { + empty := StackOp{Op: "if", Then: []StackOp{{Op: "dup"}}} + if got := SizeOfStackOp(empty); got != 3 { + t.Fatalf("empty else arm: want 3 bytes (OP_IF DUP OP_ENDIF), got %d", got) + } + full := StackOp{Op: "if", Then: []StackOp{{Op: "dup"}}, Else: []StackOp{{Op: "drop"}}} + if got := SizeOfStackOp(full); got != 5 { + t.Fatalf("non-empty else arm: want 5 bytes, got %d", got) + } +} + +// An unknown mnemonic must fail loudly. Costing it zero is how a codegen typo +// becomes a size report that is quietly wrong. +func TestCostModelRejectsUnknownOpcode(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("expected a panic for an unknown opcode") + } + }() + SizeOfStackOp(StackOp{Op: "opcode", Code: "OP_NOT_A_REAL_OPCODE"}) +} + +// ecNoOpts adapts an options-taking EC emitter to the bare +// `func(func(StackOp))` shape the op-count and cost-model tables use. The +// flag-sensitive behaviour is covered separately by +// ec_flag_parity_test.go; these tables are about the DEFAULT output. +func ecNoOpts(f func(func(StackOp), *EcCodegenOptions)) func(func(StackOp)) { + return func(e func(StackOp)) { f(e, nil) } +} diff --git a/compilers/go/codegen/crypto_codegen_test.go b/compilers/go/codegen/crypto_codegen_test.go index a5c846406..1a05ffcb2 100644 --- a/compilers/go/codegen/crypto_codegen_test.go +++ b/compilers/go/codegen/crypto_codegen_test.go @@ -46,22 +46,22 @@ func TestCryptoEmitOpCountGoldens(t *testing.T) { {"Sha256Finalize", EmitSha256Finalize, 63941}, {"Blake3Compress", EmitBlake3Compress, 10373}, {"Blake3Hash", EmitBlake3Hash, 10387}, - {"EcAdd", EmitEcAdd, 8223}, - {"EcMul", EmitEcMul, 130515}, - {"EcMulGen", EmitEcMulGen, 130517}, - {"EcNegate", EmitEcNegate, 945}, - {"EcOnCurve", EmitEcOnCurve, 533}, - {"P256Add", EmitP256Add, 6663}, - {"P256Mul", EmitP256Mul, 140036}, + {"EcAdd", ecNoOpts(EmitEcAdd), 8223}, + {"EcMul", ecNoOpts(EmitEcMul), 130515}, + {"EcMulGen", ecNoOpts(EmitEcMulGen), 130517}, + {"EcNegate", ecNoOpts(EmitEcNegate), 945}, + {"EcOnCurve", ecNoOpts(EmitEcOnCurve), 533}, + {"P256Add", ecNoOpts(EmitP256Add), 6663}, + {"P256Mul", ecNoOpts(EmitP256Mul), 140036}, // +58 ops: SEC1 §4.1.4 / FIPS 186-5 input-validation gates on the // verifier's untrusted arguments — sig/pubkey length gate // (cEmitLengthGate), signature range gate 1<=r,s<=n-1 // (cEmitSigRangeGate), and the pubkey prefix-byte check folded into // cDecompressPubKey's _dk_valid. P-384 carries the identical fix but // has no golden entry in this table. - {"VerifyECDSA_P256", EmitVerifyECDSA_P256, 297331}, - {"P384Add", EmitP384Add, 11469}, - {"P384Mul", EmitP384Mul, 211178}, + {"VerifyECDSA_P256", ecNoOpts(EmitVerifyECDSA_P256), 297331}, + {"P384Add", ecNoOpts(EmitP384Add), 11469}, + {"P384Mul", ecNoOpts(EmitP384Mul), 211178}, {"VerifyWOTS", EmitVerifyWOTS, 15488}, } for _, tc := range cases { diff --git a/compilers/go/codegen/ec.go b/compilers/go/codegen/ec.go index dabf7a452..8ffa15d0e 100644 --- a/compilers/go/codegen/ec.go +++ b/compilers/go/codegen/ec.go @@ -22,6 +22,9 @@ var ecFieldP *big.Int // p - 2, used for Fermat's little theorem modular inverse var ecFieldPMinus2 *big.Int +// secp256k1 curve order +var ecCurveN *big.Int + // secp256k1 generator x-coordinate var ecGenX *big.Int @@ -31,6 +34,7 @@ var ecGenY *big.Int func init() { ecFieldP, _ = new(big.Int).SetString("fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f", 16) ecFieldPMinus2 = new(big.Int).Sub(ecFieldP, big.NewInt(2)) + ecCurveN, _ = new(big.Int).SetString("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141", 16) ecGenX, _ = new(big.Int).SetString("79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", 16) ecGenY, _ = new(big.Int).SetString("483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8", 16) } @@ -49,18 +53,196 @@ func bigintToBytes32(n *big.Int) []byte { // =========================================================================== // ECTracker tracks named stack positions and emits StackOps for EC codegen. +// EcCodegenOptions are the codegen options shared by every EC / NIST-curve +// emitter. +// +// Off by default: with a nil pointer (or all-false struct) each emitter is +// byte-identical to what the seven tiers ship today, so no golden, size +// baseline, or cross-tier parity gate can move. +type EcCodegenOptions struct { + // ConstantPool parks large repeated constants (the field prime, the group + // order) in a stack slot and copies them with OP_PICK instead of re-pushing + // the literal. + // + // ecFieldMod pushes the 256-bit prime at every modular reduction — 34 bytes + // a time, 20,025 times in p256-wallet (71 % of that fixture). A pick from a + // slot a dozen deep costs 2. + ConstantPool bool + + // ReductionSinking emits `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. + // + // Which reductions qualify is decided by the sign lattice below — never + // assumed. Only sound alongside ConstantPool: the cheap subtraction + // references the prime twice, so without a pooled slot it is a regression. + ReductionSinking bool + + // FixedBaseComb uses a Lim-Lee comb instead of the binary ladder wherever + // the base point is a compile-time constant (EcMulGen, P256MulGen, + // P384MulGen, and the u1*G half of ECDSA verification). + // + // The window width is not fixed: the emitter renders each candidate and + // keeps whichever the byte-cost model scores smallest. + FixedBaseComb bool +} + +// ecDom is what is known about a tracked value's sign and range. +// +// domReduced implies domNonNegative; the ordering is what the transfer +// functions meet over. domUnknown 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 domNonNegative but NOT domReduced: a coordinate may legitimately be up +// to 2^256 - 1 while p is 2^32 + 977 smaller. Multiplication and addition need +// only domNonNegative; subtraction's cheap form needs the subtrahend +// domReduced, 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)). +type ecDom int + +const ( + // domUnknown means nothing is known; the value may be negative. + domUnknown ecDom = iota + // domNonNegative means the value is provably >= 0. It may be >= p. + domNonNegative + // domReduced means the value is provably in [0, p). + domReduced +) + +// isNonNegative reports whether d proves the value is >= 0. +func isNonNegative(d ecDom) bool { return d >= domNonNegative } + +// Stack slot names reserved for pooled constants. +const ( + ecPoolFieldP = "_pool$p" + ecPoolGroupN = "_pool$n" +) + type ECTracker struct { nm []string // stack names ("" for anonymous) - e func(StackOp) + // dm holds the 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 []ecDom + // altDm holds lattice facts for values parked on the alt stack, bottom to top. + altDm []ecDom + e func(StackOp) + // pooling is true when this tracker may serve constants from a pooled slot. + pooling bool + // sinking is true when this tracker may emit sunk reductions. + sinking bool + // comb is true when a compile-time-known base may use a fixed-base comb. + comb bool } // NewECTracker creates a new tracker with initial named stack slots. func NewECTracker(init []string, emit func(StackOp)) *ECTracker { + return NewECTrackerOpts(init, emit, nil, nil) +} + +// NewECTrackerOpts creates a tracker carrying codegen options and, optionally, +// initial lattice facts for the pre-existing slots. +func NewECTrackerOpts(init []string, emit func(StackOp), opts *EcCodegenOptions, initDomains []ecDom) *ECTracker { nm := make([]string, len(init)) copy(nm, init) - return &ECTracker{nm: nm, e: emit} + dm := make([]ecDom, len(init)) + if initDomains != nil { + copy(dm, initDomains) + } + t := &ECTracker{nm: nm, dm: dm, e: emit} + if opts != nil { + t.pooling = opts.ConstantPool + t.sinking = opts.ReductionSinking + t.comb = opts.FixedBaseComb + } + return t +} + +// options returns the options this tracker was built with, for handing to a +// nested tracker. +func (t *ECTracker) options() *EcCodegenOptions { + return &EcCodegenOptions{ConstantPool: t.pooling, ReductionSinking: t.sinking, FixedBaseComb: t.comb} +} + +// domainsCopy returns a copy of the lattice facts, for seeding a nested tracker. +func (t *ECTracker) domainsCopy() []ecDom { + out := make([]ecDom, len(t.dm)) + copy(out, t.dm) + return out +} + +// namesCopy returns a copy of the stack names, for seeding a nested tracker. +func (t *ECTracker) namesCopy() []string { + out := make([]string, len(t.nm)) + copy(out, t.nm) + return out +} + +// -- sign lattice ------------------------------------------------------------ + +// domainOf reports what is known about the named value. domUnknown when the +// name is absent. +func (t *ECTracker) domainOf(name string) ecDom { + // 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 len(t.dm) != len(t.nm) { + panic(fmt.Sprintf( + "ECTracker: lattice desynchronised (%d slots, %d facts). "+ + "Every nm mutation must go through a tracker method or pushTracked/popTracked.", + len(t.nm), len(t.dm))) + } + for i := len(t.nm) - 1; i >= 0; i-- { + if t.nm[i] == name { + return t.dm[i] + } + } + return domUnknown +} + +// setDomain records a fact about the named value's slot. +func (t *ECTracker) setDomain(name string, d ecDom) { + for i := len(t.nm) - 1; i >= 0; i-- { + if t.nm[i] == name { + t.dm[i] = d + return + } + } } +// pushTracked pushes a slot the caller tracks itself (used where raw opcodes +// create items). +func (t *ECTracker) pushTracked(name string, d ecDom) { + t.nm = append(t.nm, name) + t.dm = append(t.dm, d) +} + +// popTracked pops a slot the caller tracks itself. Mirror of pushTracked. +func (t *ECTracker) popTracked() string { + if len(t.nm) == 0 { + return "" + } + n := t.nm[len(t.nm)-1] + t.nm = t.nm[:len(t.nm)-1] + t.dm = t.dm[:len(t.dm)-1] + return n +} + +// removeSlotAt removes the slot at an absolute (bottom-relative) index. +func (t *ECTracker) removeSlotAt(index int) { + t.nm = append(t.nm[:index], t.nm[index+1:]...) + t.dm = append(t.dm[:index], t.dm[index+1:]...) +} + +func (t *ECTracker) depth() int { return len(t.nm) } + func (t *ECTracker) findDepth(name string) int { for i := len(t.nm) - 1; i >= 0; i-- { if t.nm[i] == name { @@ -72,42 +254,57 @@ func (t *ECTracker) findDepth(name string) int { func (t *ECTracker) pushBytes(n string, v []byte) { t.e(StackOp{Op: "push", Value: PushValue{Kind: "bytes", Bytes: v}}) - t.nm = append(t.nm, n) + // A byte blob is not a number until BIN2NUM decides how to read it. + t.pushTracked(n, domUnknown) } func (t *ECTracker) pushBigInt(n string, v *big.Int) { t.e(StackOp{Op: "push", Value: PushValue{Kind: "bigint", BigInt: new(big.Int).Set(v)}}) - t.nm = append(t.nm, n) + d := domUnknown + if v.Sign() >= 0 { + d = domNonNegative + } + t.pushTracked(n, d) } func (t *ECTracker) pushInt(n string, v int64) { t.e(StackOp{Op: "push", Value: bigIntPush(v)}) - t.nm = append(t.nm, n) + d := domUnknown + if v >= 0 { + d = domNonNegative + } + t.pushTracked(n, d) } func (t *ECTracker) dup(n string) { t.e(StackOp{Op: "dup"}) - t.nm = append(t.nm, n) + d := domUnknown + if len(t.dm) > 0 { + d = t.dm[len(t.dm)-1] + } + t.pushTracked(n, d) } func (t *ECTracker) drop() { t.e(StackOp{Op: "drop"}) - if len(t.nm) > 0 { - t.nm = t.nm[:len(t.nm)-1] - } + t.popTracked() } func (t *ECTracker) nip() { t.e(StackOp{Op: "nip"}) L := len(t.nm) if L >= 2 { - t.nm = append(t.nm[:L-2], t.nm[L-1]) + t.removeSlotAt(L - 2) } } func (t *ECTracker) over(n string) { t.e(StackOp{Op: "over"}) - t.nm = append(t.nm, n) + d := domUnknown + if len(t.dm) >= 2 { + d = t.dm[len(t.dm)-2] + } + t.pushTracked(n, d) } func (t *ECTracker) swap() { @@ -115,6 +312,7 @@ func (t *ECTracker) swap() { L := len(t.nm) if L >= 2 { t.nm[L-1], t.nm[L-2] = t.nm[L-2], t.nm[L-1] + t.dm[L-1], t.dm[L-2] = t.dm[L-2], t.dm[L-1] } } @@ -122,9 +320,9 @@ func (t *ECTracker) rot() { t.e(StackOp{Op: "rot"}) L := len(t.nm) if L >= 3 { - r := t.nm[L-3] - t.nm = append(t.nm[:L-3], t.nm[L-2:]...) - t.nm = append(t.nm, r) + r, rd := t.nm[L-3], t.dm[L-3] + t.removeSlotAt(L - 3) + t.pushTracked(r, rd) } } @@ -145,13 +343,13 @@ func (t *ECTracker) roll(d int) { return } t.e(StackOp{Op: "push", Value: bigIntPush(int64(d))}) - t.nm = append(t.nm, "") + t.pushTracked("", domNonNegative) t.e(StackOp{Op: "roll", Depth: d}) - t.nm = t.nm[:len(t.nm)-1] // pop the push placeholder + t.popTracked() // the depth literal idx := len(t.nm) - 1 - d - r := t.nm[idx] - t.nm = append(t.nm[:idx], t.nm[idx+1:]...) - t.nm = append(t.nm, r) + r, rd := t.nm[idx], t.dm[idx] + t.removeSlotAt(idx) + t.pushTracked(r, rd) } func (t *ECTracker) pick(d int, n string) { @@ -164,10 +362,15 @@ func (t *ECTracker) pick(d int, n string) { return } t.e(StackOp{Op: "push", Value: bigIntPush(int64(d))}) - t.nm = append(t.nm, "") + t.pushTracked("", domNonNegative) t.e(StackOp{Op: "pick", Depth: d}) - t.nm = t.nm[:len(t.nm)-1] // pop the push placeholder - t.nm = append(t.nm, n) + t.popTracked() // the depth literal + // Once the depth literal is gone the copied slot sits at depth d. + src := domUnknown + if idx := len(t.dm) - 1 - d; idx >= 0 { + src = t.dm[idx] + } + t.pushTracked(n, src) } func (t *ECTracker) toTop(name string) { @@ -178,16 +381,96 @@ func (t *ECTracker) copyToTop(name, n string) { t.pick(t.findDepth(name), n) } +// -- constant pool ----------------------------------------------------------- +// +// A pooled constant is an ordinary tracked slot; nothing about the stack model +// changes. pushConst just chooses, per call site and by emitted bytes, between +// copying that slot and re-pushing the literal. Nested trackers built from +// namesCopy() inherit the slot for free, so pooled constants work unchanged +// inside an OP_IF arm. + +func (t *ECTracker) hasSlot(slot string) bool { + for _, n := range t.nm { + if n == slot { + return true + } + } + return false +} + +// poolConstant parks value in slot for the lifetime of this emitter. No-op when +// pooling is off. +func (t *ECTracker) poolConstant(slot string, value *big.Int) { + if !t.pooling || t.hasSlot(slot) { + return + } + t.pushBigInt(slot, value) +} + +// releaseConstant removes a pooled slot. No-op when pooling is off or the slot +// is absent. +func (t *ECTracker) releaseConstant(slot string) { + if !t.pooling || !t.hasSlot(slot) { + return + } + t.toTop(slot) + t.drop() +} + +// constCost is the emitted byte cost a pushConst of this constant would incur +// right now. +// +// The comparison is exact — SizeOfPushBigInt is the same encoder the emit pass +// uses — so pooling can never make a call site bigger. A pick at depth d costs +// SizeOfPushBigInt(d) + 1; depths 0 and 1 are OP_DUP / OP_OVER, 1 byte each. +func (t *ECTracker) constCost(slot string, value *big.Int) int { + if t.pooling && t.hasSlot(slot) { + d := t.findDepth(slot) + pickCost := 1 + if d > 1 { + pickCost = SizeOfPushBigInt(big.NewInt(int64(d))) + 1 + } + if pickCost < SizeOfPushBigInt(value) { + return pickCost + } + } + return SizeOfPushBigInt(value) +} + +// pushConst materializes value on top as name, from the pooled slot when that +// is cheaper in emitted bytes than pushing the literal. +func (t *ECTracker) pushConst(slot string, value *big.Int, name string) { + if t.pooling && t.hasSlot(slot) { + d := t.findDepth(slot) + pickCost := 1 + if d > 1 { + pickCost = SizeOfPushBigInt(big.NewInt(int64(d))) + 1 + } + if pickCost < SizeOfPushBigInt(value) { + t.pick(d, name) + return + } + } + t.pushBigInt(name, value) +} + func (t *ECTracker) toAlt() { t.op("OP_TOALTSTACK") if len(t.nm) > 0 { - t.nm = t.nm[:len(t.nm)-1] + d := t.dm[len(t.dm)-1] + t.popTracked() + t.altDm = append(t.altDm, d) } } func (t *ECTracker) fromAlt(n string) { t.op("OP_FROMALTSTACK") - t.nm = append(t.nm, n) + d := domUnknown + if len(t.altDm) > 0 { + d = t.altDm[len(t.altDm)-1] + t.altDm = t.altDm[:len(t.altDm)-1] + } + t.pushTracked(n, d) } func (t *ECTracker) rename(n string) { @@ -200,13 +483,13 @@ func (t *ECTracker) rename(n string) { // produce="" means no output pushed. func (t *ECTracker) rawBlock(consume []string, produce string, fn func(emit func(StackOp))) { for i := len(consume) - 1; i >= 0; i-- { - if len(t.nm) > 0 { - t.nm = t.nm[:len(t.nm)-1] - } + t.popTracked() } fn(t.e) if produce != "" { - t.nm = append(t.nm, produce) + // Opaque opcodes: nothing is known about the result unless the caller + // proves it and records that with setDomain afterwards. + t.pushTracked(produce, domUnknown) } } @@ -214,17 +497,15 @@ func (t *ECTracker) rawBlock(consume []string, produce string, fn func(emit func // resultName="" means no result pushed. func (t *ECTracker) emitIf(condName string, thenFn func(func(StackOp)), elseFn func(func(StackOp)), resultName string) { t.toTop(condName) - // condition consumed - if len(t.nm) > 0 { - t.nm = t.nm[:len(t.nm)-1] - } + t.popTracked() // condition consumed var thenOps []StackOp var elseOps []StackOp thenFn(func(op StackOp) { thenOps = append(thenOps, op) }) elseFn(func(op StackOp) { elseOps = append(elseOps, op) }) t.e(StackOp{Op: "if", Then: thenOps, Else: elseOps}) if resultName != "" { - t.nm = append(t.nm, resultName) + // A join over two arms this tracker did not analyse: nothing is known. + t.pushTracked(resultName, domUnknown) } } @@ -234,11 +515,28 @@ func (t *ECTracker) emitIf(condName string, thenFn func(func(StackOp)), elseFn f // ecPushFieldP pushes the field prime p onto the stack as a script number. func ecPushFieldP(t *ECTracker, name string) { - t.pushBigInt(name, ecFieldP) + t.pushConst(ecPoolFieldP, ecFieldP, name) +} + +// ecFieldModShort emits `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. +func ecFieldModShort(t *ECTracker, aName, resultName string) { + t.toTop(aName) + ecPushFieldP(t, "_fmods_p") + t.rawBlock([]string{aName, "_fmods_p"}, resultName, func(e func(StackOp)) { + e(StackOp{Op: "opcode", Code: "OP_MOD"}) + }) + t.setDomain(resultName, domReduced) } // ecFieldMod reduces TOS mod p, ensuring non-negative result. func ecFieldMod(t *ECTracker, aName, resultName string) { + if t.sinking && isNonNegative(t.domainOf(aName)) { + ecFieldModShort(t, aName, resultName) + return + } t.toTop(aName) ecPushFieldP(t, "_fmod_p") // (a % p + p) % p @@ -252,40 +550,91 @@ func ecFieldMod(t *ECTracker, aName, resultName string) { e(StackOp{Op: "swap"}) // (a%p+p) p e(StackOp{Op: "opcode", Code: "OP_MOD"}) // ((a%p+p)%p) }) + t.setDomain(resultName, domReduced) } // ecFieldAdd computes (a + b) mod p. func ecFieldAdd(t *ECTracker, aName, bName, resultName string) { + // Read the operand facts BEFORE rawBlock consumes their slots. + sumNonNeg := isNonNegative(t.domainOf(aName)) && isNonNegative(t.domainOf(bName)) t.toTop(aName) t.toTop(bName) t.rawBlock([]string{aName, bName}, "_fadd_sum", func(e func(StackOp)) { e(StackOp{Op: "opcode", Code: "OP_ADD"}) }) + if sumNonNeg { + t.setDomain("_fadd_sum", domNonNegative) + } ecFieldMod(t, "_fadd_sum", resultName) } +// ecCheapSubPays reports whether the cheap subtraction shape pays 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. +func ecCheapSubPays(t *ECTracker) bool { + c := t.constCost(ecPoolFieldP, ecFieldP) + return 2*c+2 < c+8 +} + // ecFieldSub computes (a - b) mod p (non-negative). func ecFieldSub(t *ECTracker, aName, bName, resultName string) { 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. + cheap := t.sinking && + isNonNegative(t.domainOf(aName)) && + t.domainOf(bName) == domReduced && + ecCheapSubPays(t) + t.rawBlock([]string{aName, bName}, "_fsub_diff", func(e func(StackOp)) { e(StackOp{Op: "opcode", Code: "OP_SUB"}) }) + + if cheap { + ecPushFieldP(t, "_fsub_p") + t.rawBlock([]string{"_fsub_diff", "_fsub_p"}, "_fsub_shift", func(e func(StackOp)) { + e(StackOp{Op: "opcode", Code: "OP_ADD"}) + }) + t.setDomain("_fsub_shift", domNonNegative) + ecFieldModShort(t, "_fsub_shift", resultName) + return + } ecFieldMod(t, "_fsub_diff", resultName) } // ecFieldMul computes (a * b) mod p. func ecFieldMul(t *ECTracker, aName, bName, resultName string) { + ecFieldMulSigned(t, aName, bName, resultName, false) +} + +// ecFieldMulSigned is ecFieldMul with an explicit assertion about the product's +// sign, independent of the operands — ecFieldSqr uses it, since a*a >= 0 for any +// a whatsoever. +func ecFieldMulSigned(t *ECTracker, aName, bName, resultName string, productNonNegative bool) { + nonNeg := productNonNegative || + (isNonNegative(t.domainOf(aName)) && isNonNegative(t.domainOf(bName))) t.toTop(aName) t.toTop(bName) t.rawBlock([]string{aName, bName}, "_fmul_prod", func(e func(StackOp)) { e(StackOp{Op: "opcode", Code: "OP_MUL"}) }) + if nonNeg { + t.setDomain("_fmul_prod", domNonNegative) + } ecFieldMod(t, "_fmul_prod", resultName) } // ecFieldMulConst computes (a * c) mod p where c is a small constant. func ecFieldMulConst(t *ECTracker, aName string, c int64, resultName string) { + // Every call site passes a small positive c, so the product keeps a's sign. + nonNeg := c > 0 && isNonNegative(t.domainOf(aName)) t.toTop(aName) t.rawBlock([]string{aName}, "_fmc_prod", func(e func(StackOp)) { if c == 2 { @@ -296,13 +645,16 @@ func ecFieldMulConst(t *ECTracker, aName string, c int64, resultName string) { e(StackOp{Op: "opcode", Code: "OP_MUL"}) } }) + if nonNeg { + t.setDomain("_fmc_prod", domNonNegative) + } ecFieldMod(t, "_fmc_prod", resultName) } -// ecFieldSqr computes (a * a) mod p. +// ecFieldSqr computes (a * a) mod p. A square is non-negative whatever a's sign is. func ecFieldSqr(t *ECTracker, aName, resultName string) { t.copyToTop(aName, "_fsqr_copy") - ecFieldMul(t, aName, "_fsqr_copy", resultName) + ecFieldMulSigned(t, aName, "_fsqr_copy", resultName, true) } // ecFieldInv computes a^(p-2) mod p via square-and-multiply. @@ -357,8 +709,8 @@ func ecDecomposePoint(t *ECTracker, pointName, xName, yName string) { e(StackOp{Op: "opcode", Code: "OP_SPLIT"}) }) // Manually track the two new items - t.nm = append(t.nm, "_dp_xb") - t.nm = append(t.nm, "_dp_yb") + t.pushTracked("_dp_xb", domUnknown) + t.pushTracked("_dp_yb", domUnknown) // Convert y_bytes (on top) to num // Reverse from BE to LE, append 0x00 sign byte to ensure unsigned, then BIN2NUM @@ -368,6 +720,10 @@ func ecDecomposePoint(t *ECTracker, pointName, xName, yName string) { e(StackOp{Op: "opcode", Code: "OP_CAT"}) e(StackOp{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, domNonNegative) // Convert x_bytes to num t.toTop("_dp_xb") @@ -377,6 +733,7 @@ func ecDecomposePoint(t *ECTracker, pointName, xName, yName string) { e(StackOp{Op: "opcode", Code: "OP_CAT"}) e(StackOp{Op: "opcode", Code: "OP_BIN2NUM"}) }) + t.setDomain(xName, domNonNegative) // Stack: [yName, xName] -- swap to standard order [xName, yName] t.swap() @@ -671,10 +1028,10 @@ func ecJacobianToAffine(t *ECTracker, rxName, ryName string) { // Stack layout: [..., ax, ay, _k, jx, jy, jz] // After: [..., ax, ay, _k, jx', jy', jz'] func ecBuildJacobianAddAffineInline(e func(StackOp), t *ECTracker) { - // Create inner tracker with cloned stack state - initNm := make([]string, len(t.nm)) - copy(initNm, t.nm) - ecJacobianAddAffineBody(NewECTracker(initNm, e), false) + // Create inner tracker with cloned stack state AND lattice facts: the + // operands' proved domains are what decide which reduction shape the body + // emits, so dropping them here would silently fall back everywhere. + ecJacobianAddAffineBody(NewECTrackerOpts(t.namesCopy(), e, t.options(), t.domainsCopy()), false) } // ecJacobianAddAffineBody is the mixed-add itself, emitting through a tracker @@ -825,9 +1182,7 @@ func ecSelectCoord(t *ECTracker, addName, dblName, condName, resultName string) // // Stack layout: [..., ax, ay, _k, jx, jy, jz] — same in and out. func ecBuildJacobianAddOrDoubleInline(e func(StackOp), t *ECTracker) { - initNm := make([]string, len(t.nm)) - copy(initNm, t.nm) - it := NewECTracker(initNm, e) + it := NewECTrackerOpts(t.namesCopy(), e, t.options(), t.domainsCopy()) // Keep the pre-add accumulator: it is what must be DOUBLED in the // exceptional case, and the add below consumes jx/jy/jz. @@ -894,12 +1249,14 @@ func ecBuildJacobianAddOrDoubleInline(e func(StackOp), t *ECTracker) { // EmitEcAdd adds two points. // Stack in: [point_a, point_b] (b on top) // Stack out: [result_point] -func EmitEcAdd(emit func(StackOp)) { - t := NewECTracker([]string{"_pa", "_pb"}, emit) +func EmitEcAdd(emit func(StackOp), opts *EcCodegenOptions) { + t := NewECTrackerOpts([]string{"_pa", "_pb"}, emit, opts, nil) + t.poolConstant(ecPoolFieldP, ecFieldP) ecDecomposePoint(t, "_pa", "px", "py") ecDecomposePoint(t, "_pb", "qx", "qy") ecAffineAdd(t) ecComposePoint(t, "rx", "ry", "_result") + t.releaseConstant(ecPoolFieldP) } // ecEmitScalarReduce reduces a scalar to [0, n-1]: ((k mod n) + n) mod n. @@ -915,7 +1272,7 @@ func EmitEcAdd(emit func(StackOp)) { // (42 bytes) against a ~429 KB script, and makes k >= n, k < 0 and k = 0 all // well defined. func ecEmitScalarReduce(t *ECTracker, kName, resultName string, n *big.Int) { - t.pushBigInt("_n_red", n) + t.pushConst(ecPoolGroupN, n, "_n_red") t.rawBlock([]string{kName, "_n_red"}, resultName, func(e func(StackOp)) { e(StackOp{Op: "opcode", Code: "OP_2DUP"}) e(StackOp{Op: "opcode", Code: "OP_MOD"}) @@ -933,8 +1290,10 @@ func ecEmitScalarReduce(t *ECTracker, kName, resultName string, n *big.Int) { // Stack out: [result_point] // // Uses 256-iteration double-and-add with Jacobian coordinates. -func EmitEcMul(emit func(StackOp)) { - t := NewECTracker([]string{"_pt", "_k"}, emit) +func EmitEcMul(emit func(StackOp), opts *EcCodegenOptions) { + t := NewECTrackerOpts([]string{"_pt", "_k"}, emit, opts, nil) + t.poolConstant(ecPoolFieldP, ecFieldP) + t.poolConstant(ecPoolGroupN, ecCurveN) // Decompose to affine base point ecDecomposePoint(t, "_pt", "ax", "ay") @@ -944,18 +1303,17 @@ func EmitEcMul(emit func(StackOp)) { // // "k ∈ [1, n-1]" is a PRECONDITION the caller cannot enforce — the scalar is // usually an unlock argument — so reduce it first. See ecEmitScalarReduce. - curveN, _ := new(big.Int).SetString("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141", 16) t.toTop("_k") - ecEmitScalarReduce(t, "_k", "_kr", curveN) - t.pushBigInt("_n", curveN) + ecEmitScalarReduce(t, "_k", "_kr", ecCurveN) + t.pushConst(ecPoolGroupN, ecCurveN, "_n") t.rawBlock([]string{"_kr", "_n"}, "_kn", func(e func(StackOp)) { e(StackOp{Op: "opcode", Code: "OP_ADD"}) }) - t.pushBigInt("_n2", curveN) + t.pushConst(ecPoolGroupN, ecCurveN, "_n2") t.rawBlock([]string{"_kn", "_n2"}, "_kn2", func(e func(StackOp)) { e(StackOp{Op: "opcode", Code: "OP_ADD"}) }) - t.pushBigInt("_n3", curveN) + t.pushConst(ecPoolGroupN, ecCurveN, "_n3") t.rawBlock([]string{"_kn2", "_n3"}, "_kn3", func(e func(StackOp)) { e(StackOp{Op: "opcode", Code: "OP_ADD"}) }) @@ -995,7 +1353,7 @@ func EmitEcMul(emit func(StackOp)) { // 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 = t.nm[:len(t.nm)-1] // _bit consumed by IF + t.popTracked() // _bit consumed by IF var addOps []StackOp addEmit := func(op StackOp) { addOps = append(addOps, op) } // Only the final step can be handed two equal operands — see @@ -1021,37 +1379,316 @@ func EmitEcMul(emit func(StackOp)) { // Compose result ecComposePoint(t, "_rx", "_ry", "_result") + t.releaseConstant(ecPoolGroupN) + t.releaseConstant(ecPoolFieldP) +} + +// =========================================================================== +// Fixed-base comb (secp256k1) +// =========================================================================== + +// ecEmitCombMulGen emits k*G by a Lim-Lee fixed-base comb instead of the +// 257-round binary ladder, returning false when no geometry exists for w. +// +// The ladder doubles and conditionally adds once per SCALAR BIT. A comb splits +// the scalar into w blocks of d bits and reads one bit from each block per +// round, so it performs one doubling and one conditional add per COLUMN: the +// round count falls from w*d to d at the price of a 2^w - 1 entry table. G is a +// compile-time constant here, so the table costs nothing to build — it is +// 2*(2^w - 1) literal pushes, resident for the whole emitter, read by every +// round with a 2-3 byte OP_PICK. +// +// This is the secp256k1 twin of cEmitCombMulGen in p256_p384.go. The curve +// arithmetic is NOT shared: secp256k1 has a = 0, so ecJacobianDouble computes +// D = 3X^2 where the NIST version computes 3(X-Z^2)(X+Z^2). Only comb.go — the +// compile-time table and the interval checker — is common, and it takes a from +// the curve record rather than assuming it. +// +// SOUNDNESS. The cheap incomplete mixed add cannot represent a pre-add +// accumulator equal to the addend, its negation, or the point at infinity. +// ecBuildJacobianAddOrDoubleInline's comment justifies using it everywhere but +// the ladder's LAST step by an interval argument over c_i mod n, and insists +// that argument be re-derived by anything changing the offset or the iteration +// count. A comb changes both, so it is re-derived: CombSafeRounds evaluates the +// same argument as executable interval arithmetic over the comb's own geometry, +// and any round it cannot prove gets the complete add-or-double form instead. +// Nothing is assumed safe. +// +// The other half of that argument is that the accumulator never starts at +// infinity, which needs the first digit non-zero. CombGeometry searches for the +// scalar offset that guarantees it rather than reusing the ladder's hardcoded +// +3n — which happens to be right for secp256k1 at w=3 and is wrong for P-384. +// +// Stack in: [_k]. Stack out: [_result]. +func ecEmitCombMulGen(emit func(StackOp), w int, opts *EcCodegenOptions) bool { + curve := Secp256k1CombCurve + params := CombGeometry(w, curve) + if params == nil { + return false + } + d := params.D + table := CombTable(w, d, curve) + safe := CombSafeRounds(params, curve) + entries := (1 << w) - 1 + + t := NewECTrackerOpts([]string{"_k"}, emit, opts, nil) + t.poolConstant(ecPoolFieldP, ecFieldP) + t.poolConstant(ecPoolGroupN, ecCurveN) + + // k' = (k mod n) + m*n. The reduce is what confines k to [0, n-1] and so + // what makes the interval argument apply at all; see ecEmitScalarReduce. + t.toTop("_k") + ecEmitScalarReduce(t, "_k", "_kr", ecCurveN) + t.rename("_k") + for i := int64(0); i < params.OffsetMultiple.Int64(); i++ { + off := fmt.Sprintf("_off%d", i) + t.pushConst(ecPoolGroupN, ecCurveN, off) + t.rawBlock([]string{"_k", off}, "_k", func(e func(StackOp)) { + e(StackOp{Op: "opcode", Code: "OP_ADD"}) + }) + } + t.setDomain("_k", domNonNegative) + + // Table, resident for the whole comb: picking an entry costs 2-3 bytes + // against a 34-byte literal push, and every round reads all of them. + for j := 1; j <= entries; j++ { + t.pushBigInt(fmt.Sprintf("_Tx%d", j), table[j].X) + t.pushBigInt(fmt.Sprintf("_Ty%d", j), table[j].Y) + t.setDomain(fmt.Sprintf("_Tx%d", j), domReduced) + t.setDomain(fmt.Sprintf("_Ty%d", j), domReduced) + } + + // emitSelect materializes round i's digit and the selected table entry as + // ax/ay/_flag. + // + // Exactly one equality holds, so sum(eq_j * T_j) is that entry's coordinate + // and every term is non-negative and below p — no reduction is needed, and + // the result is domReduced by construction. When the digit is zero every + // term vanishes and _flag is 0, so no add runs. + emitSelect := func(i int) { + for b := 0; b < w; b++ { + shift := i + b*d + kc := fmt.Sprintf("_kc%d", b) + sh := fmt.Sprintf("_sh%d", b) + t.copyToTop("_k", kc) + if shift == 0 { + t.rename(sh) + } else if shift == 1 { + t.rawBlock([]string{kc}, sh, func(e func(StackOp)) { + e(StackOp{Op: "opcode", Code: "OP_2DIV"}) + }) + } else { + sd := fmt.Sprintf("_sd%d", b) + t.pushInt(sd, int64(shift)) + t.rawBlock([]string{kc, sd}, sh, func(e func(StackOp)) { + e(StackOp{Op: "opcode", Code: "OP_RSHIFTNUM"}) + }) + } + two := fmt.Sprintf("_two%d", b) + bit := fmt.Sprintf("_b%d", b) + t.pushInt(two, 2) + t.rawBlock([]string{sh, two}, bit, func(e func(StackOp)) { + e(StackOp{Op: "opcode", Code: "OP_MOD"}) + }) + t.setDomain(bit, domReduced) + } + + t.toTop("_b0") + t.rename("_idx") + for b := 1; b < w; b++ { + bit := fmt.Sprintf("_b%d", b) + wt := fmt.Sprintf("_wt%d", b) + bw := fmt.Sprintf("_bw%d", b) + t.toTop(bit) + t.pushInt(wt, int64(1<= 1; j-- { + t.toTop(fmt.Sprintf("_eq%d", j)) + t.drop() + } + + t.toTop("_idx") + t.rawBlock([]string{"_idx"}, "_flag", func(e func(StackOp)) { + e(StackOp{Op: "opcode", Code: "OP_0NOTEQUAL"}) + }) + } + + // Round d-1 initialises the accumulator. The first digit is non-zero by + // construction (CombGeometry), so this is a real point and never infinity. + emitSelect(d - 1) + t.toTop("_flag") + t.drop() + t.toTop("ax") + t.rename("jx") + t.toTop("ay") + t.rename("jy") + t.pushInt("jz", 1) + t.setDomain("jz", domReduced) + + for i := d - 2; i >= 0; i-- { + ecJacobianDouble(t) + emitSelect(i) + + // ecJacobianAddAffineBody documents its layout as [..., ax, ay, jx, jy, + // jz] and replaces the accumulator IN PLACE at the top. The selection + // leaves ax/ay above jz, so restore the contract before the branch — + // otherwise the add arm would reorder the stack and the empty else arm + // would not, leaving the two arms with different layouts at OP_ENDIF. + t.toTop("_flag") + t.toAlt() + t.toTop("jx") + t.toTop("jy") + t.toTop("jz") + t.fromAlt("_flag") + + t.popTracked() // consumed by OP_IF + var addOps []StackOp + addEmit := func(op StackOp) { addOps = append(addOps, op) } + if safe[i] { + ecBuildJacobianAddAffineInline(addEmit, t) + } else { + ecBuildJacobianAddOrDoubleInline(addEmit, t) + } + emit(StackOp{Op: "if", Then: addOps, Else: []StackOp{}}) + + // The addend was selected fresh for this round; the add only copied it. + t.toTop("ay") + t.drop() + t.toTop("ax") + t.drop() + } + + ecJacobianToAffine(t, "_rx", "_ry") + + for j := entries; j >= 1; j-- { + t.toTop(fmt.Sprintf("_Ty%d", j)) + t.drop() + t.toTop(fmt.Sprintf("_Tx%d", j)) + t.drop() + } + t.toTop("_k") + t.drop() + + ecComposePoint(t, "_rx", "_ry", "_result") + t.releaseConstant(ecPoolGroupN) + t.releaseConstant(ecPoolFieldP) + return true +} + +// ecEmitCombBest emits the cheapest comb over the candidate window widths. +// +// Each candidate is rendered in full and scored with the same byte-cost model +// the emitter is measured by, and the smallest wins — the window width is not +// hardcoded. w=1 is the binary ladder and is excluded; beyond w=4 the 2^w +// selection logic outgrows the saving. +// +// Returns nil when no candidate could be built, so the caller falls back to the +// ladder rather than emitting nothing. +func ecEmitCombBest(opts *EcCodegenOptions) []StackOp { + var best []StackOp + for _, w := range []int{2, 3, 4} { + var ops []StackOp + if !ecEmitCombMulGen(func(op StackOp) { ops = append(ops, op) }, w, opts) { + continue + } + if best == nil || EstimateScriptBytes(ops) < EstimateScriptBytes(best) { + best = ops + } + } + return best } // EmitEcMulGen performs scalar multiplication G * k. // Stack in: [scalar] // Stack out: [result_point] -func EmitEcMulGen(emit func(StackOp)) { +func EmitEcMulGen(emit func(StackOp), opts *EcCodegenOptions) { + // G is a compile-time constant, so this is the one secp256k1 call site where + // a fixed-base comb applies. EmitEcMul cannot use it: its base arrives at + // run time. + if opts != nil && opts.FixedBaseComb { + if ops := ecEmitCombBest(opts); ops != nil { + for _, op := range ops { + emit(op) + } + return + } + } + // Push generator point as 64-byte blob, then delegate to ecMul gPoint := make([]byte, 64) copy(gPoint[0:32], bigintToBytes32(ecGenX)) copy(gPoint[32:64], bigintToBytes32(ecGenY)) emit(StackOp{Op: "push", Value: PushValue{Kind: "bytes", Bytes: gPoint}}) emit(StackOp{Op: "swap"}) // [point, scalar] - EmitEcMul(emit) + EmitEcMul(emit, opts) } // EmitEcNegate negates a point (x, p - y). // Stack in: [point] // Stack out: [negated_point] -func EmitEcNegate(emit func(StackOp)) { - t := NewECTracker([]string{"_pt"}, emit) +func EmitEcNegate(emit func(StackOp), opts *EcCodegenOptions) { + t := NewECTrackerOpts([]string{"_pt"}, emit, opts, nil) + t.poolConstant(ecPoolFieldP, ecFieldP) ecDecomposePoint(t, "_pt", "_nx", "_ny") ecPushFieldP(t, "_fp") ecFieldSub(t, "_fp", "_ny", "_neg_y") ecComposePoint(t, "_nx", "_neg_y", "_result") + t.releaseConstant(ecPoolFieldP) } // EmitEcOnCurve checks if point is on secp256k1 (y^2 = x^3 + 7 mod p). // Stack in: [point] // Stack out: [boolean] -func EmitEcOnCurve(emit func(StackOp)) { - t := NewECTracker([]string{"_pt"}, emit) +func EmitEcOnCurve(emit func(StackOp), opts *EcCodegenOptions) { + t := NewECTrackerOpts([]string{"_pt"}, emit, opts, nil) + t.poolConstant(ecPoolFieldP, ecFieldP) ecDecomposePoint(t, "_pt", "_x", "_y") // GAP-301: coordinate canonicity. ecDecomposePoint BIN2NUMs each coordinate @@ -1099,6 +1736,7 @@ func EmitEcOnCurve(emit func(StackOp)) { t.rawBlock([]string{"_canon", "_curve_eq"}, "_result", func(e func(StackOp)) { e(StackOp{Op: "opcode", Code: "OP_BOOLAND"}) }) + t.releaseConstant(ecPoolFieldP) } // EmitEcModReduce computes ((value % mod) + mod) % mod. diff --git a/compilers/go/codegen/ec_flag_parity_test.go b/compilers/go/codegen/ec_flag_parity_test.go new file mode 100644 index 000000000..133001ecd --- /dev/null +++ b/compilers/go/codegen/ec_flag_parity_test.go @@ -0,0 +1,164 @@ +package codegen + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" +) + +// Cross-tier parity for the EXPERIMENTAL EC size flags. +// +// The flags default off, so the ordinary conformance suite — which compiles +// with defaults — cannot see them at all. Seven tiers could each ship a +// DIFFERENT --ec-constant-pool and the suite would stay green. +// +// That matters because the flags are not cosmetic: they change which reduction +// form is emitted and which addition formula each ladder round uses. A tier +// that ports the constant pool but not the sign lattice's `Reduced` +// precondition produces a script that is smaller, passes its own tests, and is +// wrong on ecAdd((0,1), (2^256-1,1)). Byte-identical output against a single +// reference is the only cheap check that catches that. +// +// conformance/ec-flag-parity/expected.json is derived from the TypeScript +// reference compiler and re-derived by its own vitest, so it cannot go stale. + +type parityEntry struct { + Bytes int `json:"bytes"` + Sha256 string `json:"sha256"` +} + +type parityFixture struct { + Variants map[string]struct { + ConstantPool bool `json:"constantPool"` + ReductionSinking bool `json:"reductionSinking"` + FixedBaseComb bool `json:"fixedBaseComb"` + } `json:"variants"` + Emitters map[string]map[string]parityEntry `json:"emitters"` +} + +func loadParityFixture(t *testing.T) *parityFixture { + t.Helper() + // codegen -> compilers/go -> compilers -> repo root + path := filepath.Join("..", "..", "..", "conformance", "ec-flag-parity", "expected.json") + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + var f parityFixture + if err := json.Unmarshal(raw, &f); err != nil { + t.Fatalf("parse %s: %v", path, err) + } + return &f +} + +// ecEmittersUnderTest maps the fixture's emitter names to this tier's +// functions. Emitters whose output the flags cannot reach (EcModReduce, +// EcPointX, ...) are deliberately included: a tier that accidentally made them +// flag-sensitive would be diverging just as badly as one that ignored a flag. +func ecEmittersUnderTest() map[string]func(func(StackOp), *EcCodegenOptions) { + ignore := func(f func(func(StackOp))) func(func(StackOp), *EcCodegenOptions) { + return func(e func(StackOp), _ *EcCodegenOptions) { f(e) } + } + return map[string]func(func(StackOp), *EcCodegenOptions){ + "EcAdd": EmitEcAdd, + "EcMul": EmitEcMul, + "EcMulGen": EmitEcMulGen, + "EcNegate": EmitEcNegate, + "EcOnCurve": EmitEcOnCurve, + "EcModReduce": ignore(EmitEcModReduce), + "EcEncodeCompressed": ignore(EmitEcEncodeCompressed), + "EcMakePoint": ignore(EmitEcMakePoint), + "EcPointX": ignore(EmitEcPointX), + "EcPointY": ignore(EmitEcPointY), + + "P256Add": EmitP256Add, + "P256Mul": EmitP256Mul, + "P256MulGen": EmitP256MulGen, + "P256Negate": EmitP256Negate, + "P256OnCurve": EmitP256OnCurve, + "P256EncodeCompressed": ignore(EmitP256EncodeCompressed), + "VerifyECDSA_P256": EmitVerifyECDSA_P256, + + "P384Add": EmitP384Add, + "P384Mul": EmitP384Mul, + "P384MulGen": EmitP384MulGen, + "P384Negate": EmitP384Negate, + "P384OnCurve": EmitP384OnCurve, + "P384EncodeCompressed": ignore(EmitP384EncodeCompressed), + "VerifyECDSA_P384": EmitVerifyECDSA_P384, + } +} + +func emitAndHash(t *testing.T, emit func(func(StackOp), *EcCodegenOptions), opts *EcCodegenOptions) (int, string) { + t.Helper() + var ops []StackOp + emit(func(op StackOp) { ops = append(ops, op) }, opts) + res, err := EmitMethod(&StackMethod{Name: "t", Ops: ops}) + if err != nil { + t.Fatalf("emit failed: %v", err) + } + raw, err := hex.DecodeString(res.ScriptHex) + if err != nil { + t.Fatalf("bad hex: %v", err) + } + sum := sha256.Sum256(raw) + return len(raw), hex.EncodeToString(sum[:]) +} + +func TestEcFlagParityAgainstTypeScriptReference(t *testing.T) { + f := loadParityFixture(t) + emitters := ecEmittersUnderTest() + + for name, emit := range emitters { + want, ok := f.Emitters[name] + if !ok { + t.Fatalf("%s: no entry in the parity fixture", name) + } + for variant, spec := range f.Variants { + expect, ok := want[variant] + if !ok { + t.Fatalf("%s/%s: no entry in the parity fixture", name, variant) + } + t.Run(fmt.Sprintf("%s/%s", name, variant), func(t *testing.T) { + opts := &EcCodegenOptions{ + ConstantPool: spec.ConstantPool, + ReductionSinking: spec.ReductionSinking, + FixedBaseComb: spec.FixedBaseComb, + } + gotBytes, gotHash := emitAndHash(t, emit, opts) + if gotBytes != expect.Bytes || gotHash != expect.Sha256 { + t.Fatalf("%s under %s: Go emits %d bytes (%s), TS reference emits %d bytes (%s)", + name, variant, gotBytes, gotHash[:16], expect.Bytes, expect.Sha256[:16]) + } + }) + } + } +} + +// A nil options pointer must be byte-identical to the shipping output. This is +// what keeps the existing goldens, the size baseline and every cross-tier hex +// comparison from moving while the flags are experimental. +func TestEcFlagsDefaultOffIsByteIdentical(t *testing.T) { + f := loadParityFixture(t) + for name, emit := range ecEmittersUnderTest() { + t.Run(name, func(t *testing.T) { + var nilOps, offOps []StackOp + emit(func(op StackOp) { nilOps = append(nilOps, op) }, nil) + emit(func(op StackOp) { offOps = append(offOps, op) }, &EcCodegenOptions{}) + nilRes, _ := EmitMethod(&StackMethod{Name: "t", Ops: nilOps}) + offRes, _ := EmitMethod(&StackMethod{Name: "t", Ops: offOps}) + if nilRes.ScriptHex != offRes.ScriptHex { + t.Fatalf("%s: nil options and all-false options disagree", name) + } + raw, _ := hex.DecodeString(nilRes.ScriptHex) + sum := sha256.Sum256(raw) + if hex.EncodeToString(sum[:]) != f.Emitters[name]["off"].Sha256 { + t.Fatalf("%s: default output moved", name) + } + }) + } +} diff --git a/compilers/go/codegen/p256_p384.go b/compilers/go/codegen/p256_p384.go index adfcacd31..e0b530fbf 100644 --- a/compilers/go/codegen/p256_p384.go +++ b/compilers/go/codegen/p256_p384.go @@ -14,6 +14,7 @@ package codegen import ( + "fmt" "math/big" ) @@ -159,10 +160,34 @@ func bigIntBitLen(n *big.Int) int { // =========================================================================== func cPushFieldP(t *ECTracker, name string, c *nistCurveParams) { - t.pushBigInt(name, c.fieldP) + t.pushConst(ecPoolFieldP, c.fieldP, name) +} + +// cFieldModShort emits `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. +func cFieldModShort(t *ECTracker, aName, resultName string, c *nistCurveParams) { + t.toTop(aName) + cPushFieldP(t, "_fmods_p", c) + t.rawBlock([]string{aName, "_fmods_p"}, resultName, func(e func(StackOp)) { + e(StackOp{Op: "opcode", Code: "OP_MOD"}) + }) + t.setDomain(resultName, domReduced) +} + +// cCheapSubPays reports whether the cheap `a - b + p` subtraction pays. It +// references the prime TWICE where the shipping shape references it once and +// pays six more opcodes, so it only wins when the prime is pooled. +func cCheapSubPays(t *ECTracker, c *nistCurveParams) bool { + cost := t.constCost(ecPoolFieldP, c.fieldP) + return 2*cost+2 < cost+8 } func cFieldMod(t *ECTracker, aName, resultName string, c *nistCurveParams) { + if t.sinking && isNonNegative(t.domainOf(aName)) { + cFieldModShort(t, aName, resultName, c) + return + } t.toTop(aName) cPushFieldP(t, "_fmod_p", c) t.rawBlock([]string{aName, "_fmod_p"}, resultName, func(e func(StackOp)) { @@ -175,36 +200,73 @@ func cFieldMod(t *ECTracker, aName, resultName string, c *nistCurveParams) { e(StackOp{Op: "swap"}) e(StackOp{Op: "opcode", Code: "OP_MOD"}) }) + t.setDomain(resultName, domReduced) } func cFieldAdd(t *ECTracker, aName, bName, resultName string, c *nistCurveParams) { + // Read the operand facts before rawBlock consumes their slots. + sumNonNeg := isNonNegative(t.domainOf(aName)) && isNonNegative(t.domainOf(bName)) t.toTop(aName) t.toTop(bName) t.rawBlock([]string{aName, bName}, "_fadd_sum", func(e func(StackOp)) { e(StackOp{Op: "opcode", Code: "OP_ADD"}) }) + if sumNonNeg { + t.setDomain("_fadd_sum", domNonNegative) + } cFieldMod(t, "_fadd_sum", resultName, c) } func cFieldSub(t *ECTracker, aName, bName, resultName string, c *nistCurveParams) { 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. + cheap := t.sinking && + isNonNegative(t.domainOf(aName)) && + t.domainOf(bName) == domReduced && + cCheapSubPays(t, c) + t.rawBlock([]string{aName, bName}, "_fsub_diff", func(e func(StackOp)) { e(StackOp{Op: "opcode", Code: "OP_SUB"}) }) + + if cheap { + cPushFieldP(t, "_fsub_p", c) + t.rawBlock([]string{"_fsub_diff", "_fsub_p"}, "_fsub_shift", func(e func(StackOp)) { + e(StackOp{Op: "opcode", Code: "OP_ADD"}) + }) + t.setDomain("_fsub_shift", domNonNegative) + cFieldModShort(t, "_fsub_shift", resultName, c) + return + } cFieldMod(t, "_fsub_diff", resultName, c) } func cFieldMul(t *ECTracker, aName, bName, resultName string, c *nistCurveParams) { + cFieldMulSigned(t, aName, bName, resultName, c, false) +} + +// cFieldMulSigned lets cFieldSqr assert the product's sign independently of the +// operand: a*a >= 0 for any a whatsoever. +func cFieldMulSigned(t *ECTracker, aName, bName, resultName string, c *nistCurveParams, productNonNegative bool) { + nonNeg := productNonNegative || + (isNonNegative(t.domainOf(aName)) && isNonNegative(t.domainOf(bName))) t.toTop(aName) t.toTop(bName) t.rawBlock([]string{aName, bName}, "_fmul_prod", func(e func(StackOp)) { e(StackOp{Op: "opcode", Code: "OP_MUL"}) }) + if nonNeg { + t.setDomain("_fmul_prod", domNonNegative) + } cFieldMod(t, "_fmul_prod", resultName, c) } func cFieldMulConst(t *ECTracker, aName string, cv int64, resultName string, c *nistCurveParams) { + // Every call site passes a small positive cv, so the product keeps a's sign. + nonNeg := cv > 0 && isNonNegative(t.domainOf(aName)) t.toTop(aName) t.rawBlock([]string{aName}, "_fmc_prod", func(e func(StackOp)) { if cv == 2 { @@ -214,12 +276,15 @@ func cFieldMulConst(t *ECTracker, aName string, cv int64, resultName string, c * e(StackOp{Op: "opcode", Code: "OP_MUL"}) } }) + if nonNeg { + t.setDomain("_fmc_prod", domNonNegative) + } cFieldMod(t, "_fmc_prod", resultName, c) } func cFieldSqr(t *ECTracker, aName, resultName string, c *nistCurveParams) { t.copyToTop(aName, "_fsqr_copy") - cFieldMul(t, aName, "_fsqr_copy", resultName, c) + cFieldMulSigned(t, aName, "_fsqr_copy", resultName, c, true) } // cFieldInv computes a^(p-2) mod p via generic square-and-multiply. @@ -251,7 +316,7 @@ func cFieldInv(t *ECTracker, aName, resultName string, c *nistCurveParams) { // =========================================================================== func cPushGroupN(t *ECTracker, name string, g *nistGroupParams) { - t.pushBigInt(name, g.n) + t.pushConst(ecPoolGroupN, g.n, name) } func cGroupMod(t *ECTracker, aName, resultName string, g *nistGroupParams) { @@ -339,8 +404,8 @@ func cDecomposePoint(t *ECTracker, pointName, xName, yName string, c *nistCurveP e(StackOp{Op: "push", Value: bigIntPush(int64(c.coordBytes))}) e(StackOp{Op: "opcode", Code: "OP_SPLIT"}) }) - t.nm = append(t.nm, "_dp_xb") - t.nm = append(t.nm, "_dp_yb") + t.pushTracked("_dp_xb", domUnknown) + t.pushTracked("_dp_yb", domUnknown) // Convert y_bytes (on top) to num t.rawBlock([]string{"_dp_yb"}, yName, func(e func(StackOp)) { @@ -349,6 +414,10 @@ func cDecomposePoint(t *ECTracker, pointName, xName, yName string, c *nistCurveP e(StackOp{Op: "opcode", Code: "OP_CAT"}) e(StackOp{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^(8*coordBytes) - 1 and therefore + // >= p. That gap is exactly what the subtraction precondition turns on. + t.setDomain(yName, domNonNegative) // Convert x_bytes to num t.toTop("_dp_xb") @@ -358,6 +427,7 @@ func cDecomposePoint(t *ECTracker, pointName, xName, yName string, c *nistCurveP e(StackOp{Op: "opcode", Code: "OP_CAT"}) e(StackOp{Op: "opcode", Code: "OP_BIN2NUM"}) }) + t.setDomain(xName, domNonNegative) // Swap to standard order [xName, yName] t.swap() @@ -659,7 +729,7 @@ func cJacobianToAffine(t *ECTracker, rxName, ryName string, c *nistCurveParams) func cBuildJacobianAddAffineInline(e func(StackOp), t *ECTracker, c *nistCurveParams) { initNm := make([]string, len(t.nm)) copy(initNm, t.nm) - cJacobianAddAffineBody(NewECTracker(initNm, e), false, c) + cJacobianAddAffineBody(NewECTrackerOpts(t.namesCopy(), e, t.options(), t.domainsCopy()), false, c) } // cJacobianAddAffineBody is the mixed-add itself, emitting through a tracker @@ -805,7 +875,7 @@ func cSelectCoord(t *ECTracker, addName, dblName, condName, resultName string, c func cBuildJacobianAddOrDoubleInline(e func(StackOp), t *ECTracker, c *nistCurveParams) { initNm := make([]string, len(t.nm)) copy(initNm, t.nm) - it := NewECTracker(initNm, e) + it := NewECTrackerOpts(t.namesCopy(), e, t.options(), t.domainsCopy()) // Keep the pre-add accumulator: it is what must be DOUBLED in the // exceptional case, and the add below consumes jx/jy/jz. @@ -869,8 +939,10 @@ func cBuildJacobianAddOrDoubleInline(e func(StackOp), t *ECTracker, c *nistCurve // Scalar multiplication (generic for both P-256 and P-384) // =========================================================================== -func cEmitMul(emit func(StackOp), c *nistCurveParams, g *nistGroupParams) { - t := NewECTracker([]string{"_pt", "_k"}, emit) +func cEmitMul(emit func(StackOp), c *nistCurveParams, g *nistGroupParams, opts *EcCodegenOptions) { + t := NewECTrackerOpts([]string{"_pt", "_k"}, emit, opts, nil) + t.poolConstant(ecPoolFieldP, c.fieldP) + t.poolConstant(ecPoolGroupN, g.n) cDecomposePoint(t, "_pt", "ax", "ay", c) // k' = k + 3n @@ -879,6 +951,10 @@ func cEmitMul(emit func(StackOp), c *nistCurveParams, g *nistGroupParams) { // scalar is usually an unlock argument — so reduce it first. t.toTop("_k") cEmitScalarReduce(t, "_k", "_kr", g) + // Literal, NOT pooled: this matches the TypeScript reference byte for byte. + // (Pooling these three would save 96 B per P-256 ladder and 144 B per P-384 + // one — a real missed opportunity, but one that has to be taken in the + // reference first, or the tiers diverge.) t.pushBigInt("_n", g.n) t.rawBlock([]string{"_kr", "_n"}, "_kn", func(e func(StackOp)) { e(StackOp{Op: "opcode", Code: "OP_ADD"}) @@ -930,7 +1006,7 @@ func cEmitMul(emit func(StackOp), c *nistCurveParams, g *nistGroupParams) { // Conditional add t.toTop("_bit") - t.nm = t.nm[:len(t.nm)-1] // _bit consumed by IF + t.popTracked() // _bit consumed by IF var addOps []StackOp addEmit := func(op StackOp) { addOps = append(addOps, op) } // Only the final step can be handed two equal operands — see @@ -954,6 +1030,271 @@ func cEmitMul(emit func(StackOp), c *nistCurveParams, g *nistGroupParams) { t.drop() cComposePoint(t, "_rx", "_ry", "_result", c) + t.releaseConstant(ecPoolGroupN) + t.releaseConstant(ecPoolFieldP) +} + +// =========================================================================== +// Fixed-base comb (the base is a compile-time constant) +// =========================================================================== + +// cEmitCombMulGen emits k*G by a Lim-Lee comb, for a base known at compile time. +// +// The binary ladder runs one doubling and one conditional add per scalar BIT. A +// comb splits the scalar into w blocks of d bits and runs one doubling and one +// conditional add per COLUMN, so the round count falls from w*d to d at the +// price of a 2^w - 1 entry table — which costs nothing to build here, because G +// is a constant. Measured optimum is w=3: the selection logic grows as 2^w and +// overtakes the saving by w=5. +// +// P-256 u1*G: 90,610 B binary -> ~44,600 B comb (w=3, 86 rounds) +// +// SOUNDNESS. The cheap incomplete mixed add cannot represent a pre-add +// accumulator equal to the addend, its negation, or the point at infinity. +// cBuildJacobianAddOrDoubleInline's comment justifies using it everywhere but +// the last step of the BINARY ladder by an interval argument over c_i mod n, +// and insists that argument be re-derived by anything changing the offset or +// the iteration count. A comb changes both, so it is re-derived — as executable +// interval arithmetic in CombSafeRounds, evaluated here. Rounds it cannot prove +// get the complete add-or-double form instead; nothing is assumed. For P-256 at +// w=3 it proves 81 of 86 rounds, so the fallback costs ~1.2 kB. +// +// The other half of that argument is that the accumulator never starts at +// infinity, which needs the first digit to be non-zero. CombGeometry searches +// for the scalar offset that guarantees it rather than reusing the ladder's +// hardcoded +3n — which happens to be right for P-256 at w=3 and WRONG for +// P-384. +// +// Stack in: [_k]. Stack out: [_result]. +func cEmitCombMulGen( + emit func(StackOp), + c *nistCurveParams, + g *nistGroupParams, + curve *CombCurve, + w int, + opts *EcCodegenOptions, +) bool { + params := CombGeometry(w, curve) + if params == nil { + return false + } + d := params.D + table := CombTable(w, d, curve) + safe := CombSafeRounds(params, curve) + entries := (1 << w) - 1 + + t := NewECTrackerOpts([]string{"_k"}, emit, opts, nil) + t.poolConstant(ecPoolFieldP, c.fieldP) + t.poolConstant(ecPoolGroupN, g.n) + + // k' = (k mod n) + m*n. The reduce is what confines k to [0, n-1] and so + // what makes the interval argument apply at all; see cEmitScalarReduce. + t.toTop("_k") + cEmitScalarReduce(t, "_k", "_kr", g) + t.rename("_k") + for i := int64(0); i < params.OffsetMultiple.Int64(); i++ { + off := fmt.Sprintf("_off%d", i) + t.pushConst(ecPoolGroupN, g.n, off) + t.rawBlock([]string{"_k", off}, "_k", func(e func(StackOp)) { + e(StackOp{Op: "opcode", Code: "OP_ADD"}) + }) + } + t.setDomain("_k", domNonNegative) + + // Table, resident for the whole comb: picking an entry costs 2-3 bytes + // against a 34-byte literal push, and every round reads all of them. + for j := 1; j <= entries; j++ { + t.pushBigInt(fmt.Sprintf("_Tx%d", j), table[j].X) + t.pushBigInt(fmt.Sprintf("_Ty%d", j), table[j].Y) + t.setDomain(fmt.Sprintf("_Tx%d", j), domReduced) + t.setDomain(fmt.Sprintf("_Ty%d", j), domReduced) + } + + // emitSelect materializes round i's digit and the selected table entry as + // ax/ay/_flag. + // + // Exactly one equality holds, so sum(eq_j * T_j) is that entry's coordinate + // and every term is non-negative and below p — no reduction is needed, and + // the result is domReduced by construction. When the digit is zero every + // term vanishes and _flag is 0, so no add runs. + emitSelect := func(i int) { + for b := 0; b < w; b++ { + shift := i + b*d + kc := fmt.Sprintf("_kc%d", b) + sh := fmt.Sprintf("_sh%d", b) + t.copyToTop("_k", kc) + if shift == 0 { + t.rename(sh) + } else if shift == 1 { + t.rawBlock([]string{kc}, sh, func(e func(StackOp)) { + e(StackOp{Op: "opcode", Code: "OP_2DIV"}) + }) + } else { + sd := fmt.Sprintf("_sd%d", b) + t.pushInt(sd, int64(shift)) + t.rawBlock([]string{kc, sd}, sh, func(e func(StackOp)) { + e(StackOp{Op: "opcode", Code: "OP_RSHIFTNUM"}) + }) + } + two := fmt.Sprintf("_two%d", b) + bit := fmt.Sprintf("_b%d", b) + t.pushInt(two, 2) + t.rawBlock([]string{sh, two}, bit, func(e func(StackOp)) { + e(StackOp{Op: "opcode", Code: "OP_MOD"}) + }) + t.setDomain(bit, domReduced) + } + + t.toTop("_b0") + t.rename("_idx") + for b := 1; b < w; b++ { + bit := fmt.Sprintf("_b%d", b) + wt := fmt.Sprintf("_wt%d", b) + bw := fmt.Sprintf("_bw%d", b) + t.toTop(bit) + t.pushInt(wt, int64(1<= 1; j-- { + t.toTop(fmt.Sprintf("_eq%d", j)) + t.drop() + } + + t.toTop("_idx") + t.rawBlock([]string{"_idx"}, "_flag", func(e func(StackOp)) { + e(StackOp{Op: "opcode", Code: "OP_0NOTEQUAL"}) + }) + } + + // Round d-1 initialises the accumulator. The first digit is non-zero by + // construction (CombGeometry), so this is a real point and never infinity. + emitSelect(d - 1) + t.toTop("_flag") + t.drop() + t.toTop("ax") + t.rename("jx") + t.toTop("ay") + t.rename("jy") + t.pushInt("jz", 1) + t.setDomain("jz", domReduced) + + for i := d - 2; i >= 0; i-- { + cJacobianDouble(t, c) + emitSelect(i) + + // cJacobianAddAffineBody documents its layout as [..., ax, ay, jx, jy, + // jz] and replaces the accumulator IN PLACE at the top. The selection + // leaves ax/ay above jz, so restore the contract before the branch — + // otherwise the add arm would reorder the stack and the empty else arm + // would not, leaving the two arms with different layouts at OP_ENDIF. + t.toTop("_flag") + t.toAlt() + t.toTop("jx") + t.toTop("jy") + t.toTop("jz") + t.fromAlt("_flag") + + t.popTracked() // consumed by OP_IF + var addOps []StackOp + addEmit := func(op StackOp) { addOps = append(addOps, op) } + if safe[i] { + cBuildJacobianAddAffineInline(addEmit, t, c) + } else { + cBuildJacobianAddOrDoubleInline(addEmit, t, c) + } + emit(StackOp{Op: "if", Then: addOps, Else: []StackOp{}}) + + // The addend was selected fresh for this round; the add only copied it. + t.toTop("ay") + t.drop() + t.toTop("ax") + t.drop() + } + + cJacobianToAffine(t, "_rx", "_ry", c) + + for j := entries; j >= 1; j-- { + t.toTop(fmt.Sprintf("_Ty%d", j)) + t.drop() + t.toTop(fmt.Sprintf("_Tx%d", j)) + t.drop() + } + t.toTop("_k") + t.drop() + + cComposePoint(t, "_rx", "_ry", "_result", c) + t.releaseConstant(ecPoolGroupN) + t.releaseConstant(ecPoolFieldP) + return true +} + +// cEmitCombBest emits the cheapest comb over the candidate window widths. +// +// The instruction is not to hardcode a winner: each candidate is rendered in +// full and scored with the same byte-cost model the emitter is measured by, and +// the smallest wins. w=1 is the binary ladder and is excluded; beyond w=4 the +// 2^w selection logic dominates. +// +// Returns nil when no candidate could be built, so the caller falls back to the +// ladder rather than emitting nothing. +func cEmitCombBest(c *nistCurveParams, g *nistGroupParams, curve *CombCurve, opts *EcCodegenOptions) []StackOp { + var best []StackOp + for _, w := range []int{2, 3, 4} { + var ops []StackOp + if !cEmitCombMulGen(func(op StackOp) { ops = append(ops, op) }, c, g, curve, w, opts) { + continue + } + if best == nil || EstimateScriptBytes(ops) < EstimateScriptBytes(best) { + best = ops + } + } + return best } // =========================================================================== @@ -1027,8 +1368,8 @@ func cDecompressPubKey( e(StackOp{Op: "push", Value: bigIntPush(1)}) e(StackOp{Op: "opcode", Code: "OP_SPLIT"}) }) - t.nm = append(t.nm, "_dk_prefix") - t.nm = append(t.nm, "_dk_xbytes") + t.pushTracked("_dk_prefix", domUnknown) + t.pushTracked("_dk_xbytes", domUnknown) // 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 +1461,7 @@ func cDecompressPubKey( // Use OP_IF to select: if match, use y_cand (drop neg_y), else use neg_y (drop y_cand) t.toTop("_dk_match") - t.nm = t.nm[:len(t.nm)-1] // condition consumed by IF + t.popTracked() // condition consumed by IF thenOps := []StackOp{{Op: "drop"}} // remove neg_y, leaving y_cand elseOps := []StackOp{{Op: "nip"}} // remove y_cand, leaving neg_y @@ -1135,7 +1476,7 @@ func cDecompressPubKey( } } if negIdx >= 0 { - t.nm = append(t.nm[:negIdx], t.nm[negIdx+1:]...) + t.removeSlotAt(negIdx) } ycIdx := -1 @@ -1226,8 +1567,8 @@ func cEmitLengthGate(t *ECTracker, name string, want int, flagName string) { e(StackOp{Op: "opcode", Code: "OP_SPLIT"}) e(StackOp{Op: "drop"}) }) - t.nm = append(t.nm, flagName) - t.nm = append(t.nm, name) + t.pushTracked(flagName, domUnknown) + t.pushTracked(name, domUnknown) } // cEmitSigRangeGate is SEC1 §4.1.4 step 1 / FIPS 186-5 §6.4.2: verify @@ -1301,8 +1642,16 @@ func cEmitVerifyECDSA( c *nistCurveParams, g *nistGroupParams, curveB, sqrtExp, gx, gy *big.Int, + combCurve *CombCurve, + opts *EcCodegenOptions, ) { - t := NewECTracker([]string{"_msg", "_sig", "_pk"}, emit) + t := NewECTrackerOpts([]string{"_msg", "_sig", "_pk"}, emit, opts, nil) + // The verifier does hundreds of reductions OUTSIDE the two ladders — + // decompression's sqrt ladder, cGroupInv, cAffineAdd, the final cGroupMod. + // Each ladder pools separately: cEmitMul runs on its own tracker that + // deliberately cannot see this stack, so it cannot reach this slot. + t.poolConstant(ecPoolFieldP, c.fieldP) + t.poolConstant(ecPoolGroupN, g.n) // Step 0: length gate. _sig and _pk are bare ByteString in the builtin // table and the type checker imposes no width, so both arrive @@ -1336,8 +1685,8 @@ func cEmitVerifyECDSA( e(StackOp{Op: "push", Value: bigIntPush(int64(c.coordBytes))}) e(StackOp{Op: "opcode", Code: "OP_SPLIT"}) }) - t.nm = append(t.nm, "_r_bytes") - t.nm = append(t.nm, "_s_bytes") + t.pushTracked("_r_bytes", domUnknown) + t.pushTracked("_s_bytes", domUnknown) // Convert r_bytes to integer t.toTop("_r_bytes") @@ -1395,7 +1744,17 @@ func cEmitVerifyECDSA( copy(gPointData[0:c.coordBytes], bigintToNBytes(gx, c.coordBytes)) copy(gPointData[c.coordBytes:pointBytes], bigintToNBytes(gy, c.coordBytes)) - t.pushBytes("_G", gPointData) + // u1*G. G is a compile-time constant, so this half can use a fixed-base + // comb — one doubling and one add per COLUMN instead of per bit. u2*Q below + // cannot: Q arrives in the witness. + var combOps []StackOp + if opts != nil && opts.FixedBaseComb { + combOps = cEmitCombBest(c, g, combCurve, opts) + } + + if combOps == nil { + t.pushBytes("_G", gPointData) + } t.toTop("_u1") // Stash items on altstack. _input_ok goes DEEPEST — the altstack is LIFO @@ -1411,14 +1770,23 @@ func cEmitVerifyECDSA( t.toTop("_qx") t.toAlt() - // Remove _G and _u1 from tracker before cEmitMul - t.nm = t.nm[:len(t.nm)-1] // _u1 - t.nm = t.nm[:len(t.nm)-1] // _G + // The multiply creates its own ECTracker and cannot see items below its + // operands. Remove them from ours. + t.popTracked() // _u1 + if combOps == nil { + t.popTracked() // _G + } - cEmitMul(emit, c, g) + if combOps != nil { + for _, op := range combOps { + emit(op) + } + } else { + cEmitMul(emit, c, g, opts) + } // After mul, one result point is on the stack - t.nm = append(t.nm, "_R1_point") + t.pushTracked("_R1_point", domUnknown) // Pop qx/qy/u2 from altstack (LIFO order) t.fromAlt("_qx") @@ -1435,10 +1803,10 @@ func cEmitVerifyECDSA( t.toTop("_u2") // Remove from tracker, emit mul, push result - t.nm = t.nm[:len(t.nm)-1] // _u2 - t.nm = t.nm[:len(t.nm)-1] // _Q_point - cEmitMul(emit, c, g) - t.nm = append(t.nm, "_R2_point") + t.popTracked() // _u2 + t.popTracked() // _Q_point + cEmitMul(emit, c, g, opts) + t.pushTracked("_R2_point", domUnknown) // Restore R1 point t.fromAlt("_R1_point") @@ -1503,6 +1871,8 @@ func cEmitVerifyECDSA( t.rawBlock([]string{"_input_ok", "_sig_ok"}, "_result", func(e func(StackOp)) { e(StackOp{Op: "opcode", Code: "OP_BOOLAND"}) }) + t.releaseConstant(ecPoolGroupN) + t.releaseConstant(ecPoolFieldP) } // =========================================================================== @@ -1510,41 +1880,54 @@ func cEmitVerifyECDSA( // =========================================================================== // EmitP256Add adds two P-256 points. -func EmitP256Add(emit func(StackOp)) { - t := NewECTracker([]string{"_pa", "_pb"}, emit) +func EmitP256Add(emit func(StackOp), opts *EcCodegenOptions) { + t := NewECTrackerOpts([]string{"_pa", "_pb"}, emit, opts, nil) + t.poolConstant(ecPoolFieldP, p256CurveParams.fieldP) cDecomposePoint(t, "_pa", "px", "py", p256CurveParams) cDecomposePoint(t, "_pb", "qx", "qy", p256CurveParams) cAffineAdd(t, p256CurveParams) cComposePoint(t, "rx", "ry", "_result", p256CurveParams) + t.releaseConstant(ecPoolFieldP) } // EmitP256Mul performs P-256 scalar multiplication. -func EmitP256Mul(emit func(StackOp)) { - cEmitMul(emit, p256CurveParams, p256GroupParams) +func EmitP256Mul(emit func(StackOp), opts *EcCodegenOptions) { + cEmitMul(emit, p256CurveParams, p256GroupParams, opts) } // EmitP256MulGen performs P-256 generator multiplication. -func EmitP256MulGen(emit func(StackOp)) { +func EmitP256MulGen(emit func(StackOp), opts *EcCodegenOptions) { + if opts != nil && opts.FixedBaseComb { + if ops := cEmitCombBest(p256CurveParams, p256GroupParams, P256CombCurve, opts); ops != nil { + for _, op := range ops { + emit(op) + } + return + } + } gPoint := make([]byte, 64) copy(gPoint[0:32], bigintToNBytes(p256GX, 32)) copy(gPoint[32:64], bigintToNBytes(p256GY, 32)) emit(StackOp{Op: "push", Value: PushValue{Kind: "bytes", Bytes: gPoint}}) emit(StackOp{Op: "swap"}) // [point, scalar] - EmitP256Mul(emit) + EmitP256Mul(emit, opts) } // EmitP256Negate negates a P-256 point. -func EmitP256Negate(emit func(StackOp)) { - t := NewECTracker([]string{"_pt"}, emit) +func EmitP256Negate(emit func(StackOp), opts *EcCodegenOptions) { + t := NewECTrackerOpts([]string{"_pt"}, emit, opts, nil) + t.poolConstant(ecPoolFieldP, p256CurveParams.fieldP) cDecomposePoint(t, "_pt", "_nx", "_ny", p256CurveParams) cPushFieldP(t, "_fp", p256CurveParams) cFieldSub(t, "_fp", "_ny", "_neg_y", p256CurveParams) cComposePoint(t, "_nx", "_neg_y", "_result", p256CurveParams) + t.releaseConstant(ecPoolFieldP) } // EmitP256OnCurve checks if a P-256 point is on the curve (y^2 = x^3 - 3x + b mod p). -func EmitP256OnCurve(emit func(StackOp)) { - t := NewECTracker([]string{"_pt"}, emit) +func EmitP256OnCurve(emit func(StackOp), opts *EcCodegenOptions) { + t := NewECTrackerOpts([]string{"_pt"}, emit, opts, nil) + t.poolConstant(ecPoolFieldP, p256CurveParams.fieldP) cDecomposePoint(t, "_pt", "_x", "_y", p256CurveParams) cEmitCanonicityGuard(t, "_x", "_y", p256CurveParams) @@ -1574,6 +1957,7 @@ func EmitP256OnCurve(emit func(StackOp)) { t.rawBlock([]string{"_canon", "_curve_eq"}, "_result", func(e func(StackOp)) { e(StackOp{Op: "opcode", Code: "OP_BOOLAND"}) }) + t.releaseConstant(ecPoolFieldP) } // EmitP256EncodeCompressed encodes a P-256 point as 33-byte compressed pubkey. @@ -1604,8 +1988,8 @@ func EmitP256EncodeCompressed(emit func(StackOp)) { } // EmitVerifyECDSA_P256 verifies an ECDSA signature on P-256. -func EmitVerifyECDSA_P256(emit func(StackOp)) { - cEmitVerifyECDSA(emit, p256CurveParams, p256GroupParams, p256B, p256SqrtExp, p256GX, p256GY) +func EmitVerifyECDSA_P256(emit func(StackOp), opts *EcCodegenOptions) { + cEmitVerifyECDSA(emit, p256CurveParams, p256GroupParams, p256B, p256SqrtExp, p256GX, p256GY, P256CombCurve, opts) } // =========================================================================== @@ -1613,41 +1997,54 @@ func EmitVerifyECDSA_P256(emit func(StackOp)) { // =========================================================================== // EmitP384Add adds two P-384 points. -func EmitP384Add(emit func(StackOp)) { - t := NewECTracker([]string{"_pa", "_pb"}, emit) +func EmitP384Add(emit func(StackOp), opts *EcCodegenOptions) { + t := NewECTrackerOpts([]string{"_pa", "_pb"}, emit, opts, nil) + t.poolConstant(ecPoolFieldP, p384CurveParams.fieldP) cDecomposePoint(t, "_pa", "px", "py", p384CurveParams) cDecomposePoint(t, "_pb", "qx", "qy", p384CurveParams) cAffineAdd(t, p384CurveParams) cComposePoint(t, "rx", "ry", "_result", p384CurveParams) + t.releaseConstant(ecPoolFieldP) } // EmitP384Mul performs P-384 scalar multiplication. -func EmitP384Mul(emit func(StackOp)) { - cEmitMul(emit, p384CurveParams, p384GroupParams) +func EmitP384Mul(emit func(StackOp), opts *EcCodegenOptions) { + cEmitMul(emit, p384CurveParams, p384GroupParams, opts) } // EmitP384MulGen performs P-384 generator multiplication. -func EmitP384MulGen(emit func(StackOp)) { +func EmitP384MulGen(emit func(StackOp), opts *EcCodegenOptions) { + if opts != nil && opts.FixedBaseComb { + if ops := cEmitCombBest(p384CurveParams, p384GroupParams, P384CombCurve, opts); ops != nil { + for _, op := range ops { + emit(op) + } + return + } + } gPoint := make([]byte, 96) copy(gPoint[0:48], bigintToNBytes(p384GX, 48)) copy(gPoint[48:96], bigintToNBytes(p384GY, 48)) emit(StackOp{Op: "push", Value: PushValue{Kind: "bytes", Bytes: gPoint}}) emit(StackOp{Op: "swap"}) // [point, scalar] - EmitP384Mul(emit) + EmitP384Mul(emit, opts) } // EmitP384Negate negates a P-384 point. -func EmitP384Negate(emit func(StackOp)) { - t := NewECTracker([]string{"_pt"}, emit) +func EmitP384Negate(emit func(StackOp), opts *EcCodegenOptions) { + t := NewECTrackerOpts([]string{"_pt"}, emit, opts, nil) + t.poolConstant(ecPoolFieldP, p384CurveParams.fieldP) cDecomposePoint(t, "_pt", "_nx", "_ny", p384CurveParams) cPushFieldP(t, "_fp", p384CurveParams) cFieldSub(t, "_fp", "_ny", "_neg_y", p384CurveParams) cComposePoint(t, "_nx", "_neg_y", "_result", p384CurveParams) + t.releaseConstant(ecPoolFieldP) } // EmitP384OnCurve checks if a P-384 point is on the curve. -func EmitP384OnCurve(emit func(StackOp)) { - t := NewECTracker([]string{"_pt"}, emit) +func EmitP384OnCurve(emit func(StackOp), opts *EcCodegenOptions) { + t := NewECTrackerOpts([]string{"_pt"}, emit, opts, nil) + t.poolConstant(ecPoolFieldP, p384CurveParams.fieldP) cDecomposePoint(t, "_pt", "_x", "_y", p384CurveParams) cEmitCanonicityGuard(t, "_x", "_y", p384CurveParams) @@ -1677,6 +2074,7 @@ func EmitP384OnCurve(emit func(StackOp)) { t.rawBlock([]string{"_canon", "_curve_eq"}, "_result", func(e func(StackOp)) { e(StackOp{Op: "opcode", Code: "OP_BOOLAND"}) }) + t.releaseConstant(ecPoolFieldP) } // EmitP384EncodeCompressed encodes a P-384 point as 49-byte compressed pubkey. @@ -1707,6 +2105,6 @@ func EmitP384EncodeCompressed(emit func(StackOp)) { } // EmitVerifyECDSA_P384 verifies an ECDSA signature on P-384. -func EmitVerifyECDSA_P384(emit func(StackOp)) { - cEmitVerifyECDSA(emit, p384CurveParams, p384GroupParams, p384B, p384SqrtExp, p384GX, p384GY) +func EmitVerifyECDSA_P384(emit func(StackOp), opts *EcCodegenOptions) { + cEmitVerifyECDSA(emit, p384CurveParams, p384GroupParams, p384B, p384SqrtExp, p384GX, p384GY, P384CombCurve, opts) } diff --git a/compilers/go/codegen/stack.go b/compilers/go/codegen/stack.go index dcdd78723..7a4885f35 100644 --- a/compilers/go/codegen/stack.go +++ b/compilers/go/codegen/stack.go @@ -25,14 +25,14 @@ const maxStackDepth = 800 // StackOp represents a single stack-machine operation. type StackOp struct { - Op string // "push", "dup", "swap", "roll", "pick", "drop", "opcode", "if", "nip", "over", "rot", "tuck", "placeholder", "raw_bytes" - Value PushValue // for push ops - Depth int // for roll/pick (informational) - Code string // for opcode ops (e.g. "OP_ADD") - Then []StackOp // for if ops - Else []StackOp // for if ops - ParamIndex int // for placeholder ops — index into constructor params - ParamName string // for placeholder ops — name of constructor param + Op string // "push", "dup", "swap", "roll", "pick", "drop", "opcode", "if", "nip", "over", "rot", "tuck", "placeholder", "raw_bytes" + Value PushValue // for push ops + Depth int // for roll/pick (informational) + Code string // for opcode ops (e.g. "OP_ADD") + Then []StackOp // for if ops + Else []StackOp // for if ops + ParamIndex int // for placeholder ops — index into constructor params + ParamName string // for placeholder ops — name of constructor param SourceLoc *ir.SourceLocation // Debug: source location from ANF binding // raw_bytes — opaque opcode-byte span emitted verbatim by a raw_script @@ -46,10 +46,10 @@ type StackOp struct { // PushValue holds the typed value for a push operation. type PushValue struct { - Kind string // "bigint", "bool", "bytes" - BigInt *big.Int - Bool bool - Bytes []byte + Kind string // "bigint", "bool", "bytes" + BigInt *big.Int + Bool bool + Bytes []byte } // StackMethod is the stack-lowered form of a single contract method. @@ -98,25 +98,25 @@ func isVariableLengthStateType(t string) bool { // --------------------------------------------------------------------------- var builtinOpcodes = map[string][]string{ - "sha256": {"OP_SHA256"}, - "ripemd160": {"OP_RIPEMD160"}, - "hash160": {"OP_HASH160"}, - "hash256": {"OP_HASH256"}, - "checkSig": {"OP_CHECKSIG"}, + "sha256": {"OP_SHA256"}, + "ripemd160": {"OP_RIPEMD160"}, + "hash160": {"OP_HASH160"}, + "hash256": {"OP_HASH256"}, + "checkSig": {"OP_CHECKSIG"}, "checkMultiSig": {"OP_CHECKMULTISIG"}, - "len": {"OP_SIZE"}, - "cat": {"OP_CAT"}, - "num2bin": {"OP_NUM2BIN"}, - "bin2num": {"OP_BIN2NUM"}, - "abs": {"OP_ABS"}, - "min": {"OP_MIN"}, - "max": {"OP_MAX"}, - "within": {"OP_WITHIN"}, - "split": {"OP_SPLIT"}, - "left": {"OP_SPLIT", "OP_DROP"}, - "int2str": {"OP_NUM2BIN"}, - "bool": {"OP_0NOTEQUAL"}, - "unpack": {"OP_BIN2NUM"}, + "len": {"OP_SIZE"}, + "cat": {"OP_CAT"}, + "num2bin": {"OP_NUM2BIN"}, + "bin2num": {"OP_BIN2NUM"}, + "abs": {"OP_ABS"}, + "min": {"OP_MIN"}, + "max": {"OP_MAX"}, + "within": {"OP_WITHIN"}, + "split": {"OP_SPLIT"}, + "left": {"OP_SPLIT", "OP_DROP"}, + "int2str": {"OP_NUM2BIN"}, + "bool": {"OP_0NOTEQUAL"}, + "unpack": {"OP_BIN2NUM"}, } // --------------------------------------------------------------------------- @@ -569,18 +569,18 @@ func collectRefs(value *ir.ANFValue) []string { // --------------------------------------------------------------------------- type loweringContext struct { - sm *stackMap - ops []StackOp - maxDepth int - properties []ir.ANFProperty - privateMethods map[string]*ir.ANFMethod // private methods available for inlining - localBindings map[string]bool // binding names in current lowerBindings scope; used by @ref: handler - outerProtectedRefs map[string]bool // parent-scope refs that must not be consumed (used after current if-branch) - insideBranch bool // true when executing inside an if-branch; update_prop skips old-value removal - currentSourceLoc *ir.SourceLocation // Debug: source location to attach to next emitted StackOps - constValues map[string]*big.Int // compile-time constant values tracked for extraction (e.g., Merkle depth) - arrayLengths map[string]int // element counts for array_literal bindings (used by checkMultiSig) - arrayElements map[string][]string // element refs for array_literal bindings (used by checkMultiSig) + sm *stackMap + ops []StackOp + maxDepth int + properties []ir.ANFProperty + privateMethods map[string]*ir.ANFMethod // private methods available for inlining + localBindings map[string]bool // binding names in current lowerBindings scope; used by @ref: handler + outerProtectedRefs map[string]bool // parent-scope refs that must not be consumed (used after current if-branch) + insideBranch bool // true when executing inside an if-branch; update_prop skips old-value removal + currentSourceLoc *ir.SourceLocation // Debug: source location to attach to next emitted StackOps + constValues map[string]*big.Int // compile-time constant values tracked for extraction (e.g., Merkle depth) + arrayLengths map[string]int // element counts for array_literal bindings (used by checkMultiSig) + arrayElements map[string][]string // element refs for array_literal bindings (used by checkMultiSig) // renamedParams maps a method param name whose name collides with a MUTABLE // property to the reserved stack-slot name its witness value lives under @@ -600,6 +600,13 @@ type loweringContext struct { // validated PoC tuple). Set via LowerToStackOptions.SP1FriParams, // which the compiler-level CompileOptions.SP1FriParams threads through. sp1FriParams *SP1FriVerifierParams + + // ecCodegen carries the EXPERIMENTAL EC size options (constant pool, sign + // lattice / reduction sinking, fixed-base comb) down to the EC and NIST + // curve emitters. nil — not an all-false struct — when nothing is enabled, + // so those emitters take their untouched default path and the emitted bytes + // are provably identical to the shipping ones. + ecCodegen *EcCodegenOptions } func newLoweringContext(params []string, properties []ir.ANFProperty) *loweringContext { @@ -663,11 +670,12 @@ func (ctx *loweringContext) emitOp(op StackOp) { // Leaves stack: [..., script, varint_bytes] // // Bitcoin varint format: -// len < 0xfd: 1 byte (len itself) -// len <= 0xffff: 0xfd + 2 bytes LE (3 bytes) -// len <= 0xffffffff: 0xfe + 4 bytes LE (5 bytes) -// otherwise: 0xff + 8 bytes LE (9 bytes — never used in -// practice for BSV scripts) +// +// len < 0xfd: 1 byte (len itself) +// len <= 0xffff: 0xfd + 2 bytes LE (3 bytes) +// len <= 0xffffffff: 0xfe + 4 bytes LE (5 bytes) +// otherwise: 0xff + 8 bytes LE (9 bytes — never used in +// practice for BSV scripts) // // We must support all four shapes; emitting a 3-byte varint for a script whose // length exceeds 0xffff produces a truncated value that no longer matches what @@ -720,7 +728,8 @@ func (ctx *loweringContext) emitVarintEncoding() { ctx.emitOp(StackOp{Op: "push", Value: bigIntPush(253)}) ctx.sm.push("") ctx.emitOp(StackOp{Op: "opcode", Code: "OP_LESSTHAN"}) - ctx.sm.pop(); ctx.sm.pop() + ctx.sm.pop() + ctx.sm.pop() ctx.sm.push("") ctx.emitOp(StackOp{Op: "opcode", Code: "OP_IF"}) ctx.sm.pop() @@ -735,7 +744,8 @@ func (ctx *loweringContext) emitVarintEncoding() { ctx.emitOp(StackOp{Op: "push", Value: bigIntPush(0x10000)}) ctx.sm.push("") ctx.emitOp(StackOp{Op: "opcode", Code: "OP_LESSTHAN"}) - ctx.sm.pop(); ctx.sm.pop() + ctx.sm.pop() + ctx.sm.pop() ctx.sm.push("") ctx.emitOp(StackOp{Op: "opcode", Code: "OP_IF"}) ctx.sm.pop() @@ -751,7 +761,8 @@ func (ctx *loweringContext) emitVarintEncoding() { ctx.emitOp(StackOp{Op: "push", Value: bigIntPush(0x100000000)}) ctx.sm.push("") ctx.emitOp(StackOp{Op: "opcode", Code: "OP_LESSTHAN"}) - ctx.sm.pop(); ctx.sm.pop() + ctx.sm.pop() + ctx.sm.pop() ctx.sm.push("") ctx.emitOp(StackOp{Op: "opcode", Code: "OP_IF"}) ctx.sm.pop() @@ -790,7 +801,8 @@ func (ctx *loweringContext) emitPushDataEncode() { ctx.emitOp(StackOp{Op: "push", Value: bigIntPush(76)}) ctx.sm.push("") ctx.emitOp(StackOp{Op: "opcode", Code: "OP_LESSTHAN"}) - ctx.sm.pop(); ctx.sm.pop() + ctx.sm.pop() + ctx.sm.pop() ctx.sm.push("") ctx.emitOp(StackOp{Op: "opcode", Code: "OP_IF"}) @@ -801,18 +813,22 @@ func (ctx *loweringContext) emitPushDataEncode() { ctx.emitOp(StackOp{Op: "push", Value: bigIntPush(2)}) ctx.sm.push("") ctx.emitOp(StackOp{Op: "opcode", Code: "OP_NUM2BIN"}) - ctx.sm.pop(); ctx.sm.pop() + ctx.sm.pop() + ctx.sm.pop() ctx.sm.push("") ctx.emitOp(StackOp{Op: "push", Value: bigIntPush(1)}) ctx.sm.push("") ctx.emitOp(StackOp{Op: "opcode", Code: "OP_SPLIT"}) - ctx.sm.pop(); ctx.sm.pop() - ctx.sm.push(""); ctx.sm.push("") + ctx.sm.pop() + ctx.sm.pop() + ctx.sm.push("") + ctx.sm.push("") ctx.emitOp(StackOp{Op: "drop"}) ctx.sm.pop() ctx.emitOp(StackOp{Op: "swap"}) ctx.sm.swap() - ctx.sm.pop(); ctx.sm.pop() + ctx.sm.pop() + ctx.sm.pop() ctx.emitOp(StackOp{Op: "opcode", Code: "OP_CAT"}) ctx.sm.push("") smEndTarget := ctx.sm.clone() @@ -825,7 +841,8 @@ func (ctx *loweringContext) emitPushDataEncode() { ctx.emitOp(StackOp{Op: "push", Value: bigIntPush(256)}) ctx.sm.push("") ctx.emitOp(StackOp{Op: "opcode", Code: "OP_LESSTHAN"}) - ctx.sm.pop(); ctx.sm.pop() + ctx.sm.pop() + ctx.sm.pop() ctx.sm.push("") ctx.emitOp(StackOp{Op: "opcode", Code: "OP_IF"}) @@ -836,25 +853,30 @@ func (ctx *loweringContext) emitPushDataEncode() { ctx.emitOp(StackOp{Op: "push", Value: bigIntPush(2)}) ctx.sm.push("") ctx.emitOp(StackOp{Op: "opcode", Code: "OP_NUM2BIN"}) - ctx.sm.pop(); ctx.sm.pop() + ctx.sm.pop() + ctx.sm.pop() ctx.sm.push("") ctx.emitOp(StackOp{Op: "push", Value: bigIntPush(1)}) ctx.sm.push("") ctx.emitOp(StackOp{Op: "opcode", Code: "OP_SPLIT"}) - ctx.sm.pop(); ctx.sm.pop() - ctx.sm.push(""); ctx.sm.push("") + ctx.sm.pop() + ctx.sm.pop() + ctx.sm.push("") + ctx.sm.push("") ctx.emitOp(StackOp{Op: "drop"}) ctx.sm.pop() ctx.emitOp(StackOp{Op: "push", Value: PushValue{Kind: "bytes", Bytes: []byte{0x4c}}}) ctx.sm.push("") ctx.emitOp(StackOp{Op: "swap"}) ctx.sm.swap() - ctx.sm.pop(); ctx.sm.pop() + ctx.sm.pop() + ctx.sm.pop() ctx.emitOp(StackOp{Op: "opcode", Code: "OP_CAT"}) ctx.sm.push("") ctx.emitOp(StackOp{Op: "swap"}) ctx.sm.swap() - ctx.sm.pop(); ctx.sm.pop() + ctx.sm.pop() + ctx.sm.pop() ctx.emitOp(StackOp{Op: "opcode", Code: "OP_CAT"}) ctx.sm.push("") @@ -865,25 +887,30 @@ func (ctx *loweringContext) emitPushDataEncode() { ctx.emitOp(StackOp{Op: "push", Value: bigIntPush(4)}) ctx.sm.push("") ctx.emitOp(StackOp{Op: "opcode", Code: "OP_NUM2BIN"}) - ctx.sm.pop(); ctx.sm.pop() + ctx.sm.pop() + ctx.sm.pop() ctx.sm.push("") ctx.emitOp(StackOp{Op: "push", Value: bigIntPush(2)}) ctx.sm.push("") ctx.emitOp(StackOp{Op: "opcode", Code: "OP_SPLIT"}) - ctx.sm.pop(); ctx.sm.pop() - ctx.sm.push(""); ctx.sm.push("") + ctx.sm.pop() + ctx.sm.pop() + ctx.sm.push("") + ctx.sm.push("") ctx.emitOp(StackOp{Op: "drop"}) ctx.sm.pop() ctx.emitOp(StackOp{Op: "push", Value: PushValue{Kind: "bytes", Bytes: []byte{0x4d}}}) ctx.sm.push("") ctx.emitOp(StackOp{Op: "swap"}) ctx.sm.swap() - ctx.sm.pop(); ctx.sm.pop() + ctx.sm.pop() + ctx.sm.pop() ctx.emitOp(StackOp{Op: "opcode", Code: "OP_CAT"}) ctx.sm.push("") ctx.emitOp(StackOp{Op: "swap"}) ctx.sm.swap() - ctx.sm.pop(); ctx.sm.pop() + ctx.sm.pop() + ctx.sm.pop() ctx.emitOp(StackOp{Op: "opcode", Code: "OP_CAT"}) ctx.sm.push("") @@ -901,8 +928,10 @@ func (ctx *loweringContext) emitPushDataDecode() { ctx.emitOp(StackOp{Op: "push", Value: bigIntPush(1)}) ctx.sm.push("") ctx.emitOp(StackOp{Op: "opcode", Code: "OP_SPLIT"}) - ctx.sm.pop(); ctx.sm.pop() - ctx.sm.push(""); ctx.sm.push("") + ctx.sm.pop() + ctx.sm.pop() + ctx.sm.push("") + ctx.sm.push("") ctx.emitOp(StackOp{Op: "swap"}) ctx.sm.swap() ctx.emitOp(StackOp{Op: "opcode", Code: "OP_BIN2NUM"}) @@ -911,7 +940,8 @@ func (ctx *loweringContext) emitPushDataDecode() { ctx.emitOp(StackOp{Op: "push", Value: bigIntPush(76)}) ctx.sm.push("") ctx.emitOp(StackOp{Op: "opcode", Code: "OP_LESSTHAN"}) - ctx.sm.pop(); ctx.sm.pop() + ctx.sm.pop() + ctx.sm.pop() ctx.sm.push("") ctx.emitOp(StackOp{Op: "opcode", Code: "OP_IF"}) @@ -920,8 +950,10 @@ func (ctx *loweringContext) emitPushDataDecode() { // THEN: fb < 76 → direct length ctx.emitOp(StackOp{Op: "opcode", Code: "OP_SPLIT"}) - ctx.sm.pop(); ctx.sm.pop() - ctx.sm.push(""); ctx.sm.push("") + ctx.sm.pop() + ctx.sm.pop() + ctx.sm.push("") + ctx.sm.push("") smEndTarget := ctx.sm.clone() ctx.emitOp(StackOp{Op: "opcode", Code: "OP_ELSE"}) @@ -932,7 +964,8 @@ func (ctx *loweringContext) emitPushDataDecode() { ctx.emitOp(StackOp{Op: "push", Value: bigIntPush(77)}) ctx.sm.push("") ctx.emitOp(StackOp{Op: "opcode", Code: "OP_NUMEQUAL"}) - ctx.sm.pop(); ctx.sm.pop() + ctx.sm.pop() + ctx.sm.pop() ctx.sm.push("") ctx.emitOp(StackOp{Op: "opcode", Code: "OP_IF"}) @@ -945,14 +978,18 @@ func (ctx *loweringContext) emitPushDataDecode() { ctx.emitOp(StackOp{Op: "push", Value: bigIntPush(2)}) ctx.sm.push("") ctx.emitOp(StackOp{Op: "opcode", Code: "OP_SPLIT"}) - ctx.sm.pop(); ctx.sm.pop() - ctx.sm.push(""); ctx.sm.push("") + ctx.sm.pop() + ctx.sm.pop() + ctx.sm.push("") + ctx.sm.push("") ctx.emitOp(StackOp{Op: "swap"}) ctx.sm.swap() ctx.emitOp(StackOp{Op: "opcode", Code: "OP_BIN2NUM"}) ctx.emitOp(StackOp{Op: "opcode", Code: "OP_SPLIT"}) - ctx.sm.pop(); ctx.sm.pop() - ctx.sm.push(""); ctx.sm.push("") + ctx.sm.pop() + ctx.sm.pop() + ctx.sm.push("") + ctx.sm.push("") ctx.emitOp(StackOp{Op: "opcode", Code: "OP_ELSE"}) ctx.sm = smAfterInnerIf.clone() @@ -963,14 +1000,18 @@ func (ctx *loweringContext) emitPushDataDecode() { ctx.emitOp(StackOp{Op: "push", Value: bigIntPush(1)}) ctx.sm.push("") ctx.emitOp(StackOp{Op: "opcode", Code: "OP_SPLIT"}) - ctx.sm.pop(); ctx.sm.pop() - ctx.sm.push(""); ctx.sm.push("") + ctx.sm.pop() + ctx.sm.pop() + ctx.sm.push("") + ctx.sm.push("") ctx.emitOp(StackOp{Op: "swap"}) ctx.sm.swap() ctx.emitOp(StackOp{Op: "opcode", Code: "OP_BIN2NUM"}) ctx.emitOp(StackOp{Op: "opcode", Code: "OP_SPLIT"}) - ctx.sm.pop(); ctx.sm.pop() - ctx.sm.push(""); ctx.sm.push("") + ctx.sm.pop() + ctx.sm.pop() + ctx.sm.push("") + ctx.sm.push("") ctx.emitOp(StackOp{Op: "opcode", Code: "OP_ENDIF"}) ctx.emitOp(StackOp{Op: "opcode", Code: "OP_ENDIF"}) @@ -2728,8 +2769,8 @@ func (ctx *loweringContext) lowerComputeStateOutputHash(bindingName string, args ctx.emitOp(StackOp{Op: "opcode", Code: "OP_SPLIT"}) // [prefix, amountAndTail] ctx.sm.pop() ctx.sm.pop() - ctx.sm.push("") // prefix - ctx.sm.push("") // amountAndTail + ctx.sm.push("") // prefix + ctx.sm.push("") // amountAndTail ctx.emitOp(StackOp{Op: "nip"}) // drop prefix ctx.sm.pop() ctx.sm.pop() @@ -2739,8 +2780,8 @@ func (ctx *loweringContext) lowerComputeStateOutputHash(bindingName string, args ctx.emitOp(StackOp{Op: "opcode", Code: "OP_SPLIT"}) // [amount(8), tail(44)] ctx.sm.pop() ctx.sm.pop() - ctx.sm.push("") // amount - ctx.sm.push("") // tail + ctx.sm.push("") // amount + ctx.sm.push("") // tail ctx.emitOp(StackOp{Op: "drop"}) // drop tail ctx.sm.pop() // --- Stack: [..., stateBytes, amount(8LE)] --- @@ -3113,7 +3154,8 @@ func (ctx *loweringContext) lowerDeserializeState(preimageRef string, bindingInd ctx.emitOp(StackOp{Op: "push", Value: PushValue{Kind: "bytes", Bytes: []byte{0}}}) ctx.sm.push("") ctx.emitOp(StackOp{Op: "opcode", Code: "OP_CAT"}) - ctx.sm.pop(); ctx.sm.pop() + ctx.sm.pop() + ctx.sm.pop() ctx.sm.push("") ctx.emitOp(StackOp{Op: "opcode", Code: "OP_BIN2NUM"}) // Stack: [..., rest, fb_num] @@ -3141,7 +3183,8 @@ func (ctx *loweringContext) lowerDeserializeState(preimageRef string, bindingInd ctx.emitOp(StackOp{Op: "push", Value: bigIntPush(253)}) ctx.sm.push("") ctx.emitOp(StackOp{Op: "opcode", Code: "OP_LESSTHAN"}) - ctx.sm.pop(); ctx.sm.pop() + ctx.sm.pop() + ctx.sm.pop() ctx.sm.push("") ctx.emitOp(StackOp{Op: "opcode", Code: "OP_IF"}) ctx.sm.pop() @@ -3157,7 +3200,8 @@ func (ctx *loweringContext) lowerDeserializeState(preimageRef string, bindingInd ctx.emitOp(StackOp{Op: "push", Value: bigIntPush(254)}) ctx.sm.push("") ctx.emitOp(StackOp{Op: "opcode", Code: "OP_NUMEQUAL"}) - ctx.sm.pop(); ctx.sm.pop() + ctx.sm.pop() + ctx.sm.pop() ctx.sm.push("") ctx.emitOp(StackOp{Op: "opcode", Code: "OP_IF"}) ctx.sm.pop() @@ -3180,7 +3224,8 @@ func (ctx *loweringContext) lowerDeserializeState(preimageRef string, bindingInd ctx.emitOp(StackOp{Op: "push", Value: bigIntPush(255)}) ctx.sm.push("") ctx.emitOp(StackOp{Op: "opcode", Code: "OP_NUMEQUAL"}) - ctx.sm.pop(); ctx.sm.pop() + ctx.sm.pop() + ctx.sm.pop() ctx.sm.push("") ctx.emitOp(StackOp{Op: "opcode", Code: "OP_IF"}) ctx.sm.pop() @@ -3297,15 +3342,18 @@ func (ctx *loweringContext) parseVariableLengthStateFields(stateProps []ir.ANFPr // Variable-length byte-string: decode push-data // prefix, extract data. ctx.emitPushDataDecode() // [..., data, rest] - ctx.sm.pop(); ctx.sm.pop() + ctx.sm.pop() + ctx.sm.pop() ctx.sm.push(prop.Name) ctx.sm.push("") // rest on top } else { ctx.emitOp(StackOp{Op: "push", Value: bigIntPush(int64(propSizes[i]))}) ctx.sm.push("") ctx.emitOp(StackOp{Op: "opcode", Code: "OP_SPLIT"}) - ctx.sm.pop(); ctx.sm.pop() - ctx.sm.push(""); ctx.sm.push("") + ctx.sm.pop() + ctx.sm.pop() + ctx.sm.push("") + ctx.sm.push("") ctx.emitOp(StackOp{Op: "swap"}) ctx.sm.swap() if isNumericStateType(prop.Type) { @@ -3313,7 +3361,8 @@ func (ctx *loweringContext) parseVariableLengthStateFields(stateProps []ir.ANFPr } ctx.emitOp(StackOp{Op: "swap"}) ctx.sm.swap() - ctx.sm.pop(); ctx.sm.pop() + ctx.sm.pop() + ctx.sm.pop() ctx.sm.push(prop.Name) ctx.sm.push("") } @@ -4009,8 +4058,8 @@ func (ctx *loweringContext) lowerArrayAccess(bindingName string, args []string, ctx.bringToTop(index, indexConsume) // OP_SPLIT at index: stack = [..., left, right] - ctx.sm.pop() // index consumed - ctx.sm.pop() // data consumed + ctx.sm.pop() // index consumed + ctx.sm.pop() // data consumed ctx.emitOp(StackOp{Op: "opcode", Code: "OP_SPLIT"}) ctx.sm.push("") // left part (discard) ctx.sm.push("") // right part (keep) @@ -4026,8 +4075,8 @@ func (ctx *loweringContext) lowerArrayAccess(bindingName string, args []string, ctx.sm.push("") // OP_SPLIT: split off first byte: stack = [..., firstByte, rest] - ctx.sm.pop() // 1 consumed - ctx.sm.pop() // right consumed + ctx.sm.pop() // 1 consumed + ctx.sm.pop() // right consumed ctx.emitOp(StackOp{Op: "opcode", Code: "OP_SPLIT"}) ctx.sm.push("") // first byte (keep) ctx.sm.push("") // rest (discard) @@ -4179,12 +4228,12 @@ func (ctx *loweringContext) lowerRight(bindingName string, args []string, bindin ctx.sm.pop() // len ctx.sm.pop() // data - ctx.emitOp(StackOp{Op: "swap"}) // - ctx.emitOp(StackOp{Op: "opcode", Code: "OP_SIZE"}) // - ctx.emitOp(StackOp{Op: "rot"}) // - ctx.emitOp(StackOp{Op: "opcode", Code: "OP_SUB"}) // - ctx.emitOp(StackOp{Op: "opcode", Code: "OP_SPLIT"}) // - ctx.emitOp(StackOp{Op: "nip"}) // + ctx.emitOp(StackOp{Op: "swap"}) // + ctx.emitOp(StackOp{Op: "opcode", Code: "OP_SIZE"}) // + ctx.emitOp(StackOp{Op: "rot"}) // + ctx.emitOp(StackOp{Op: "opcode", Code: "OP_SUB"}) // + ctx.emitOp(StackOp{Op: "opcode", Code: "OP_SPLIT"}) // + ctx.emitOp(StackOp{Op: "nip"}) // ctx.sm.push(bindingName) ctx.trackDepth() @@ -4211,11 +4260,11 @@ func (ctx *loweringContext) lowerSafeDivMod(bindingName, funcName string, args [ // Stack: ... a b // DUP b, check non-zero, then divide/mod - ctx.emitOp(StackOp{Op: "opcode", Code: "OP_DUP"}) // ... a b b - ctx.sm.push("") // extra b copy + ctx.emitOp(StackOp{Op: "opcode", Code: "OP_DUP"}) // ... a b b + ctx.sm.push("") // extra b copy ctx.emitOp(StackOp{Op: "opcode", Code: "OP_0NOTEQUAL"}) // ... a b (b!=0) ctx.emitOp(StackOp{Op: "opcode", Code: "OP_VERIFY"}) // ... a b (aborts if zero) - ctx.sm.pop() // remove the check result + ctx.sm.pop() // remove the check result // Pop both operands, emit div or mod ctx.sm.pop() // b @@ -4286,21 +4335,21 @@ func (ctx *loweringContext) lowerPow(bindingName string, args []string, bindingI ctx.sm.pop() // base // Stack: base exp - ctx.emitOp(StackOp{Op: "swap"}) // exp base - ctx.emitOp(StackOp{Op: "push", Value: bigIntPush(1)}) // exp base 1(acc) + ctx.emitOp(StackOp{Op: "swap"}) // exp base + ctx.emitOp(StackOp{Op: "push", Value: bigIntPush(1)}) // exp base 1(acc) const maxPowIterations = 32 for i := 0; i < maxPowIterations; i++ { // Stack: exp base acc ctx.emitOp(StackOp{Op: "push", Value: bigIntPush(2)}) - ctx.emitOp(StackOp{Op: "opcode", Code: "OP_PICK"}) // exp base acc exp + ctx.emitOp(StackOp{Op: "opcode", Code: "OP_PICK"}) // exp base acc exp ctx.emitOp(StackOp{Op: "push", Value: bigIntPush(int64(i))}) - ctx.emitOp(StackOp{Op: "opcode", Code: "OP_GREATERTHAN"}) // exp base acc (exp > i) + ctx.emitOp(StackOp{Op: "opcode", Code: "OP_GREATERTHAN"}) // exp base acc (exp > i) ctx.emitOp(StackOp{ Op: "if", Then: []StackOp{ - {Op: "over"}, // exp base acc base - {Op: "opcode", Code: "OP_MUL"}, // exp base (acc*base) + {Op: "over"}, // exp base acc base + {Op: "opcode", Code: "OP_MUL"}, // exp base (acc*base) }, }) } @@ -4446,14 +4495,14 @@ func (ctx *loweringContext) lowerGcd(bindingName string, args []string, bindingI for i := 0; i < gcdIterations; i++ { // Stack: a b // if b != 0: a b -> b (a%b) - ctx.emitOp(StackOp{Op: "opcode", Code: "OP_DUP"}) // a b b - ctx.emitOp(StackOp{Op: "opcode", Code: "OP_0NOTEQUAL"}) // a b (b!=0) + ctx.emitOp(StackOp{Op: "opcode", Code: "OP_DUP"}) // a b b + ctx.emitOp(StackOp{Op: "opcode", Code: "OP_0NOTEQUAL"}) // a b (b!=0) ctx.emitOp(StackOp{ Op: "if", Then: []StackOp{ // a b -> b (a%b) {Op: "opcode", Code: "OP_TUCK"}, // b a b - {Op: "opcode", Code: "OP_MOD"}, // b (a%b) + {Op: "opcode", Code: "OP_MOD"}, // b (a%b) }, }) } @@ -4525,18 +4574,18 @@ func (ctx *loweringContext) lowerLog2(bindingName string, args []string, binding const log2Iterations = 64 for i := 0; i < log2Iterations; i++ { // Stack: input counter - ctx.emitOp(StackOp{Op: "swap"}) // counter input - ctx.emitOp(StackOp{Op: "opcode", Code: "OP_DUP"}) // counter input input - ctx.emitOp(StackOp{Op: "push", Value: bigIntPush(1)}) // counter input input 1 - ctx.emitOp(StackOp{Op: "opcode", Code: "OP_GREATERTHAN"}) // counter input (input>1) + ctx.emitOp(StackOp{Op: "swap"}) // counter input + ctx.emitOp(StackOp{Op: "opcode", Code: "OP_DUP"}) // counter input input + ctx.emitOp(StackOp{Op: "push", Value: bigIntPush(1)}) // counter input input 1 + ctx.emitOp(StackOp{Op: "opcode", Code: "OP_GREATERTHAN"}) // counter input (input>1) ctx.emitOp(StackOp{ Op: "if", Then: []StackOp{ - {Op: "push", Value: bigIntPush(2)}, // counter input 2 - {Op: "opcode", Code: "OP_DIV"}, // counter (input/2) - {Op: "swap"}, // (input/2) counter - {Op: "opcode", Code: "OP_1ADD"}, // (input/2) (counter+1) - {Op: "swap"}, // (counter+1) (input/2) + {Op: "push", Value: bigIntPush(2)}, // counter input 2 + {Op: "opcode", Code: "OP_DIV"}, // counter (input/2) + {Op: "swap"}, // (input/2) counter + {Op: "opcode", Code: "OP_1ADD"}, // (input/2) (counter+1) + {Op: "swap"}, // (counter+1) (input/2) }, }) // Stack: counter input (or input counter if swapped back) @@ -4585,6 +4634,11 @@ type LowerToStackOptions struct { // canonical `minimal-guest`, `evm-guest`, and `production-{100,64,16}` // tuples; downstream consumers can supply arbitrary tuples directly. SP1FriParams *SP1FriVerifierParams + + // EcCodegen enables the EXPERIMENTAL EC script-size optimizations. nil + // keeps every EC emitter byte-identical to the shipping output; see + // EcCodegenOptions and docs/experiments/script-size-optimizer-results.md. + EcCodegen *EcCodegenOptions } // LowerToStack converts an ANF program to a slice of StackMethods. @@ -4928,6 +4982,7 @@ func lowerMethodWithPrivateMethodsAndOptions(method *ir.ANFMethod, properties [] ctx := newLoweringContext(paramNames, properties) ctx.privateMethods = privateMethods ctx.sp1FriParams = opts.SP1FriParams + ctx.ecCodegen = opts.EcCodegen // Mode 3: witness-assisted Groth16 verifier preamble. If the method // body opens with a call to assertGroth16WitnessAssisted, emit the @@ -5180,15 +5235,15 @@ func (ctx *loweringContext) lowerEcBuiltin(bindingName, funcName string, args [] switch funcName { case "ecAdd": - EmitEcAdd(emitFn) + EmitEcAdd(emitFn, ctx.ecCodegen) case "ecMul": - EmitEcMul(emitFn) + EmitEcMul(emitFn, ctx.ecCodegen) case "ecMulGen": - EmitEcMulGen(emitFn) + EmitEcMulGen(emitFn, ctx.ecCodegen) case "ecNegate": - EmitEcNegate(emitFn) + EmitEcNegate(emitFn, ctx.ecCodegen) case "ecOnCurve": - EmitEcOnCurve(emitFn) + EmitEcOnCurve(emitFn, ctx.ecCodegen) case "ecModReduce": EmitEcModReduce(emitFn) case "ecEncodeCompressed": @@ -5236,27 +5291,27 @@ func (ctx *loweringContext) lowerNistEcBuiltin(bindingName, funcName string, arg switch funcName { case "p256Add": - EmitP256Add(emitFn) + EmitP256Add(emitFn, ctx.ecCodegen) case "p256Mul": - EmitP256Mul(emitFn) + EmitP256Mul(emitFn, ctx.ecCodegen) case "p256MulGen": - EmitP256MulGen(emitFn) + EmitP256MulGen(emitFn, ctx.ecCodegen) case "p256Negate": - EmitP256Negate(emitFn) + EmitP256Negate(emitFn, ctx.ecCodegen) case "p256OnCurve": - EmitP256OnCurve(emitFn) + EmitP256OnCurve(emitFn, ctx.ecCodegen) case "p256EncodeCompressed": EmitP256EncodeCompressed(emitFn) case "p384Add": - EmitP384Add(emitFn) + EmitP384Add(emitFn, ctx.ecCodegen) case "p384Mul": - EmitP384Mul(emitFn) + EmitP384Mul(emitFn, ctx.ecCodegen) case "p384MulGen": - EmitP384MulGen(emitFn) + EmitP384MulGen(emitFn, ctx.ecCodegen) case "p384Negate": - EmitP384Negate(emitFn) + EmitP384Negate(emitFn, ctx.ecCodegen) case "p384OnCurve": - EmitP384OnCurve(emitFn) + EmitP384OnCurve(emitFn, ctx.ecCodegen) case "p384EncodeCompressed": EmitP384EncodeCompressed(emitFn) default: @@ -5283,9 +5338,9 @@ func (ctx *loweringContext) lowerVerifyECDSA(bindingName, funcName string, args emitFn := func(op StackOp) { ctx.emitOp(op) } if funcName == "verifyECDSA_P256" { - EmitVerifyECDSA_P256(emitFn) + EmitVerifyECDSA_P256(emitFn, ctx.ecCodegen) } else { - EmitVerifyECDSA_P384(emitFn) + EmitVerifyECDSA_P384(emitFn, ctx.ecCodegen) } ctx.sm.push(bindingName) @@ -5424,9 +5479,9 @@ var bn254BuiltinNames = map[string]bool{ "bn254FieldAdd": true, "bn254FieldSub": true, "bn254FieldMul": true, "bn254FieldInv": true, "bn254FieldNeg": true, - "bn254G1Add": true, "bn254G1ScalarMul": true, + "bn254G1Add": true, "bn254G1ScalarMul": true, "bn254G1Negate": true, "bn254G1OnCurve": true, - "bn254Pairing": true, + "bn254Pairing": true, "bn254MultiPairing4": true, "bn254MultiPairing3": true, } diff --git a/compilers/go/compiler/compiler.go b/compilers/go/compiler/compiler.go index 869183d69..1b9087335 100644 --- a/compilers/go/compiler/compiler.go +++ b/compilers/go/compiler/compiler.go @@ -99,7 +99,7 @@ type SourceMap struct { // IRDebug holds optional IR snapshots for debugging / conformance checking. type IRDebug struct { - ANF *ir.ANFProgram `json:"anf,omitempty"` + ANF *ir.ANFProgram `json:"anf,omitempty"` Stack []codegen.StackMethod `json:"stack,omitempty"` } @@ -122,22 +122,22 @@ type Groth16WAMeta struct { // Artifact is the final compiled output of a Rúnar compiler. type Artifact struct { - Version string `json:"version"` - CompilerVersion string `json:"compilerVersion"` - ContractName string `json:"contractName"` - ParentClass string `json:"parentClass,omitempty"` - ABI ABI `json:"abi"` - Script string `json:"script"` - ASM string `json:"asm"` - StateFields []StateField `json:"stateFields,omitempty"` - ConstructorSlots []ConstructorSlot `json:"constructorSlots,omitempty"` - CodeSepIndexSlots []CodeSepIndexSlot `json:"codeSepIndexSlots,omitempty"` - CodeSeparatorIndex *int `json:"codeSeparatorIndex,omitempty"` - CodeSeparatorIndices []int `json:"codeSeparatorIndices,omitempty"` - BuildTimestamp string `json:"buildTimestamp"` - ANF *ir.ANFProgram `json:"anf,omitempty"` - SourceMapData *SourceMap `json:"sourceMap,omitempty"` - IR *IRDebug `json:"ir,omitempty"` + Version string `json:"version"` + CompilerVersion string `json:"compilerVersion"` + ContractName string `json:"contractName"` + ParentClass string `json:"parentClass,omitempty"` + ABI ABI `json:"abi"` + Script string `json:"script"` + ASM string `json:"asm"` + StateFields []StateField `json:"stateFields,omitempty"` + ConstructorSlots []ConstructorSlot `json:"constructorSlots,omitempty"` + CodeSepIndexSlots []CodeSepIndexSlot `json:"codeSepIndexSlots,omitempty"` + CodeSeparatorIndex *int `json:"codeSeparatorIndex,omitempty"` + CodeSeparatorIndices []int `json:"codeSeparatorIndices,omitempty"` + BuildTimestamp string `json:"buildTimestamp"` + ANF *ir.ANFProgram `json:"anf,omitempty"` + SourceMapData *SourceMap `json:"sourceMap,omitempty"` + IR *IRDebug `json:"ir,omitempty"` // Groth16WA is populated only for artifacts produced by the // `runarc groth16-wa` backend. Nil for normal Rúnar contract @@ -229,6 +229,7 @@ func CompileFromProgram(program *ir.ANFProgram, opts ...CompileOptions) (*Artifa if o.SP1FriParams != nil { lowerOpts.SP1FriParams = o.SP1FriParams } + lowerOpts.EcCodegen = o.ecCodegenOptions() // Pass 5: Stack lowering stackMethods, err := codegen.LowerToStack(program, lowerOpts) @@ -390,7 +391,7 @@ func assembleArtifact(program *ir.ANFProgram, scriptHex, scriptAsm string, const ASM: scriptAsm, StateFields: stateFields, ConstructorSlots: constructorSlots, - CodeSepIndexSlots: codeSepIndexSlots, + CodeSepIndexSlots: codeSepIndexSlots, CodeSeparatorIndex: csIndex, CodeSeparatorIndices: csIndices, BuildTimestamp: buildTimestamp(), @@ -742,6 +743,7 @@ func CompileFromSourceWithResult(sourcePath string, opts ...CompileOptions) *Com if o.SP1FriParams != nil { lowerOpts.SP1FriParams = o.SP1FriParams } + lowerOpts.EcCodegen = o.ecCodegenOptions() stackMethods, stackErr = codegen.LowerToStack(result.ANF, lowerOpts) if stackErr != nil { result.Diagnostics = append(result.Diagnostics, frontend.MakeDiagnostic( @@ -906,6 +908,7 @@ func CompileFromSourceStrWithResult(source string, fileName string, opts ...Comp if o.SP1FriParams != nil { lowerOpts.SP1FriParams = o.SP1FriParams } + lowerOpts.EcCodegen = o.ecCodegenOptions() stackMethods, stackErr = codegen.LowerToStack(result.ANF, lowerOpts) if stackErr != nil { result.Diagnostics = append(result.Diagnostics, frontend.MakeDiagnostic( diff --git a/compilers/go/compiler/options.go b/compilers/go/compiler/options.go index 5e9f29a79..6c6095d22 100644 --- a/compilers/go/compiler/options.go +++ b/compilers/go/compiler/options.go @@ -16,6 +16,29 @@ type CompileOptions struct { // Default (false) enables constant folding. DisableConstantFolding bool + // EcConstantPool, EcReductionSinking and EcFixedBaseComb are the + // EXPERIMENTAL EC script-size optimizations. All three default off, and + // with all three off every EC emitter is byte-identical to the shipping + // output — no golden, size baseline, or cross-tier hex comparison moves. + // + // Cross-tier byte parity for the flags THEMSELVES is gated by + // conformance/ec-flag-parity/expected.json, replayed here in + // codegen/ec_flag_parity_test.go. See + // docs/experiments/script-size-optimizer-results.md. + EcConstantPool bool + + // EcReductionSinking needs EcConstantPool: the cheap subtraction shape + // references the field prime twice, so without a pooled slot it is a + // regression. The emitters compare the two costs and never take the cheap + // shape when it does not pay, so enabling it alone is safe — just useless. + EcReductionSinking bool + + // EcFixedBaseComb applies only where the base point is a compile-time + // constant (ecMulGen, p256MulGen, p384MulGen, and the u1*G half of ECDSA + // verification). Runtime-base multiplies keep the binary ladder: the comb's + // interval soundness argument does not cover an attacker-chosen base. + EcFixedBaseComb bool + // ParseOnly stops compilation after the parse pass (pass 1). ParseOnly bool @@ -75,18 +98,18 @@ type CompileOptions struct { // a named preset. The presets cover: // // - "minimal-guest" — PoC tuple, matches -// tests/vectors/sp1/fri/minimal-guest/proof.postcard -// (degreeBits=3, num_queries=2, log_blowup=2, -// log_final_poly_len=2, commit/query_pow_bits=1). +// tests/vectors/sp1/fri/minimal-guest/proof.postcard +// (degreeBits=3, num_queries=2, log_blowup=2, +// log_final_poly_len=2, commit/query_pow_bits=1). // - "evm-guest" — production-scale tuple, matches -// tests/vectors/sp1/fri/evm-guest/proof.postcard -// (degreeBits=10, num_queries=100, log_blowup=1, -// log_final_poly_len=0, commit/query_pow_bits=16). +// tests/vectors/sp1/fri/evm-guest/proof.postcard +// (degreeBits=10, num_queries=100, log_blowup=1, +// log_final_poly_len=0, commit/query_pow_bits=16). // - "production-100" — alias for "evm-guest". // - "production-64" — production-scale w/ num_queries=64 fallback -// (per docs/sp1-fri-verifier.md §5). +// (per docs/sp1-fri-verifier.md §5). // - "production-16" — production-scale w/ num_queries=16 fallback -// (per docs/sp1-fri-verifier.md §5). +// (per docs/sp1-fri-verifier.md §5). // // Returns an error when the preset name is unrecognised. func SP1FriPreset(name string) (codegen.SP1FriVerifierParams, error) { @@ -291,3 +314,19 @@ func collectLoadPropRefs(bindings []ir.ANFBinding, out map[string]bool) { } } } + +// ecCodegenOptions builds the options handed to the EC / NIST codegen modules. +// +// Returns nil — not an all-false struct — when nothing is enabled, so those +// emitters take their untouched default path and the emitted bytes are provably +// identical to the shipping ones. +func (o *CompileOptions) ecCodegenOptions() *codegen.EcCodegenOptions { + if !o.EcConstantPool && !o.EcReductionSinking && !o.EcFixedBaseComb { + return nil + } + return &codegen.EcCodegenOptions{ + ConstantPool: o.EcConstantPool, + ReductionSinking: o.EcReductionSinking, + FixedBaseComb: o.EcFixedBaseComb, + } +} diff --git a/compilers/go/main.go b/compilers/go/main.go index 74ec156ab..8a57a91d4 100644 --- a/compilers/go/main.go +++ b/compilers/go/main.go @@ -59,7 +59,6 @@ func rewriteSourceMapPaths(sm *compiler.SourceMap) *compiler.SourceMap { return out } - func main() { // Subcommand dispatch: if the first arg looks like a subcommand // (not a flag), route it to the dedicated handler. This lets us add @@ -98,11 +97,17 @@ func main() { emitIRTo := flag.String("emit-ir-to", "", "write the ANF IR JSON (same bytes as --emit-ir) to this path and CONTINUE compiling (requires --source)") parseOnly := flag.Bool("parse-only", false, "stop after parse + validate; exits 0 with 'parser ok' marker (requires --source)") disableConstFold := flag.Bool("disable-constant-folding", false, "disable ANF constant folding pass") + ecConstantPool := flag.Bool("ec-constant-pool", false, "EXPERIMENTAL: pool repeated EC curve constants (changes emitted bytes)") + ecReductionSinking := flag.Bool("ec-reduction-sinking", false, "EXPERIMENTAL: drop provably-dead sign fix-ups from EC modular reductions") + ecFixedBaseComb := flag.Bool("ec-fixed-base-comb", false, "EXPERIMENTAL: comb multiplication where the base point is a compile-time constant") emitSourceMap := flag.String("emit-source-map", "", "after a successful compile, write artifact.sourceMap JSON to this path") flag.Parse() opts := compiler.CompileOptions{ DisableConstantFolding: *disableConstFold, + EcConstantPool: *ecConstantPool, + EcReductionSinking: *ecReductionSinking, + EcFixedBaseComb: *ecFixedBaseComb, // IncludeSourceMap is auto-enabled when --emit-source-map is requested // so the artifact carries the mapping table the user just asked for. IncludeSourceMap: *emitSourceMap != "", diff --git a/compilers/java/src/main/java/runar/compiler/Cli.java b/compilers/java/src/main/java/runar/compiler/Cli.java index eb1cdfcde..23c0f5625 100644 --- a/compilers/java/src/main/java/runar/compiler/Cli.java +++ b/compilers/java/src/main/java/runar/compiler/Cli.java @@ -177,7 +177,7 @@ private int compileSource(Args parsed) { // Runs INDEPENDENTLY of --emit-ir / --hex so a single invocation // can produce both artefacts (e.g. `--hex --emit-source-map=...`). if (parsed.emitSourceMap != null) { - int rc = writeSourceMap(anf, parsed.emitSourceMap); + int rc = writeSourceMap(anf, parsed.emitSourceMap, parsed.ecCodegen()); if (rc != 0) return rc; } @@ -196,7 +196,7 @@ private int compileSource(Args parsed) { } if (parsed.hex) { - return emitHex(anf); + return emitHex(anf, parsed.ecCodegen()); } // No output flag specified; default to IR emission for parity with @@ -251,8 +251,13 @@ static String repoRelativeFileName(String srcPath) { } private int writeSourceMap(AnfProgram anf, String path) { + return writeSourceMap(anf, path, null); + } + + private int writeSourceMap( + AnfProgram anf, String path, runar.compiler.codegen.Ec.EcCodegenOptions ecCodegen) { try { - StackProgram stack = StackLower.run(anf); + StackProgram stack = StackLower.run(anf, ecCodegen); StackProgram optimised = Peephole.run(stack); Emit.EmitResultWithSourceMap result = Emit.runResultWithSourceMap(optimised); StringBuilder b = new StringBuilder(64 + 80 * result.sourceMap().size()); @@ -319,7 +324,7 @@ private int compileIr(Args parsed) { // GAP-002: --emit-source-map also runs on the IR path. if (parsed.emitSourceMap != null) { - int rc = writeSourceMap(anf, parsed.emitSourceMap); + int rc = writeSourceMap(anf, parsed.emitSourceMap, parsed.ecCodegen()); if (rc != 0) return rc; } @@ -329,7 +334,7 @@ private int compileIr(Args parsed) { } if (parsed.hex) { - return emitHex(anf); + return emitHex(anf, parsed.ecCodegen()); } out.println(Jcs.stringify(anf)); @@ -355,8 +360,12 @@ public static AnfProgram optimizeAnf(AnfProgram anf, boolean disableConstantFold } private int emitHex(AnfProgram anf) { + return emitHex(anf, null); + } + + private int emitHex(AnfProgram anf, runar.compiler.codegen.Ec.EcCodegenOptions ecCodegen) { try { - StackProgram stack = StackLower.run(anf); + StackProgram stack = StackLower.run(anf, ecCodegen); StackProgram optimised = Peephole.run(stack); String hex = Emit.run(optimised); out.println(hex); @@ -625,6 +634,29 @@ static final class Args { boolean hex; boolean parseOnly; boolean disableConstantFolding; + /** + * EXPERIMENTAL EC script-size optimizations. All three default off, and + * with all three off every EC emitter is byte-identical to the shipping + * output — no golden, size baseline, or cross-tier hex comparison moves. + * + *

Cross-tier byte parity for the flags THEMSELVES is gated by + * conformance/ec-flag-parity/expected.json, replayed in + * codegen/EcFlagParityTest. + */ + boolean ecConstantPool; + /** + * Needs {@code ecConstantPool}: the cheap subtraction shape references the + * field prime twice, so without a pooled slot it does not pay. The + * emitters compare the two costs, so enabling it alone is safe — just + * useless. + */ + boolean ecReductionSinking; + /** + * Applies only where the base point is a compile-time constant. Runtime-base + * multiplies keep the binary ladder: the comb's interval soundness argument + * does not cover an attacker-chosen base. + */ + boolean ecFixedBaseComb; boolean version; boolean help; boolean daemon; @@ -658,6 +690,9 @@ static Args parse(String[] argv) { case "--hex" -> out.hex = true; case "--parse-only" -> out.parseOnly = true; case "--disable-constant-folding" -> out.disableConstantFolding = true; + case "--ec-constant-pool" -> out.ecConstantPool = true; + case "--ec-reduction-sinking" -> out.ecReductionSinking = true; + case "--ec-fixed-base-comb" -> out.ecFixedBaseComb = true; case "--emit-source-map" -> out.emitSourceMap = requireValue(list, "--emit-source-map"); case "--daemon" -> out.daemon = true; case "--version" -> out.version = true; @@ -668,6 +703,19 @@ static Args parse(String[] argv) { return out; } + /** + * Options handed to the EC / NIST codegen modules. + * + *

Returns {@code null} — not an all-false record — when nothing is + * enabled, so those emitters take their untouched default path and the + * emitted bytes are provably identical to the shipping ones. + */ + runar.compiler.codegen.Ec.EcCodegenOptions ecCodegen() { + if (!ecConstantPool && !ecReductionSinking && !ecFixedBaseComb) return null; + return new runar.compiler.codegen.Ec.EcCodegenOptions( + ecConstantPool, ecReductionSinking, ecFixedBaseComb); + } + private static String requireValue(List list, String flag) { if (list.isEmpty()) { throw new CliError("missing value for " + flag); diff --git a/compilers/java/src/main/java/runar/compiler/codegen/Comb.java b/compilers/java/src/main/java/runar/compiler/codegen/Comb.java new file mode 100644 index 000000000..8702a69fe --- /dev/null +++ b/compilers/java/src/main/java/runar/compiler/codegen/Comb.java @@ -0,0 +1,315 @@ +package runar.compiler.codegen; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.List; + +/** + * Fixed-base comb: compile-time table, and the soundness check that decides where the cheap + * incomplete addition may be used. + * + *

Port of {@code packages/runar-compiler/src/passes/comb.ts}. The binary ladders in {@code + * Ec.java} / {@code P256P384.java} use the cheap mixed add at every step but the last, justified by + * an interval argument over {@code c_i mod n}. That comment is emphatic that the argument must be + * RE-DERIVED, not assumed, by anything which changes the offset, the iteration count, or the reduce + * — and a comb changes all three. {@link #combSafeRounds} below is that re-derivation, written as + * executable interval arithmetic rather than prose, so a round only gets the cheap add when the + * exception is proved unreachable. Rounds it cannot prove fall back to the complete add-or-double + * form. + * + *

Nothing here emits Script. It is pure {@link BigInteger} arithmetic, run once per compilation, + * and unit-tested against published curve vectors. + */ +public final class Comb { + + private Comb() {} + + /** An affine point. A {@code null} reference is the point at infinity. */ + public record Point(BigInteger x, BigInteger y) {} + + /** + * A short-Weierstrass curve, for the compile-time table. + * + * @param p field prime + * @param a curve coefficient: -3 on the NIST curves, 0 on secp256k1 + * @param b curve coefficient + * @param n group order + * @param g base point + */ + public record Curve(BigInteger p, BigInteger a, BigInteger b, BigInteger n, Point g) {} + + /** + * Comb geometry for one window width, chosen so the top digit is never zero. + * + *

The binary ladder hardcodes {@code k + 3n}, which puts the scalar's top bit at a fixed + * position and so keeps the accumulator off the point at infinity. A comb needs the same + * guarantee, but its first round reads bit {@code w*d - 1}, so the offset has to be chosen + * against {@code w*d} rather than assumed. {@code offsetMultiple} is the smallest {@code m} for + * which every {@code k + m*n} has bit {@code w*d - 1} set: + * + *

{@code
+     * m*n >= 2^(w*d - 1)   and   (m+1)*n - 1 < 2^(w*d)
+     * }
+ * + *

{@code m*n == 0 (mod n)} so the result is unchanged. For P-256 at w=3 the search returns + * m=3, d=86 — i.e. exactly the {@code +3n} the binary ladder already uses. For P-384 at w=3 it + * returns m=5, d=129; assuming {@code +3n} there would have left the top digit free to be zero. + * + * @param d rounds, and the block width: digit {@code i} reads bits {@code i, i+d, ..., i+(w-1)d} + * @param lo inclusive lower bound of the scalar domain after the offset + * @param hi inclusive upper bound of the scalar domain after the offset + */ + public record Params(int w, int d, int offsetMultiple, BigInteger lo, BigInteger hi) {} + + private static BigInteger hex(String s) { + return new BigInteger(s, 16); + } + + public static final Curve P256_COMB_CURVE = + new Curve( + hex("ffffffff00000001000000000000000000000000ffffffffffffffffffffffff"), + BigInteger.valueOf(-3), + hex("5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b"), + hex("ffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551"), + new Point( + hex("6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296"), + hex("4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5"))); + + public static final Curve P384_COMB_CURVE = + new Curve( + hex( + "fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe" + + "ffffffff0000000000000000ffffffff"), + BigInteger.valueOf(-3), + hex( + "b3312fa7e23ee7e4988e056be3f82d19181d9c6efe8141120314088f5013875a" + + "c656398d8a2ed19d2a85c8edd3ec2aef"), + hex( + "ffffffffffffffffffffffffffffffffffffffffffffffffc7634d81f4372ddf" + + "581a0db248b0a77aecec196accc52973"), + new Point( + hex( + "aa87ca22be8b05378eb1c71ef320ad746e1d3b628ba79b9859f741e082542a38" + + "5502f25dbf55296c3a545e3872760ab7"), + hex( + "3617de4a96262c6f5d9e98bf9292dc29f8f41dbd289a147ce9da3113b5f0b8c0" + + "0a60b1ce1d7e819d7a431d7c90ea0e5f"))); + + /** + * secp256k1. NOT built from the NIST template: it is {@code y^2 = x^3 + 7}, so {@code a = 0}. + * Getting {@code a} wrong here does not produce an obviously broken table — it produces a table + * of points on a DIFFERENT curve, which that other curve's on-curve check would happily accept. + * Hence the published 2G vectors pinned in the tests. + */ + public static final Curve SECP256K1_COMB_CURVE = + new Curve( + hex("fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f"), + BigInteger.ZERO, + BigInteger.valueOf(7), + hex("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"), + new Point( + hex("79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"), + hex("483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8"))); + + /** + * Geometry for window width {@code w}, or {@code null} if no offset in the search range puts a + * guaranteed set bit at the top of the first digit. + * + *

Returning {@code null} rather than guessing keeps the caller from silently combing a scalar + * whose leading digit can vanish. + */ + public static Params combGeometry(int w, Curve c) { + int base = (c.n().bitLength() + w - 1) / w; + for (int d = base; d <= base + 2; d++) { + int bits = w * d; + BigInteger top = BigInteger.ONE.shiftLeft(bits - 1); + BigInteger cap = BigInteger.ONE.shiftLeft(bits); + for (int m = 1; m <= 16; m++) { + BigInteger mm = BigInteger.valueOf(m); + BigInteger lo = mm.multiply(c.n()); + BigInteger hi = mm.add(BigInteger.ONE).multiply(c.n()).subtract(BigInteger.ONE); + if (lo.compareTo(top) >= 0 && hi.compareTo(cap) < 0) { + return new Params(w, d, m, lo, hi); + } + } + } + return null; + } + + // ------------------------------------------------------------------ + // Affine arithmetic (compile time only) + // ------------------------------------------------------------------ + + private static BigInteger mod(BigInteger v, BigInteger m) { + return v.mod(m); + } + + /** Affine addition. {@code null} is the point at infinity. */ + public static Point combAffineAdd(Point p, Point q, Curve c) { + if (p == null) { + return q; + } + if (q == null) { + return p; + } + if (p.x().equals(q.x())) { + if (mod(p.y().add(q.y()), c.p()).signum() == 0) { + return null; // P == -Q + } + // Tangent. + BigInteger num = + mod(BigInteger.valueOf(3).multiply(p.x()).multiply(p.x()).add(c.a()), c.p()); + BigInteger den = mod(p.y().shiftLeft(1), c.p()).modInverse(c.p()); + BigInteger lam = mod(num.multiply(den), c.p()); + BigInteger x = mod(lam.multiply(lam).subtract(p.x().shiftLeft(1)), c.p()); + return new Point(x, mod(lam.multiply(p.x().subtract(x)).subtract(p.y()), c.p())); + } + BigInteger den = mod(q.x().subtract(p.x()), c.p()).modInverse(c.p()); + BigInteger lam = mod(mod(q.y().subtract(p.y()), c.p()).multiply(den), c.p()); + BigInteger x = mod(lam.multiply(lam).subtract(p.x()).subtract(q.x()), c.p()); + return new Point(x, mod(lam.multiply(p.x().subtract(x)).subtract(p.y()), c.p())); + } + + /** Compile-time double-and-add. {@code null} is the point at infinity. */ + public static Point combScalarMul(BigInteger k, Point p, Curve c) { + Point r = null; + Point base = p; + BigInteger e = mod(k, c.n()); + while (e.signum() > 0) { + if (e.testBit(0)) { + r = combAffineAdd(r, base, c); + } + base = combAffineAdd(base, base, c); + e = e.shiftRight(1); + } + return r; + } + + // ------------------------------------------------------------------ + // Comb table + // ------------------------------------------------------------------ + + /** + * The multiple of G that table entry {@code j} represents. + * + *

Comb round {@code i} consumes bits {@code {i, i+d, i+2d, ...}} of the scalar — one from each + * block — so entry {@code j} stands for the sum of {@code 2^(t*d)} over the set bits {@code t} of + * {@code j}. + */ + public static BigInteger combValue(int j, int d) { + BigInteger v = BigInteger.ZERO; + for (int t = 0; (j >> t) != 0; t++) { + if (((j >> t) & 1) == 1) { + v = v.add(BigInteger.ONE.shiftLeft(t * d)); + } + } + return v; + } + + /** {@code T[j] = combValue(j)*G}. Index 0 is the point at infinity and is never added. */ + public static List combTable(int w, int d, Curve c) { + List table = new ArrayList<>(); + for (int j = 0; j < (1 << w); j++) { + table.add(j == 0 ? null : combScalarMul(combValue(j, d), c.g(), c)); + } + return table; + } + + // ------------------------------------------------------------------ + // Soundness: where may the cheap incomplete addition be used? + // ------------------------------------------------------------------ + + /** + * Bounds on the comb accumulator's multiplier before round {@code i}'s doubling. + * + *

After processing rounds {@code d-1 .. i}, the accumulator is {@code c_i*G} with + * + *

{@code
+     * c_i = sum_m 2^(m*d) * floor(K_m / 2^i)
+     * }
+ * + * where {@code K_m} is the m-th {@code d}-bit block of the expanded scalar. Each floor discards + * less than one unit of its block, so + * + *
{@code
+     * k/2^i - sum_m 2^(m*d)  <  c_i  <=  k/2^i
+     * }
+ * + * and with {@code k} confined to {@code [lo, hi]} that gives a contiguous interval. The slack + * term is bounded by {@code 2^(w*d)/(2^d - 1)}, far below {@code n}, which is why the interval + * stays narrower than the group order for all but the last few rounds — exactly the property the + * binary ladder's argument relies on. + */ + private static BigInteger[] accumulatorInterval(int i, Params params) { + BigInteger slack = BigInteger.ZERO; + for (int m = 0; m < params.w(); m++) { + slack = slack.add(BigInteger.ONE.shiftLeft(m * params.d())); + } + BigInteger hi = params.hi().shiftRight(i); + BigInteger lo = params.lo().shiftRight(i).subtract(slack); + return new BigInteger[] {lo.signum() < 0 ? BigInteger.ZERO : lo, hi}; + } + + /** Does {@code [lo, hi]} contain an integer congruent to {@code target} modulo {@code n}? */ + private static boolean intervalHitsResidue( + BigInteger lo, BigInteger hi, BigInteger target, BigInteger n) { + if (hi.compareTo(lo) < 0) { + return false; + } + if (hi.subtract(lo).add(BigInteger.ONE).compareTo(n) >= 0) { + return true; // wraps a full residue class + } + BigInteger t = mod(target, n); + // Smallest value >= lo that is congruent to t (mod n). + BigInteger first = lo.add(mod(t.subtract(lo), n)); + return first.compareTo(hi) <= 0; + } + + /** + * Per-round verdict: may round {@code i} use the cheap incomplete mixed add? + * + *

The exception the cheap formula cannot represent is a pre-add accumulator equal to the + * addend, its negation, or the point at infinity. After round {@code i}'s doubling the + * accumulator is {@code 2*c_{i+1}*G}, and the addend is {@code combValue(j)*G} for whichever + * digit {@code j} the scalar selects — so the round is safe exactly when, for every {@code j}, + * + *

{@code
+     * 2*c_{i+1} != 0, +combValue(j), -combValue(j)   (mod n)
+     * }
+ * + * over the whole interval of {@code c_{i+1}}. Both {@code G} and every table entry are + * compile-time constants and the curves have cofactor 1, so {@code ord(G) = n} and this is + * decidable here. Anything the checker cannot prove gets the complete add-or-double form + * instead; {@code true} is never assumed. + * + *

Index {@code d-1} is {@code false} by construction: that round initialises the accumulator + * from the table and performs no addition at all. + */ + public static boolean[] combSafeRounds(Params params, Curve c) { + List values = new ArrayList<>(); + for (int j = 1; j < (1 << params.w()); j++) { + values.add(combValue(j, params.d())); + } + + boolean[] safe = new boolean[params.d()]; + for (int i = 0; i < params.d(); i++) { + if (i == params.d() - 1) { + continue; + } + BigInteger[] iv = accumulatorInterval(i + 1, params); + BigInteger dLo = iv[0].shiftLeft(1); + BigInteger dHi = iv[1].shiftLeft(1); + boolean ok = !intervalHitsResidue(dLo, dHi, BigInteger.ZERO, c.n()); + for (BigInteger v : values) { + if (!ok) { + break; + } + ok = + !intervalHitsResidue(dLo, dHi, v, c.n()) + && !intervalHitsResidue(dLo, dHi, v.negate(), c.n()); + } + safe[i] = ok; + } + return safe; + } +} diff --git a/compilers/java/src/main/java/runar/compiler/codegen/CostModel.java b/compilers/java/src/main/java/runar/compiler/codegen/CostModel.java new file mode 100644 index 000000000..0b954d4d7 --- /dev/null +++ b/compilers/java/src/main/java/runar/compiler/codegen/CostModel.java @@ -0,0 +1,144 @@ +package runar.compiler.codegen; + +import java.math.BigInteger; +import java.util.List; +import runar.compiler.ir.stack.BigIntPushValue; +import runar.compiler.ir.stack.BoolPushValue; +import runar.compiler.ir.stack.ByteStringPushValue; +import runar.compiler.ir.stack.DropOp; +import runar.compiler.ir.stack.DupOp; +import runar.compiler.ir.stack.IfOp; +import runar.compiler.ir.stack.NipOp; +import runar.compiler.ir.stack.OpcodeOp; +import runar.compiler.ir.stack.OverOp; +import runar.compiler.ir.stack.PickOp; +import runar.compiler.ir.stack.PlaceholderOp; +import runar.compiler.ir.stack.PushCodeSepIndexOp; +import runar.compiler.ir.stack.PushOp; +import runar.compiler.ir.stack.PushValue; +import runar.compiler.ir.stack.RawBytesOp; +import runar.compiler.ir.stack.RollOp; +import runar.compiler.ir.stack.RotOp; +import runar.compiler.ir.stack.StackOp; +import runar.compiler.ir.stack.SwapOp; +import runar.compiler.ir.stack.TuckOp; +import runar.compiler.passes.Emit; + +/** + * Script-byte cost model for Stack IR. + * + *

Port of {@code packages/runar-compiler/src/metrics/cost-model.ts}. Optimizer passes need to + * compare two candidate lowerings by the metric that actually matters — serialized locking-script + * bytes — before either one is emitted. {@code OP_DUP} and a 33-byte constant push are one + * instruction each and 1 vs 34 bytes; an instruction count cannot tell them apart. + * + *

This is deliberately NOT an approximation: every push routes through the same encoders {@code + * Emit} uses, so + * + *

{@code
+ * estimateScriptBytes(ops) == emitted hex length / 2
+ * }
+ * + * holds exactly. {@code CostModelTest} asserts that over every crypto emitter. + */ +public final class CostModel { + + private CostModel() {} + + /** + * Serialized byte cost of a single push value. + * + *

Mirrors {@code Emit.encodePushValue}: booleans are the 1-byte OP_TRUE / OP_FALSE, integers + * go through the small-int opcodes where possible, and byte arrays are MINIMALDATA-aware before + * falling back to a length-prefixed push. + */ + public static int sizeOfPushValue(PushValue value) { + if (value instanceof BoolPushValue) { + return 1; + } + if (value instanceof BigIntPushValue b) { + return Emit.encodePushBigIntHex(b.value()).length() / 2; + } + if (value instanceof ByteStringPushValue s) { + return Emit.encodePushBytesHex(hexToBytes(s.hex())).length() / 2; + } + throw new IllegalArgumentException("cost-model: unknown push value " + value); + } + + /** + * {@link #sizeOfPushValue} for a bare integer — what the constant pool and the comb width search + * compare against. + */ + public static int sizeOfPushInt(BigInteger n) { + return Emit.encodePushBigIntHex(n).length() / 2; + } + + /** + * Serialized byte cost of one Stack IR operation, including nested {@code if} arms. + * + *

Note on {@code PickOp} / {@code RollOp}: they cost ONE byte here. The depth operand is a + * separate {@code PushOp} that the tracker emits immediately before, so charging the depth here + * would double-count it. + * + *

Throws on an unknown opcode mnemonic rather than costing it zero — a typo in a codegen + * module should surface loudly, not as a cost model that quietly under-reports. + */ + public static int sizeOfStackOp(StackOp op) { + if (op instanceof PushOp p) { + return sizeOfPushValue(p.value()); + } + if (op instanceof DupOp + || op instanceof SwapOp + || op instanceof RollOp + || op instanceof PickOp + || op instanceof DropOp + || op instanceof NipOp + || op instanceof OverOp + || op instanceof RotOp + || op instanceof TuckOp) { + return 1; + } + if (op instanceof OpcodeOp o) { + if (!Emit.OPCODES.containsKey(o.code())) { + throw new RuntimeException("cost-model: unknown opcode '" + o.code() + "'"); + } + return 1; + } + if (op instanceof IfOp i) { + // OP_IF + then + [OP_ELSE + else] + OP_ENDIF. The emitter writes + // OP_ELSE only for a NON-EMPTY else arm. + int total = 2; + total += estimateScriptBytes(i.thenBranch()); + if (i.elseBranch() != null && !i.elseBranch().isEmpty()) { + total += 1 + estimateScriptBytes(i.elseBranch()); + } + return total; + } + if (op instanceof PlaceholderOp || op instanceof PushCodeSepIndexOp) { + // Both emit a single 0x00 byte that the SDK rewrites later. + return 1; + } + if (op instanceof RawBytesOp r) { + return r.bytes().length; + } + throw new RuntimeException("cost-model: unknown stack op " + op.getClass().getSimpleName()); + } + + /** Serialized byte cost of a Stack IR sequence. */ + public static int estimateScriptBytes(List ops) { + int total = 0; + for (StackOp op : ops) { + total += sizeOfStackOp(op); + } + return total; + } + + private static byte[] hexToBytes(String hex) { + int n = hex.length() / 2; + byte[] out = new byte[n]; + for (int i = 0; i < n; i++) { + out[i] = (byte) Integer.parseInt(hex.substring(i * 2, i * 2 + 2), 16); + } + return out; + } +} diff --git a/compilers/java/src/main/java/runar/compiler/codegen/Ec.java b/compilers/java/src/main/java/runar/compiler/codegen/Ec.java index ad748ee23..16c780f1b 100644 --- a/compilers/java/src/main/java/runar/compiler/codegen/Ec.java +++ b/compilers/java/src/main/java/runar/compiler/codegen/Ec.java @@ -82,13 +82,168 @@ static String hexOf(byte[] b) { // ECTracker: named stack slot tracker (mirrors Python ECTracker) // ================================================================== + /** + * Codegen options shared by every EC / NIST-curve emitter. + * + *

Off by default: with {@code null} (or an all-false instance) each emitter is byte-identical + * to what the seven tiers ship today, so no golden, size baseline, or cross-tier parity gate can + * move. + * + * @param constantPool park large repeated constants (the field prime, the group order) in a + * stack slot and copy them with {@code OP_PICK} instead of re-pushing the literal. {@code + * fieldMod} pushes the 256-bit prime at every modular reduction — 34 bytes a time, 20,025 + * times in {@code p256-wallet} (71 % of that fixture). A pick from a slot a dozen deep costs + * 2. + * @param reductionSinking emit {@code a mod p} without the sign fix-up wherever the dividend is + * provably non-negative, and the cheap {@code a - b + p} form for subtraction wherever the + * subtrahend is provably reduced. Which reductions qualify is decided by the sign lattice + * below — never assumed. Only useful alongside {@code constantPool}: the cheap subtraction + * references the prime twice, so without a pooled slot it does not pay (and the emitters + * compare the two costs, so it is never taken when it does not). + * @param fixedBaseComb use a fixed-base comb instead of the binary ladder wherever the base + * point is a compile-time constant. The window width is not fixed here: the emitter renders + * each candidate and keeps whichever the byte-cost model scores smallest. + */ + public record EcCodegenOptions( + boolean constantPool, boolean reductionSinking, boolean fixedBaseComb) { + + /** All flags off — byte-identical to the shipping output. */ + public static EcCodegenOptions none() { + return new EcCodegenOptions(false, false, false); + } + } + + /** + * What is known about a tracked value's sign and range. + * + *

{@code REDUCED} implies {@code NON_NEGATIVE}; the ordering is what the transfer functions + * meet over. {@code UNKNOWN} is the default for every slot the analysis has not explicitly proved + * something about — including everything a {@code rawBlock} or an {@code OP_IF} produces — so an + * un-analysed value can only ever fall back to the shipping reduction. + * + *

The distinction is not academic. {@code OP_BIN2NUM} of 32 unsigned coordinate bytes gives + * {@code NON_NEGATIVE} but NOT {@code REDUCED}: a coordinate may legitimately be up to {@code + * 2^256 - 1} while p is {@code 2^32 + 977} smaller. Multiplication and addition need only {@code + * NON_NEGATIVE}; subtraction's cheap form needs the subtrahend {@code REDUCED}, and conflating + * the two produces a script that passes 256 EC oracle assertions and is still wrong on {@code + * ecAdd((0,1), (2^256-1,1))}. + */ + public enum Dom { + /** Nothing known. May be negative. */ + UNKNOWN, + /** Provably >= 0. May be >= p. */ + NON_NEGATIVE, + /** Provably in [0, p). */ + REDUCED; + + /** True when this proves the value is >= 0. */ + boolean isNonNegative() { + return this != UNKNOWN; + } + } + + /** Stack slot names reserved for pooled constants. */ + public static final String POOL_FIELD_P = "_pool$p"; + + public static final String POOL_GROUP_N = "_pool$n"; + static final class ECTracker { final List nm; + /** + * Sign-lattice fact per stack SLOT, kept parallel to {@link #nm}. + * + *

Slot-parallel rather than keyed by name on purpose: names are reused ({@code + * _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 {@code + * nm} below mirrors into {@code dm} with the same splice, so the two cannot drift. + */ + final List dm; + + /** Lattice facts for values parked on the alt stack, bottom -> top. */ + private final List altDm = new ArrayList<>(); + final Consumer e; + /** True when this tracker may serve constants from a pooled slot. */ + final boolean pooling; + /** True when this tracker may emit sunk reductions. */ + final boolean sinking; + /** True when a compile-time-known base may use a fixed-base comb. */ + final boolean comb; ECTracker(List init, Consumer emit) { + this(init, emit, null, null); + } + + ECTracker(List init, Consumer emit, EcCodegenOptions opts, + List initDomains) { this.nm = new ArrayList<>(init); + this.dm = new ArrayList<>(); + if (initDomains != null) { + this.dm.addAll(initDomains); + } else { + for (int i = 0; i < this.nm.size(); i++) this.dm.add(Dom.UNKNOWN); + } this.e = emit; + this.pooling = opts != null && opts.constantPool(); + this.sinking = opts != null && opts.reductionSinking(); + this.comb = opts != null && opts.fixedBaseComb(); + } + + /** The options this tracker was built with, for handing to a nested tracker. */ + EcCodegenOptions options() { + return new EcCodegenOptions(pooling, sinking, comb); + } + + // -- sign lattice ------------------------------------------------ + + /** What is known about the named value. {@code UNKNOWN} when the name is absent. */ + Dom domainOf(String name) { + // 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. + if (dm.size() != nm.size()) { + throw new RuntimeException( + "ECTracker: lattice desynchronised (" + nm.size() + " slots, " + dm.size() + + " facts). Every nm mutation must go through a tracker method" + + " or pushTracked/popTracked."); + } + for (int i = nm.size() - 1; i >= 0; i--) { + if (name.equals(nm.get(i))) return dm.get(i); + } + return Dom.UNKNOWN; + } + + /** Record a fact about the named value's slot. */ + void setDomain(String name, Dom d) { + for (int i = nm.size() - 1; i >= 0; i--) { + if (name.equals(nm.get(i))) { + dm.set(i, d); + return; + } + } + } + + /** Push a slot the caller tracks itself (used where raw opcodes create items). */ + void pushTracked(String name, Dom d) { + nm.add(name); + dm.add(d); + } + + /** Pop a slot the caller tracks itself. Mirror of {@link #pushTracked}. */ + String popTracked() { + if (nm.isEmpty()) return ""; + dm.remove(dm.size() - 1); + return nm.remove(nm.size() - 1); + } + + /** Remove the slot at an absolute (bottom-relative) index. */ + void removeSlotAt(int index) { + nm.remove(index); + dm.remove(index); + } + + int depth() { + return nm.size(); } int findDepth(String name) { @@ -100,43 +255,39 @@ int findDepth(String name) { void pushBytes(String n, byte[] v) { e.accept(new PushOp(PushValue.ofHex(hexOf(v)))); - nm.add(n); + // A byte blob is not a number until BIN2NUM decides how to read it. + pushTracked(n, Dom.UNKNOWN); } void pushBigInt(String n, BigInteger v) { e.accept(new PushOp(PushValue.of(v))); - nm.add(n); + pushTracked(n, v.signum() >= 0 ? Dom.NON_NEGATIVE : Dom.UNKNOWN); } void pushInt(String n, long v) { e.accept(new PushOp(PushValue.of(v))); - nm.add(n); + pushTracked(n, v >= 0 ? Dom.NON_NEGATIVE : Dom.UNKNOWN); } void dup(String n) { e.accept(new DupOp()); - nm.add(n); + pushTracked(n, dm.isEmpty() ? Dom.UNKNOWN : dm.get(dm.size() - 1)); } void drop() { e.accept(new DropOp()); - if (!nm.isEmpty()) nm.remove(nm.size() - 1); + popTracked(); } void nip() { e.accept(new NipOp()); int L = nm.size(); - if (L >= 2) { - String top = nm.get(L - 1); - nm.remove(L - 1); - nm.remove(L - 2); - nm.add(top); - } + if (L >= 2) removeSlotAt(L - 2); } void over(String n) { e.accept(new OverOp()); - nm.add(n); + pushTracked(n, dm.size() >= 2 ? dm.get(dm.size() - 2) : Dom.UNKNOWN); } void swap() { @@ -146,6 +297,9 @@ void swap() { String t = nm.get(L - 1); nm.set(L - 1, nm.get(L - 2)); nm.set(L - 2, t); + Dom d = dm.get(L - 1); + dm.set(L - 1, dm.get(L - 2)); + dm.set(L - 2, d); } } @@ -154,8 +308,9 @@ void rot() { int L = nm.size(); if (L >= 3) { String r = nm.get(L - 3); - nm.remove(L - 3); - nm.add(r); + Dom rd = dm.get(L - 3); + removeSlotAt(L - 3); + pushTracked(r, rd); } } @@ -168,23 +323,26 @@ void roll(int d) { if (d == 1) { swap(); return; } if (d == 2) { rot(); return; } e.accept(new PushOp(PushValue.of(d))); - nm.add(""); + pushTracked("", Dom.NON_NEGATIVE); e.accept(new RollOp(d)); - nm.remove(nm.size() - 1); // pop push placeholder + popTracked(); // the depth literal int idx = nm.size() - 1 - d; String r = nm.get(idx); - nm.remove(idx); - nm.add(r); + Dom rd = dm.get(idx); + removeSlotAt(idx); + pushTracked(r, rd); } void pick(int d, String n) { if (d == 0) { dup(n); return; } if (d == 1) { over(n); return; } e.accept(new PushOp(PushValue.of(d))); - nm.add(""); + pushTracked("", Dom.NON_NEGATIVE); e.accept(new PickOp(d)); - nm.remove(nm.size() - 1); - nm.add(n); + popTracked(); // the depth literal + // Once the depth literal is gone the copied slot sits at depth d. + Dom src = dm.size() > d ? dm.get(dm.size() - 1 - d) : Dom.UNKNOWN; + pushTracked(n, src); } void toTop(String name) { @@ -195,14 +353,74 @@ void copyToTop(String name, String n) { pick(findDepth(name), n); } + // -- constant pool ----------------------------------------------- + // + // A pooled constant is an ordinary tracked slot; nothing about the + // stack model changes. pushConst just chooses, per call site and by + // emitted bytes, between copying that slot and re-pushing the literal. + // Nested trackers built from a copy of nm inherit the slot for free, so + // pooled constants work unchanged inside an OP_IF arm. + + /** Park {@code value} in {@code slot} for this emitter. No-op when pooling is off. */ + void poolConstant(String slot, BigInteger value) { + if (!pooling || nm.contains(slot)) return; + pushBigInt(slot, value); + } + + /** Remove a pooled slot. No-op when pooling is off or the slot is absent. */ + void releaseConstant(String slot) { + if (!pooling || !nm.contains(slot)) return; + toTop(slot); + drop(); + } + + /** + * Emitted bytes a {@code pushConst} of this constant would cost right now. + * + *

The comparison is exact — {@code CostModel.sizeOfPushInt} is the same encoder the emit + * pass uses — so pooling can never make a call site bigger. A pick at depth d costs {@code + * sizeOfPushInt(d) + 1}; depths 0 and 1 are OP_DUP / OP_OVER, 1 byte each. + */ + int constCost(String slot, BigInteger value) { + if (pooling && nm.contains(slot)) { + int d = findDepth(slot); + int pickCost = + d <= 1 ? 1 : CostModel.sizeOfPushInt(BigInteger.valueOf(d)) + 1; + if (pickCost < CostModel.sizeOfPushInt(value)) return pickCost; + } + return CostModel.sizeOfPushInt(value); + } + + /** + * Materialize {@code value} on top as {@code name}, from the pooled slot when that is cheaper + * in emitted bytes than pushing the literal. + */ + void pushConst(String slot, BigInteger value, String name) { + if (pooling && nm.contains(slot)) { + int d = findDepth(slot); + int pickCost = + d <= 1 ? 1 : CostModel.sizeOfPushInt(BigInteger.valueOf(d)) + 1; + if (pickCost < CostModel.sizeOfPushInt(value)) { + pick(d, name); + return; + } + } + pushBigInt(name, value); + } + void toAlt() { op("OP_TOALTSTACK"); - if (!nm.isEmpty()) nm.remove(nm.size() - 1); + if (!nm.isEmpty()) { + Dom d = dm.get(dm.size() - 1); + popTracked(); + altDm.add(d); + } } void fromAlt(String n) { op("OP_FROMALTSTACK"); - nm.add(n); + Dom d = altDm.isEmpty() ? Dom.UNKNOWN : altDm.remove(altDm.size() - 1); + pushTracked(n, d); } void rename(String n) { @@ -215,11 +433,13 @@ void rename(String n) { */ void rawBlock(List consume, String produce, Consumer> fn) { for (int i = 0; i < consume.size(); i++) { - if (!nm.isEmpty()) nm.remove(nm.size() - 1); + popTracked(); } fn.accept(this.e); if (produce != null && !produce.isEmpty()) { - nm.add(produce); + // Opaque opcodes: nothing is known about the result unless the + // caller proves it and records that with setDomain afterwards. + pushTracked(produce, Dom.UNKNOWN); } } @@ -229,15 +449,15 @@ void emitIf(String condName, Consumer> elseFn, String resultName) { toTop(condName); - // condition consumed - if (!nm.isEmpty()) nm.remove(nm.size() - 1); + popTracked(); // condition consumed List thenOps = new ArrayList<>(); List elseOps = new ArrayList<>(); thenFn.accept(thenOps::add); elseFn.accept(elseOps::add); this.e.accept(new IfOp(thenOps, elseOps)); if (resultName != null && !resultName.isEmpty()) { - nm.add(resultName); + // A join over two arms this tracker did not analyse: nothing is known. + pushTracked(resultName, Dom.UNKNOWN); } } } @@ -247,10 +467,41 @@ void emitIf(String condName, // ================================================================== private static void pushFieldP(ECTracker t, String name) { - t.pushBigInt(name, EC_FIELD_P); + t.pushConst(POOL_FIELD_P, EC_FIELD_P, name); + } + + /** + * {@code a mod p} with no sign fix-up: 1 opcode instead of 7. + * + *

Sound only when the dividend is provably >= 0, because {@code OP_MOD} takes the sign of + * the dividend. The caller proves that; this function does not check. + */ + private static void fieldModShort(ECTracker t, String aName, String resultName) { + t.toTop(aName); + pushFieldP(t, "_fmods_p"); + t.rawBlock(List.of(aName, "_fmods_p"), resultName, + e -> e.accept(new OpcodeOp("OP_MOD"))); + t.setDomain(resultName, Dom.REDUCED); + } + + /** + * Does the cheap {@code a - b + p} subtraction shape pay here? + * + *

It references the prime TWICE where 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. + */ + private static boolean cheapSubPays(ECTracker t) { + int c = t.constCost(POOL_FIELD_P, EC_FIELD_P); + return 2 * c + 2 < c + 8; } private static void fieldMod(ECTracker t, String aName, String resultName) { + if (t.sinking && t.domainOf(aName).isNonNegative()) { + fieldModShort(t, aName, resultName); + return; + } t.toTop(aName); pushFieldP(t, "_fmod_p"); t.rawBlock(List.of(aName, "_fmod_p"), resultName, e -> { @@ -263,30 +514,71 @@ private static void fieldMod(ECTracker t, String aName, String resultName) { e.accept(new SwapOp()); e.accept(new OpcodeOp("OP_MOD")); }); + t.setDomain(resultName, Dom.REDUCED); } private static void fieldAdd(ECTracker t, String aName, String bName, String resultName) { + // Read the operand facts BEFORE rawBlock consumes their slots. + boolean sumNonNeg = + t.domainOf(aName).isNonNegative() && t.domainOf(bName).isNonNegative(); t.toTop(aName); t.toTop(bName); t.rawBlock(List.of(aName, bName), "_fadd_sum", e -> e.accept(new OpcodeOp("OP_ADD"))); + if (sumNonNeg) t.setDomain("_fadd_sum", Dom.NON_NEGATIVE); fieldMod(t, "_fadd_sum", resultName); } private static void fieldSub(ECTracker t, String aName, String bName, String resultName) { 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. + boolean cheap = + t.sinking + && t.domainOf(aName).isNonNegative() + && t.domainOf(bName) == Dom.REDUCED + && cheapSubPays(t); + t.rawBlock(List.of(aName, bName), "_fsub_diff", e -> e.accept(new OpcodeOp("OP_SUB"))); + + if (cheap) { + pushFieldP(t, "_fsub_p"); + t.rawBlock(List.of("_fsub_diff", "_fsub_p"), "_fsub_shift", + e -> e.accept(new OpcodeOp("OP_ADD"))); + t.setDomain("_fsub_shift", Dom.NON_NEGATIVE); + fieldModShort(t, "_fsub_shift", resultName); + return; + } fieldMod(t, "_fsub_diff", resultName); } private static void fieldMul(ECTracker t, String aName, String bName, String resultName) { + fieldMul(t, aName, bName, resultName, false); + } + + /** + * {@code fieldMul} with an explicit assertion about the product's sign, independent of the + * operands — {@code fieldSqr} uses it, since a*a >= 0 for any a whatsoever. + */ + private static void fieldMul(ECTracker t, String aName, String bName, String resultName, + boolean productNonNegative) { + boolean nonNeg = + productNonNegative + || (t.domainOf(aName).isNonNegative() + && t.domainOf(bName).isNonNegative()); t.toTop(aName); t.toTop(bName); t.rawBlock(List.of(aName, bName), "_fmul_prod", e -> e.accept(new OpcodeOp("OP_MUL"))); + if (nonNeg) t.setDomain("_fmul_prod", Dom.NON_NEGATIVE); fieldMod(t, "_fmul_prod", resultName); } private static void fieldMulConst(ECTracker t, String aName, long c, String resultName) { + // Every call site passes a small positive c, so the product keeps a's sign. + boolean nonNeg = c > 0 && t.domainOf(aName).isNonNegative(); t.toTop(aName); t.rawBlock(List.of(aName), "_fmc_prod", e -> { if (c == 2L) { @@ -296,12 +588,14 @@ private static void fieldMulConst(ECTracker t, String aName, long c, String resu e.accept(new OpcodeOp("OP_MUL")); } }); + if (nonNeg) t.setDomain("_fmc_prod", Dom.NON_NEGATIVE); fieldMod(t, "_fmc_prod", resultName); } + /** {@code (a * a) mod p}. A square is non-negative whatever a's sign is. */ private static void fieldSqr(ECTracker t, String aName, String resultName) { t.copyToTop(aName, "_fsqr_copy"); - fieldMul(t, aName, "_fsqr_copy", resultName); + fieldMul(t, aName, "_fsqr_copy", resultName, true); } /** Compute a^(p-2) mod p via square-and-multiply. Consumes {@code aName}. */ @@ -366,8 +660,8 @@ private static void decomposePoint(ECTracker t, String pointName, String xName, e.accept(new OpcodeOp("OP_SPLIT")); }); // Manually track the two new items - t.nm.add("_dp_xb"); - t.nm.add("_dp_yb"); + t.pushTracked("_dp_xb", Dom.UNKNOWN); + t.pushTracked("_dp_yb", Dom.UNKNOWN); // Convert y_bytes (on top) to num t.rawBlock(List.of("_dp_yb"), yName, e -> { @@ -376,6 +670,11 @@ private static void decomposePoint(ECTracker t, String pointName, String xName, e.accept(new OpcodeOp("OP_CAT")); e.accept(new OpcodeOp("OP_BIN2NUM")); }); + // A 0x00 sign byte is appended before BIN2NUM, so the coordinate + // decodes UNSIGNED: >= 0, but it may be up to 2^(8*coordBytes) - 1 and + // therefore >= p. That gap is exactly what the subtraction precondition + // turns on. + t.setDomain(yName, Dom.NON_NEGATIVE); // Convert x_bytes to num t.toTop("_dp_xb"); @@ -385,6 +684,7 @@ private static void decomposePoint(ECTracker t, String pointName, String xName, e.accept(new OpcodeOp("OP_CAT")); e.accept(new OpcodeOp("OP_BIN2NUM")); }); + t.setDomain(xName, Dom.NON_NEGATIVE); // Stack: [yName, xName] -> swap to [xName, yName] t.swap(); @@ -627,7 +927,10 @@ private static void jacobianToAffine(ECTracker t, String rxName, String ryName) * Stack: [..., ax, ay, _k, jx, jy, jz] */ private static void buildJacobianAddAffineInline(Consumer e, ECTracker t) { - jacobianAddAffineBody(new ECTracker(t.nm, e), false); + // The inner tracker inherits the stack state AND the lattice facts: the + // operands' proved domains are what decide which reduction shape the + // body emits, so dropping them here would silently fall back everywhere. + jacobianAddAffineBody(new ECTracker(t.nm, e, t.options(), t.dm), false); } /** @@ -779,7 +1082,7 @@ private static void selectCoord(ECTracker t, String addName, String dblName, *

Stack layout: [..., ax, ay, _k, jx, jy, jz] — same in and out. */ private static void buildJacobianAddOrDoubleInline(Consumer e, ECTracker t) { - ECTracker it = new ECTracker(t.nm, e); + ECTracker 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. @@ -831,11 +1134,17 @@ private static void buildJacobianAddOrDoubleInline(Consumer e, ECTracke // ================================================================== public static void emitEcAdd(Consumer emit) { - ECTracker t = new ECTracker(List.of("_pa", "_pb"), emit); + emitEcAdd(emit, null); + } + + public static void emitEcAdd(Consumer emit, EcCodegenOptions opts) { + ECTracker t = new ECTracker(List.of("_pa", "_pb"), emit, opts, null); + t.poolConstant(POOL_FIELD_P, EC_FIELD_P); decomposePoint(t, "_pa", "px", "py"); decomposePoint(t, "_pb", "qx", "qy"); affineAdd(t); composePoint(t, "rx", "ry", "_result"); + t.releaseConstant(POOL_FIELD_P); } /** @@ -853,7 +1162,7 @@ public static void emitEcAdd(Consumer emit) { * ~429 KB script, and makes k >= n, k < 0 and k = 0 all well defined. */ private static void emitScalarReduce(ECTracker t, String kName, String resultName) { - t.pushBigInt("_n_red", EC_CURVE_N); + t.pushConst(POOL_GROUP_N, EC_CURVE_N, "_n_red"); t.rawBlock(List.of(kName, "_n_red"), resultName, e -> { e.accept(new OpcodeOp("OP_2DUP")); e.accept(new OpcodeOp("OP_MOD")); @@ -867,7 +1176,13 @@ private static void emitScalarReduce(ECTracker t, String kName, String resultNam } public static void emitEcMul(Consumer emit) { - ECTracker t = new ECTracker(List.of("_pt", "_k"), emit); + emitEcMul(emit, null); + } + + public static void emitEcMul(Consumer emit, EcCodegenOptions opts) { + ECTracker t = new ECTracker(List.of("_pt", "_k"), emit, opts, null); + t.poolConstant(POOL_FIELD_P, EC_FIELD_P); + t.poolConstant(POOL_GROUP_N, EC_CURVE_N); decomposePoint(t, "_pt", "ax", "ay"); // k' = k + 3n @@ -876,11 +1191,11 @@ public static void emitEcMul(Consumer emit) { // is usually an unlock argument — so reduce it first. t.toTop("_k"); emitScalarReduce(t, "_k", "_kr"); - t.pushBigInt("_n", EC_CURVE_N); + t.pushConst(POOL_GROUP_N, EC_CURVE_N, "_n"); t.rawBlock(List.of("_kr", "_n"), "_kn", e -> e.accept(new OpcodeOp("OP_ADD"))); - t.pushBigInt("_n2", EC_CURVE_N); + t.pushConst(POOL_GROUP_N, EC_CURVE_N, "_n2"); t.rawBlock(List.of("_kn", "_n2"), "_kn2", e -> e.accept(new OpcodeOp("OP_ADD"))); - t.pushBigInt("_n3", EC_CURVE_N); + t.pushConst(POOL_GROUP_N, EC_CURVE_N, "_n3"); t.rawBlock(List.of("_kn2", "_n3"), "_kn3", e -> e.accept(new OpcodeOp("OP_ADD"))); t.rename("_k"); @@ -912,7 +1227,7 @@ public static void emitEcMul(Consumer emit) { // Move _bit to TOS and remove from tracker BEFORE generating add ops t.toTop("_bit"); - t.nm.remove(t.nm.size() - 1); // _bit consumed by IF + t.popTracked(); // _bit consumed by IF List addOps = new ArrayList<>(); // Only the final step can be handed two equal operands — see // buildJacobianAddOrDoubleInline for why, and for what it costs not to. @@ -934,9 +1249,267 @@ public static void emitEcMul(Consumer emit) { // Compose result composePoint(t, "_rx", "_ry", "_result"); + t.releaseConstant(POOL_GROUP_N); + t.releaseConstant(POOL_FIELD_P); + } + + // ================================================================== + // Fixed-base comb (secp256k1) + // ================================================================== + + /** + * Round {@code i}'s digit and the selected table entry, as {@code ax}/{@code ay}/{@code _flag}. + * + *

Exactly one equality holds, so {@code sum(eq_j * T_j)} is that entry's coordinate and every + * term is non-negative and below p — no reduction is needed, and the result is {@code REDUCED} by + * construction. When the digit is zero every term vanishes and {@code _flag} is 0, so no add + * runs. + * + *

Shared by both comb emitters: the selection is pure scalar bit-twiddling and table indexing, + * with no curve arithmetic in it at all. + */ + static void combEmitSelect(ECTracker t, int i, int w, int d) { + int entries = (1 << w) - 1; + for (int b = 0; b < w; b++) { + int shift = i + b * d; + String kc = "_kc" + b; + String sh = "_sh" + b; + t.copyToTop("_k", kc); + if (shift == 0) { + t.rename(sh); + } else if (shift == 1) { + t.rawBlock(List.of(kc), sh, e -> e.accept(new OpcodeOp("OP_2DIV"))); + } else { + String sd = "_sd" + b; + t.pushInt(sd, shift); + t.rawBlock(List.of(kc, sd), sh, e -> e.accept(new OpcodeOp("OP_RSHIFTNUM"))); + } + String two = "_two" + b; + String bit = "_b" + b; + t.pushInt(two, 2); + t.rawBlock(List.of(sh, two), bit, e -> e.accept(new OpcodeOp("OP_MOD"))); + t.setDomain(bit, Dom.REDUCED); + } + + t.toTop("_b0"); + t.rename("_idx"); + for (int b = 1; b < w; b++) { + String bit = "_b" + b; + String wt = "_wt" + b; + String bw = "_bw" + b; + t.toTop(bit); + t.pushInt(wt, 1L << b); + t.rawBlock(List.of(bit, wt), bw, e -> e.accept(new OpcodeOp("OP_MUL"))); + t.toTop("_idx"); + t.rawBlock(List.of(bw, "_idx"), "_idx", e -> e.accept(new OpcodeOp("OP_ADD"))); + } + t.setDomain("_idx", Dom.REDUCED); + + for (int j = 1; j <= entries; j++) { + String ic = "_ic" + j; + String jv = "_jv" + j; + String eq = "_eq" + j; + t.copyToTop("_idx", ic); + t.pushInt(jv, j); + t.rawBlock(List.of(ic, jv), eq, e -> e.accept(new OpcodeOp("OP_NUMEQUAL"))); + t.setDomain(eq, Dom.REDUCED); + } + + for (String coord : new String[] {"x", "y"}) { + String acc = coord.equals("x") ? "ax" : "ay"; + for (int j = 1; j <= entries; j++) { + String ec = "_e" + coord + j; + String tc = "_t" + coord + j; + String pr = "_pr" + coord + j; + t.copyToTop("_eq" + j, ec); + t.copyToTop("_T" + coord + j, tc); + t.rawBlock(List.of(ec, tc), pr, e -> e.accept(new OpcodeOp("OP_MUL"))); + if (j == 1) { + t.rename(acc); + } else { + t.toTop(acc); + t.rawBlock(List.of(pr, acc), acc, e -> e.accept(new OpcodeOp("OP_ADD"))); + } + } + t.setDomain(acc, Dom.REDUCED); + } + + for (int j = entries; j >= 1; j--) { + t.toTop("_eq" + j); + t.drop(); + } + + t.toTop("_idx"); + t.rawBlock(List.of("_idx"), "_flag", e -> e.accept(new OpcodeOp("OP_0NOTEQUAL"))); + } + + /** + * {@code k*G} by a Lim-Lee fixed-base comb instead of the 257-round binary ladder. + * + *

The ladder doubles and conditionally adds once per SCALAR BIT. A comb splits the scalar into + * {@code w} blocks of {@code d} bits and reads one bit from each block per round, so it performs + * one doubling and one conditional add per COLUMN: the round count falls from {@code w*d} to + * {@code d} at the price of a {@code 2^w - 1} entry table. G is a compile-time constant here, so + * the table costs nothing to build. + * + *

This is the secp256k1 twin of {@code P256P384.cEmitCombMulGen}. The curve arithmetic is NOT + * shared: secp256k1 has {@code a = 0}, so {@code jacobianDouble} computes {@code D = 3X^2} where + * the NIST version computes {@code 3(X-Z^2)(X+Z^2)}. Only {@code Comb} — the compile-time table + * and the interval checker — is common, and it takes {@code a} from the curve record. + * + *

SOUNDNESS. The cheap incomplete mixed add cannot represent a pre-add accumulator equal to the + * addend, its negation, or the point at infinity. {@code buildJacobianAddOrDoubleInline}'s comment + * justifies using it everywhere but the ladder's LAST step by an interval argument over {@code c_i + * mod n}, and insists that argument be re-derived by anything changing the offset or the iteration + * count. A comb changes both, so it is re-derived: {@code Comb.combSafeRounds} evaluates the same + * argument as executable interval arithmetic over the comb's own geometry, and any round it cannot + * prove gets the complete add-or-double form instead. Nothing is assumed safe. + * + *

The other half of that argument is that the accumulator never starts at infinity, which needs + * the first digit non-zero. {@code Comb.combGeometry} searches for the scalar offset that + * guarantees it rather than reusing the ladder's hardcoded {@code +3n} — right for secp256k1 at + * w=3, wrong for P-384. + * + *

Stack in: [_k]. Stack out: [_result]. + * + * @return false when no geometry exists for {@code w} + */ + private static boolean emitCombMulGen(Consumer emit, int w, EcCodegenOptions opts) { + Comb.Curve curve = Comb.SECP256K1_COMB_CURVE; + Comb.Params params = Comb.combGeometry(w, curve); + if (params == null) return false; + int d = params.d(); + List table = Comb.combTable(w, d, curve); + boolean[] safe = Comb.combSafeRounds(params, curve); + int entries = (1 << w) - 1; + + ECTracker t = new ECTracker(List.of("_k"), emit, opts, null); + t.poolConstant(POOL_FIELD_P, EC_FIELD_P); + t.poolConstant(POOL_GROUP_N, EC_CURVE_N); + + // k' = (k mod n) + m*n. The reduce is what confines k to [0, n-1] and so + // what makes the interval argument apply at all; see emitScalarReduce. + t.toTop("_k"); + emitScalarReduce(t, "_k", "_kr"); + t.rename("_k"); + for (int i = 0; i < params.offsetMultiple(); i++) { + String off = "_off" + i; + t.pushConst(POOL_GROUP_N, EC_CURVE_N, off); + t.rawBlock(List.of("_k", off), "_k", e -> e.accept(new OpcodeOp("OP_ADD"))); + } + t.setDomain("_k", Dom.NON_NEGATIVE); + + // Table, resident for the whole comb: picking an entry costs 2-3 bytes + // against a 34-byte literal push, and every round reads all of them. + for (int j = 1; j <= entries; j++) { + Comb.Point pt = table.get(j); + t.pushBigInt("_Tx" + j, pt.x()); + t.pushBigInt("_Ty" + j, pt.y()); + t.setDomain("_Tx" + j, Dom.REDUCED); + t.setDomain("_Ty" + j, Dom.REDUCED); + } + + // Round d-1 initialises the accumulator. The first digit is non-zero by + // construction (combGeometry), so this is a real point, never infinity. + combEmitSelect(t, d - 1, w, d); + t.toTop("_flag"); + t.drop(); + t.toTop("ax"); + t.rename("jx"); + t.toTop("ay"); + t.rename("jy"); + t.pushInt("jz", 1); + t.setDomain("jz", Dom.REDUCED); + + for (int i = d - 2; i >= 0; i--) { + jacobianDouble(t); + combEmitSelect(t, i, w, d); + + // jacobianAddAffineBody documents its layout as + // [..., ax, ay, jx, jy, jz] and replaces the accumulator IN PLACE at + // the top. The selection leaves ax/ay above jz, so restore the + // contract before the branch — otherwise the add arm would reorder + // the stack and the empty else arm would not, leaving the two arms + // with different layouts at OP_ENDIF. + t.toTop("_flag"); + t.toAlt(); + t.toTop("jx"); + t.toTop("jy"); + t.toTop("jz"); + t.fromAlt("_flag"); + + t.popTracked(); // consumed by OP_IF + List addOps = new ArrayList<>(); + if (safe[i]) { + buildJacobianAddAffineInline(addOps::add, t); + } else { + buildJacobianAddOrDoubleInline(addOps::add, t); + } + emit.accept(new IfOp(addOps, List.of())); + + // The addend was selected fresh for this round; the add only copied it. + t.toTop("ay"); + t.drop(); + t.toTop("ax"); + t.drop(); + } + + jacobianToAffine(t, "_rx", "_ry"); + + for (int j = entries; j >= 1; j--) { + t.toTop("_Ty" + j); + t.drop(); + t.toTop("_Tx" + j); + t.drop(); + } + t.toTop("_k"); + t.drop(); + + composePoint(t, "_rx", "_ry", "_result"); + t.releaseConstant(POOL_GROUP_N); + t.releaseConstant(POOL_FIELD_P); + return true; + } + + /** + * Emit the cheapest comb over the candidate window widths. + * + *

Each candidate is rendered in full and scored with the same byte-cost model the emitter is + * measured by, and the smallest wins — the window width is not hardcoded. w=1 is the binary ladder + * and is excluded; beyond w=4 the {@code 2^w} selection logic outgrows the saving. + * + * @return {@code null} when no candidate could be built, so the caller falls back to the ladder + * rather than emitting nothing + */ + private static List emitCombBest(EcCodegenOptions opts) { + List best = null; + for (int w : new int[] {2, 3, 4}) { + List ops = new ArrayList<>(); + if (!emitCombMulGen(ops::add, w, opts)) continue; + if (best == null + || CostModel.estimateScriptBytes(ops) < CostModel.estimateScriptBytes(best)) { + best = ops; + } + } + return best; } public static void emitEcMulGen(Consumer emit) { + emitEcMulGen(emit, null); + } + + public static void emitEcMulGen(Consumer emit, EcCodegenOptions opts) { + // G is a compile-time constant, so this is the one secp256k1 call site + // where a fixed-base comb applies. emitEcMul cannot use it: its base + // arrives at run time. + if (opts != null && opts.fixedBaseComb()) { + List ops = emitCombBest(opts); + if (ops != null) { + for (StackOp op : ops) emit.accept(op); + return; + } + } + byte[] gPoint = new byte[64]; byte[] gx = bigintToBytes32(EC_GEN_X); byte[] gy = bigintToBytes32(EC_GEN_Y); @@ -944,19 +1517,30 @@ public static void emitEcMulGen(Consumer emit) { System.arraycopy(gy, 0, gPoint, 32, 32); emit.accept(new PushOp(PushValue.ofHex(hexOf(gPoint)))); emit.accept(new SwapOp()); - emitEcMul(emit); + emitEcMul(emit, opts); } public static void emitEcNegate(Consumer emit) { - ECTracker t = new ECTracker(List.of("_pt"), emit); + emitEcNegate(emit, null); + } + + public static void emitEcNegate(Consumer emit, EcCodegenOptions opts) { + ECTracker t = new ECTracker(List.of("_pt"), emit, opts, null); + t.poolConstant(POOL_FIELD_P, EC_FIELD_P); decomposePoint(t, "_pt", "_nx", "_ny"); pushFieldP(t, "_fp"); fieldSub(t, "_fp", "_ny", "_neg_y"); composePoint(t, "_nx", "_neg_y", "_result"); + t.releaseConstant(POOL_FIELD_P); } public static void emitEcOnCurve(Consumer emit) { - ECTracker t = new ECTracker(List.of("_pt"), emit); + emitEcOnCurve(emit, null); + } + + public static void emitEcOnCurve(Consumer emit, EcCodegenOptions opts) { + ECTracker t = new ECTracker(List.of("_pt"), emit, opts, null); + t.poolConstant(POOL_FIELD_P, EC_FIELD_P); decomposePoint(t, "_pt", "_x", "_y"); // GAP-301: coordinate canonicity. decomposePoint BIN2NUMs each coordinate @@ -999,6 +1583,7 @@ public static void emitEcOnCurve(Consumer emit) { t.toTop("_curve_eq"); t.rawBlock(List.of("_canon", "_curve_eq"), "_result", e -> e.accept(new OpcodeOp("OP_BOOLAND"))); + t.releaseConstant(POOL_FIELD_P); } public static void emitEcModReduce(Consumer emit) { @@ -1096,12 +1681,16 @@ public static boolean isEcBuiltin(String name) { } public static void dispatch(String funcName, Consumer emit) { + dispatch(funcName, emit, null); + } + + public static void dispatch(String funcName, Consumer emit, EcCodegenOptions opts) { switch (funcName) { - case "ecAdd" -> emitEcAdd(emit); - case "ecMul" -> emitEcMul(emit); - case "ecMulGen" -> emitEcMulGen(emit); - case "ecNegate" -> emitEcNegate(emit); - case "ecOnCurve" -> emitEcOnCurve(emit); + case "ecAdd" -> emitEcAdd(emit, opts); + case "ecMul" -> emitEcMul(emit, opts); + case "ecMulGen" -> emitEcMulGen(emit, opts); + case "ecNegate" -> emitEcNegate(emit, opts); + case "ecOnCurve" -> emitEcOnCurve(emit, opts); case "ecModReduce" -> emitEcModReduce(emit); case "ecEncodeCompressed" -> emitEcEncodeCompressed(emit); case "ecMakePoint" -> emitEcMakePoint(emit); diff --git a/compilers/java/src/main/java/runar/compiler/codegen/P256P384.java b/compilers/java/src/main/java/runar/compiler/codegen/P256P384.java index 0797d16f1..b046799d4 100644 --- a/compilers/java/src/main/java/runar/compiler/codegen/P256P384.java +++ b/compilers/java/src/main/java/runar/compiler/codegen/P256P384.java @@ -123,10 +123,33 @@ static void emitReverse48(Consumer e) { // =================================================================== private static void cPushFieldP(ECTracker t, String name, BigInteger fieldP) { - t.pushBigInt(name, fieldP); + t.pushConst(Ec.POOL_FIELD_P, fieldP, name); + } + + /** + * {@code 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. + */ + private static void cFieldModShort(ECTracker t, String aName, String resultName, + BigInteger fieldP) { + t.toTop(aName); + cPushFieldP(t, "_fmods_p", fieldP); + t.rawBlock(List.of(aName, "_fmods_p"), resultName, + e -> e.accept(new OpcodeOp("OP_MOD"))); + t.setDomain(resultName, Ec.Dom.REDUCED); + } + + /** Does the cheap {@code a - b + p} subtraction pay? Only when p is pooled. */ + private static boolean cCheapSubPays(ECTracker t, BigInteger fieldP) { + int cost = t.constCost(Ec.POOL_FIELD_P, fieldP); + return 2 * cost + 2 < cost + 8; } private static void cFieldMod(ECTracker t, String aName, String resultName, BigInteger fieldP) { + if (t.sinking && t.domainOf(aName).isNonNegative()) { + cFieldModShort(t, aName, resultName, fieldP); + return; + } t.toTop(aName); cPushFieldP(t, "_fmod_p", fieldP); t.rawBlock(List.of(aName, "_fmod_p"), resultName, e -> { @@ -139,33 +162,72 @@ private static void cFieldMod(ECTracker t, String aName, String resultName, BigI e.accept(new SwapOp()); e.accept(new OpcodeOp("OP_MOD")); }); + t.setDomain(resultName, Ec.Dom.REDUCED); } private static void cFieldAdd(ECTracker t, String aName, String bName, String resultName, BigInteger fieldP) { + // Read the operand facts before rawBlock consumes their slots. + boolean sumNonNeg = + t.domainOf(aName).isNonNegative() && t.domainOf(bName).isNonNegative(); t.toTop(aName); t.toTop(bName); t.rawBlock(List.of(aName, bName), "_fadd_sum", e -> e.accept(new OpcodeOp("OP_ADD"))); + if (sumNonNeg) t.setDomain("_fadd_sum", Ec.Dom.NON_NEGATIVE); cFieldMod(t, "_fadd_sum", resultName, fieldP); } private static void cFieldSub(ECTracker t, String aName, String bName, String resultName, BigInteger fieldP) { 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. + boolean cheap = + t.sinking + && t.domainOf(aName).isNonNegative() + && t.domainOf(bName) == Ec.Dom.REDUCED + && cCheapSubPays(t, fieldP); + t.rawBlock(List.of(aName, bName), "_fsub_diff", e -> e.accept(new OpcodeOp("OP_SUB"))); + + if (cheap) { + cPushFieldP(t, "_fsub_p", fieldP); + t.rawBlock(List.of("_fsub_diff", "_fsub_p"), "_fsub_shift", + e -> e.accept(new OpcodeOp("OP_ADD"))); + t.setDomain("_fsub_shift", Ec.Dom.NON_NEGATIVE); + cFieldModShort(t, "_fsub_shift", resultName, fieldP); + return; + } cFieldMod(t, "_fsub_diff", resultName, fieldP); } private static void cFieldMul(ECTracker t, String aName, String bName, String resultName, BigInteger fieldP) { + cFieldMul(t, aName, bName, resultName, fieldP, false); + } + + /** + * {@code cFieldMul} with an explicit assertion about the product's sign, independent of the + * operands: a*a >= 0 for any a whatsoever. + */ + private static void cFieldMul(ECTracker t, String aName, String bName, String resultName, + BigInteger fieldP, boolean productNonNegative) { + boolean nonNeg = + productNonNegative + || (t.domainOf(aName).isNonNegative() + && t.domainOf(bName).isNonNegative()); t.toTop(aName); t.toTop(bName); t.rawBlock(List.of(aName, bName), "_fmul_prod", e -> e.accept(new OpcodeOp("OP_MUL"))); + if (nonNeg) t.setDomain("_fmul_prod", Ec.Dom.NON_NEGATIVE); cFieldMod(t, "_fmul_prod", resultName, fieldP); } private static void cFieldMulConst(ECTracker t, String aName, long cv, String resultName, BigInteger fieldP) { + // Every call site passes a small positive cv, so the product keeps a's sign. + boolean nonNeg = cv > 0 && t.domainOf(aName).isNonNegative(); t.toTop(aName); t.rawBlock(List.of(aName), "_fmc_prod", e -> { if (cv == 2L) { @@ -175,12 +237,13 @@ private static void cFieldMulConst(ECTracker t, String aName, long cv, String re e.accept(new OpcodeOp("OP_MUL")); } }); + if (nonNeg) t.setDomain("_fmc_prod", Ec.Dom.NON_NEGATIVE); cFieldMod(t, "_fmc_prod", resultName, fieldP); } private static void cFieldSqr(ECTracker t, String aName, String resultName, BigInteger fieldP) { t.copyToTop(aName, "_fsqr_copy"); - cFieldMul(t, aName, "_fsqr_copy", resultName, fieldP); + cFieldMul(t, aName, "_fsqr_copy", resultName, fieldP, true); } /** Compute a^(p-2) mod p via generic square-and-multiply. */ @@ -212,7 +275,7 @@ private static void cFieldInv(ECTracker t, String aName, String resultName, // =================================================================== private static void cPushGroupN(ECTracker t, String name, BigInteger n) { - t.pushBigInt(name, n); + t.pushConst(Ec.POOL_GROUP_N, n, name); } private static void cGroupMod(ECTracker t, String aName, String resultName, BigInteger n) { @@ -304,8 +367,8 @@ private static void cDecomposePoint(ECTracker t, String pointName, e.accept(new PushOp(PushValue.of(coordBytes))); e.accept(new OpcodeOp("OP_SPLIT")); }); - t.nm.add("_dp_xb"); - t.nm.add("_dp_yb"); + t.pushTracked("_dp_xb", Ec.Dom.UNKNOWN); + t.pushTracked("_dp_yb", Ec.Dom.UNKNOWN); // Convert y_bytes (on top) to num t.rawBlock(List.of("_dp_yb"), yName, e -> { @@ -314,6 +377,11 @@ private static void cDecomposePoint(ECTracker t, String pointName, e.accept(new OpcodeOp("OP_CAT")); e.accept(new OpcodeOp("OP_BIN2NUM")); }); + // A 0x00 sign byte is appended before BIN2NUM, so the coordinate + // decodes UNSIGNED: >= 0, but it may be up to 2^(8*coordBytes) - 1 and + // therefore >= p. That gap is exactly what the subtraction precondition + // turns on. + t.setDomain(yName, Ec.Dom.NON_NEGATIVE); // Convert x_bytes to num t.toTop("_dp_xb"); @@ -323,6 +391,7 @@ private static void cDecomposePoint(ECTracker t, String pointName, e.accept(new OpcodeOp("OP_CAT")); e.accept(new OpcodeOp("OP_BIN2NUM")); }); + t.setDomain(xName, Ec.Dom.NON_NEGATIVE); // Stack: [yName, xName] -> swap to [xName, yName] t.swap(); @@ -627,7 +696,11 @@ private static void cJacobianToAffine(ECTracker t, String rxName, String ryName, private static void cBuildJacobianAddAffineInline(Consumer e, ECTracker t, BigInteger fieldP, BigInteger pMinus2) { - cJacobianAddAffineBody(new ECTracker(t.nm, e), false, fieldP, pMinus2); + // The inner tracker inherits the stack state AND the lattice facts: the + // operands' proved domains are what decide which reduction shape the + // body emits, so dropping them here would silently fall back everywhere. + cJacobianAddAffineBody( + new ECTracker(t.nm, e, t.options(), t.dm), false, fieldP, pMinus2); } /** @@ -776,7 +849,7 @@ private static void cSelectCoord(ECTracker t, String addName, String dblName, */ private static void cBuildJacobianAddOrDoubleInline(Consumer e, ECTracker t, BigInteger fieldP, BigInteger pMinus2) { - ECTracker it = new ECTracker(t.nm, e); + ECTracker 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. @@ -829,8 +902,11 @@ private static void cBuildJacobianAddOrDoubleInline(Consumer e, ECTrack private static void cEmitMul(Consumer emit, int coordBytes, ReverseBytesFn revFn, BigInteger fieldP, - BigInteger pMinus2, BigInteger curveN, BigInteger nMinus2) { - ECTracker t = new ECTracker(List.of("_pt", "_k"), emit); + BigInteger pMinus2, BigInteger curveN, BigInteger nMinus2, + Ec.EcCodegenOptions opts) { + ECTracker t = new ECTracker(List.of("_pt", "_k"), emit, opts, null); + t.poolConstant(Ec.POOL_FIELD_P, fieldP); + t.poolConstant(Ec.POOL_GROUP_N, curveN); cDecomposePoint(t, "_pt", "ax", "ay", coordBytes, revFn); // k' = k + 3n (three separate adds, matches Go reference) @@ -881,7 +957,7 @@ private static void cEmitMul(Consumer emit, int coordBytes, // Conditional add t.toTop("_bit"); - t.nm.remove(t.nm.size() - 1); // _bit consumed by IF + t.popTracked(); // _bit consumed by IF List addOps = new ArrayList<>(); // Only the final step can be handed two equal operands — see // cBuildJacobianAddOrDoubleInline for why, and for what it costs not to. @@ -901,6 +977,8 @@ private static void cEmitMul(Consumer emit, int coordBytes, t.toTop("_k"); t.drop(); cComposePoint(t, "_rx", "_ry", "_result", coordBytes, revFn); + t.releaseConstant(Ec.POOL_GROUP_N); + t.releaseConstant(Ec.POOL_FIELD_P); } // =================================================================== @@ -975,8 +1053,8 @@ private static void cDecompressPubKey(ECTracker t, String pkName, String qxName, e.accept(new PushOp(PushValue.of(1))); e.accept(new OpcodeOp("OP_SPLIT")); }); - t.nm.add("_dk_prefix"); - t.nm.add("_dk_xbytes"); + t.pushTracked("_dk_prefix", Ec.Dom.UNKNOWN); + t.pushTracked("_dk_xbytes", Ec.Dom.UNKNOWN); // 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: @@ -1061,7 +1139,7 @@ private static void cDecompressPubKey(ECTracker t, String pkName, String qxName, // OP_IF select: match → use y_cand (drop neg_y), else → use neg_y (nip y_cand) t.toTop("_dk_match"); - t.nm.remove(t.nm.size() - 1); // condition consumed by IF + t.popTracked(); // condition consumed by IF List thenOps = List.of(new DropOp()); List elseOps = List.of(new NipOp()); @@ -1070,7 +1148,7 @@ private static void cDecompressPubKey(ECTracker t, String pkName, String qxName, // Remove _dk_neg_y from tracker (one of the two was consumed) for (int i = t.nm.size() - 1; i >= 0; i--) { if ("_dk_neg_y".equals(t.nm.get(i))) { - t.nm.remove(i); + t.removeSlotAt(i); break; } } @@ -1153,8 +1231,8 @@ private static void cEmitLengthGate(ECTracker t, String name, int want, String f e.accept(new OpcodeOp("OP_SPLIT")); e.accept(new DropOp()); }); - t.nm.add(flagName); - t.nm.add(name); + t.pushTracked(flagName, Ec.Dom.UNKNOWN); + t.pushTracked(name, Ec.Dom.UNKNOWN); } /** @@ -1223,12 +1301,177 @@ private static void cEmitSigRangeGate(ECTracker t, BigInteger curveN) { e -> e.accept(new OpcodeOp("OP_BOOLAND"))); } + // ================================================================== + // Fixed-base comb (the base is a compile-time constant) + // ================================================================== + + /** + * {@code k*G} by a Lim-Lee comb, for a base known at compile time. + * + *

The binary ladder runs one doubling and one conditional add per scalar BIT. A comb splits + * the scalar into {@code w} blocks of {@code d} bits and runs one doubling and one conditional + * add per COLUMN, so the round count falls from {@code w*d} to {@code d} at the price of a + * {@code 2^w - 1} entry table — which costs nothing to build here, because {@code G} is a + * constant. Measured optimum is w=3: the selection logic grows as {@code 2^w} and overtakes the + * saving by w=5. + * + *

SOUNDNESS. The cheap incomplete mixed add cannot represent a pre-add accumulator equal to + * the addend, its negation, or the point at infinity. {@code cBuildJacobianAddOrDoubleInline}'s + * comment justifies using it everywhere but the last step of the BINARY ladder by an interval + * argument over {@code c_i mod n}, and insists that argument be re-derived by anything changing + * the offset or the iteration count. A comb changes both, so it is re-derived — as executable + * interval arithmetic in {@code Comb.combSafeRounds}, evaluated here. Rounds it cannot prove get + * the complete add-or-double form instead; nothing is assumed. For P-256 at w=3 it proves 81 of + * 86 rounds. + * + *

The other half of that argument is that the accumulator never starts at infinity, which + * needs the first digit non-zero. {@code Comb.combGeometry} searches for the scalar offset that + * guarantees it rather than reusing the ladder's hardcoded {@code +3n} — right for P-256 at w=3 + * and WRONG for P-384. + * + *

Stack in: [_k]. Stack out: [_result]. + * + * @return false when no geometry exists for {@code w} + */ + private static boolean cEmitCombMulGen(Consumer emit, int coordBytes, + ReverseBytesFn revFn, BigInteger fieldP, + BigInteger pMinus2, BigInteger curveN, + Comb.Curve curve, int w, + Ec.EcCodegenOptions opts) { + Comb.Params params = Comb.combGeometry(w, curve); + if (params == null) return false; + int d = params.d(); + List table = Comb.combTable(w, d, curve); + boolean[] safe = Comb.combSafeRounds(params, curve); + int entries = (1 << w) - 1; + + ECTracker t = new ECTracker(List.of("_k"), emit, opts, null); + t.poolConstant(Ec.POOL_FIELD_P, fieldP); + t.poolConstant(Ec.POOL_GROUP_N, curveN); + + // k' = (k mod n) + m*n. The reduce is what confines k to [0, n-1] and so + // what makes the interval argument apply at all; see cEmitScalarReduce. + t.toTop("_k"); + cEmitScalarReduce(t, "_k", "_kr", curveN); + t.rename("_k"); + for (int i = 0; i < params.offsetMultiple(); i++) { + String off = "_off" + i; + t.pushConst(Ec.POOL_GROUP_N, curveN, off); + t.rawBlock(List.of("_k", off), "_k", e -> e.accept(new OpcodeOp("OP_ADD"))); + } + t.setDomain("_k", Ec.Dom.NON_NEGATIVE); + + // Table, resident for the whole comb: picking an entry costs 2-3 bytes + // against a 34-byte literal push, and every round reads all of them. + for (int j = 1; j <= entries; j++) { + Comb.Point pt = table.get(j); + t.pushBigInt("_Tx" + j, pt.x()); + t.pushBigInt("_Ty" + j, pt.y()); + t.setDomain("_Tx" + j, Ec.Dom.REDUCED); + t.setDomain("_Ty" + j, Ec.Dom.REDUCED); + } + + // Round d-1 initialises the accumulator. The first digit is non-zero by + // construction (combGeometry), so this is a real point, never infinity. + Ec.combEmitSelect(t, d - 1, w, d); + t.toTop("_flag"); + t.drop(); + t.toTop("ax"); + t.rename("jx"); + t.toTop("ay"); + t.rename("jy"); + t.pushInt("jz", 1); + t.setDomain("jz", Ec.Dom.REDUCED); + + for (int i = d - 2; i >= 0; i--) { + cJacobianDouble(t, fieldP, pMinus2); + Ec.combEmitSelect(t, i, w, d); + + // cJacobianAddAffineBody documents its layout as + // [..., ax, ay, jx, jy, jz] and replaces the accumulator IN PLACE at + // the top. The selection leaves ax/ay above jz, so restore the + // contract before the branch — otherwise the add arm would reorder + // the stack and the empty else arm would not, leaving the two arms + // with different layouts at OP_ENDIF. + t.toTop("_flag"); + t.toAlt(); + t.toTop("jx"); + t.toTop("jy"); + t.toTop("jz"); + t.fromAlt("_flag"); + + t.popTracked(); // consumed by OP_IF + List addOps = new ArrayList<>(); + if (safe[i]) { + cBuildJacobianAddAffineInline(addOps::add, t, fieldP, pMinus2); + } else { + cBuildJacobianAddOrDoubleInline(addOps::add, t, fieldP, pMinus2); + } + emit.accept(new IfOp(addOps, List.of())); + + // The addend was selected fresh for this round; the add only copied it. + t.toTop("ay"); + t.drop(); + t.toTop("ax"); + t.drop(); + } + + cJacobianToAffine(t, "_rx", "_ry", fieldP, pMinus2); + + for (int j = entries; j >= 1; j--) { + t.toTop("_Ty" + j); + t.drop(); + t.toTop("_Tx" + j); + t.drop(); + } + t.toTop("_k"); + t.drop(); + + cComposePoint(t, "_rx", "_ry", "_result", coordBytes, revFn); + t.releaseConstant(Ec.POOL_GROUP_N); + t.releaseConstant(Ec.POOL_FIELD_P); + return true; + } + + /** + * Emit the cheapest comb over the candidate window widths. + * + *

Each candidate is rendered in full and scored with the same byte-cost model the emitter is + * measured by, and the smallest wins. + */ + private static List cEmitCombBest(int coordBytes, ReverseBytesFn revFn, + BigInteger fieldP, BigInteger pMinus2, + BigInteger curveN, Comb.Curve curve, + Ec.EcCodegenOptions opts) { + List best = null; + for (int w : new int[] {2, 3, 4}) { + List ops = new ArrayList<>(); + if (!cEmitCombMulGen(ops::add, coordBytes, revFn, fieldP, pMinus2, curveN, + curve, w, opts)) { + continue; + } + if (best == null + || CostModel.estimateScriptBytes(ops) < CostModel.estimateScriptBytes(best)) { + best = ops; + } + } + return best; + } + private static void cEmitVerifyECDSA(Consumer emit, int coordBytes, ReverseBytesFn revFn, BigInteger fieldP, BigInteger pMinus2, BigInteger curveN, BigInteger nMinus2, BigInteger curveB, - BigInteger sqrtExp, BigInteger gx, BigInteger gy) { - ECTracker t = new ECTracker(List.of("_msg", "_sig", "_pk"), emit); + BigInteger sqrtExp, BigInteger gx, BigInteger gy, + Comb.Curve combCurve, Ec.EcCodegenOptions opts) { + ECTracker t = new ECTracker(List.of("_msg", "_sig", "_pk"), emit, opts, null); + // The verifier does hundreds of reductions OUTSIDE the two ladders — + // decompression's sqrt ladder, cGroupInv, cAffineAdd, the final + // cGroupMod. Each ladder pools separately: cEmitMul runs on its own + // tracker that deliberately cannot see this stack, so it cannot reach + // this slot. + t.poolConstant(Ec.POOL_FIELD_P, fieldP); + t.poolConstant(Ec.POOL_GROUP_N, curveN); // Step 0: length gate. `_sig` and `_pk` are bare ByteString in the builtin // table and the type checker imposes no width, so both arrive attacker-sized. @@ -1259,8 +1502,8 @@ private static void cEmitVerifyECDSA(Consumer emit, int coordBytes, e.accept(new PushOp(PushValue.of(coordBytes))); e.accept(new OpcodeOp("OP_SPLIT")); }); - t.nm.add("_r_bytes"); - t.nm.add("_s_bytes"); + t.pushTracked("_r_bytes", Ec.Dom.UNKNOWN); + t.pushTracked("_s_bytes", Ec.Dom.UNKNOWN); // r_bytes → integer t.toTop("_r_bytes"); @@ -1316,7 +1559,17 @@ private static void cEmitVerifyECDSA(Consumer emit, int coordBytes, System.arraycopy(bigintToNBytes(gx, coordBytes), 0, gPointData, 0, coordBytes); System.arraycopy(bigintToNBytes(gy, coordBytes), 0, gPointData, coordBytes, coordBytes); - t.pushBytes("_G", gPointData); + // u1*G. G is a compile-time constant, so this half can use a fixed-base + // comb — one doubling and one add per COLUMN instead of per bit. u2*Q + // below cannot: Q arrives in the witness. + List combOps = null; + if (opts != null && opts.fixedBaseComb() && combCurve != null) { + combOps = cEmitCombBest(coordBytes, revFn, fieldP, pMinus2, curveN, combCurve, opts); + } + + if (combOps == null) { + t.pushBytes("_G", gPointData); + } t.toTop("_u1"); // Stash items on altstack. _input_ok goes DEEPEST — the altstack is LIFO @@ -1328,13 +1581,21 @@ private static void cEmitVerifyECDSA(Consumer emit, int coordBytes, t.toTop("_qx"); t.toAlt(); // Remove _G and _u1 from tracker before cEmitMul - t.nm.remove(t.nm.size() - 1); // _u1 - t.nm.remove(t.nm.size() - 1); // _G + // The multiply creates its own ECTracker and cannot see items below its + // operands. Remove them from ours. + t.popTracked(); // _u1 + if (combOps == null) { + t.popTracked(); // _G + } - cEmitMul(emit, coordBytes, revFn, fieldP, pMinus2, curveN, nMinus2); + if (combOps != null) { + for (StackOp op : combOps) emit.accept(op); + } else { + cEmitMul(emit, coordBytes, revFn, fieldP, pMinus2, curveN, nMinus2, opts); + } // After mul, one result point is on the stack - t.nm.add("_R1_point"); + t.pushTracked("_R1_point", Ec.Dom.UNKNOWN); // Pop qx/qy/u2 from altstack (LIFO) t.fromAlt("_qx"); @@ -1350,10 +1611,10 @@ private static void cEmitVerifyECDSA(Consumer emit, int coordBytes, t.toTop("_u2"); // Remove from tracker, emit mul, push result - t.nm.remove(t.nm.size() - 1); // _u2 - t.nm.remove(t.nm.size() - 1); // _Q_point - cEmitMul(emit, coordBytes, revFn, fieldP, pMinus2, curveN, nMinus2); - t.nm.add("_R2_point"); + t.popTracked(); // _u2 + t.popTracked(); // _Q_point + cEmitMul(emit, coordBytes, revFn, fieldP, pMinus2, curveN, nMinus2, opts); + t.pushTracked("_R2_point", Ec.Dom.UNKNOWN); // Restore R1 point t.fromAlt("_R1_point"); @@ -1403,6 +1664,8 @@ private static void cEmitVerifyECDSA(Consumer emit, int coordBytes, t.toTop("_sig_ok"); t.rawBlock(List.of("_input_ok", "_sig_ok"), "_result", e -> e.accept(new OpcodeOp("OP_BOOLAND"))); + t.releaseConstant(Ec.POOL_GROUP_N); + t.releaseConstant(Ec.POOL_FIELD_P); } // =================================================================== @@ -1410,36 +1673,69 @@ private static void cEmitVerifyECDSA(Consumer emit, int coordBytes, // =================================================================== public static void emitP256Add(Consumer emit) { - ECTracker t = new ECTracker(List.of("_pa", "_pb"), emit); + emitP256Add(emit, null); + } + + public static void emitP256Add(Consumer emit, Ec.EcCodegenOptions opts) { + ECTracker t = new ECTracker(List.of("_pa", "_pb"), emit, opts, null); + t.poolConstant(Ec.POOL_FIELD_P, P256_P); cDecomposePoint(t, "_pa", "px", "py", 32, REV32); cDecomposePoint(t, "_pb", "qx", "qy", 32, REV32); cAffineAdd(t, P256_P, P256_P_MINUS_2); cComposePoint(t, "rx", "ry", "_result", 32, REV32); + t.releaseConstant(Ec.POOL_FIELD_P); } public static void emitP256Mul(Consumer emit) { - cEmitMul(emit, 32, REV32, P256_P, P256_P_MINUS_2, P256_N, P256_N_MINUS_2); + emitP256Mul(emit, null); + } + + public static void emitP256Mul(Consumer emit, Ec.EcCodegenOptions opts) { + cEmitMul(emit, 32, REV32, P256_P, P256_P_MINUS_2, P256_N, P256_N_MINUS_2, opts); } public static void emitP256MulGen(Consumer emit) { + emitP256MulGen(emit, null); + } + + public static void emitP256MulGen(Consumer emit, Ec.EcCodegenOptions opts) { + if (opts != null && opts.fixedBaseComb()) { + List ops = + cEmitCombBest(32, REV32, P256_P, P256_P_MINUS_2, P256_N, Comb.P256_COMB_CURVE, opts); + if (ops != null) { + for (StackOp op : ops) emit.accept(op); + return; + } + } byte[] gPoint = new byte[64]; System.arraycopy(bigintToNBytes(P256_GX, 32), 0, gPoint, 0, 32); System.arraycopy(bigintToNBytes(P256_GY, 32), 0, gPoint, 32, 32); emit.accept(new PushOp(PushValue.ofHex(Ec.hexOf(gPoint)))); emit.accept(new SwapOp()); // [point, scalar] - emitP256Mul(emit); + emitP256Mul(emit, opts); } public static void emitP256Negate(Consumer emit) { - ECTracker t = new ECTracker(List.of("_pt"), emit); + emitP256Negate(emit, null); + } + + public static void emitP256Negate(Consumer emit, Ec.EcCodegenOptions opts) { + ECTracker t = new ECTracker(List.of("_pt"), emit, opts, null); + t.poolConstant(Ec.POOL_FIELD_P, P256_P); cDecomposePoint(t, "_pt", "_nx", "_ny", 32, REV32); cPushFieldP(t, "_fp", P256_P); cFieldSub(t, "_fp", "_ny", "_neg_y", P256_P); cComposePoint(t, "_nx", "_neg_y", "_result", 32, REV32); + t.releaseConstant(Ec.POOL_FIELD_P); } public static void emitP256OnCurve(Consumer emit) { - ECTracker t = new ECTracker(List.of("_pt"), emit); + emitP256OnCurve(emit, null); + } + + public static void emitP256OnCurve(Consumer emit, Ec.EcCodegenOptions opts) { + ECTracker t = new ECTracker(List.of("_pt"), emit, opts, null); + t.poolConstant(Ec.POOL_FIELD_P, P256_P); cDecomposePoint(t, "_pt", "_x", "_y", 32, REV32); cEmitCanonicityGuard(t, "_x", "_y", P256_P); @@ -1466,6 +1762,7 @@ public static void emitP256OnCurve(Consumer emit) { t.toTop("_curve_eq"); t.rawBlock(List.of("_canon", "_curve_eq"), "_result", e -> e.accept(new OpcodeOp("OP_BOOLAND"))); + t.releaseConstant(Ec.POOL_FIELD_P); } public static void emitP256EncodeCompressed(Consumer emit) { @@ -1493,9 +1790,13 @@ public static void emitP256EncodeCompressed(Consumer emit) { } public static void emitVerifyECDSA_P256(Consumer emit) { + emitVerifyECDSA_P256(emit, null); + } + + public static void emitVerifyECDSA_P256(Consumer emit, Ec.EcCodegenOptions opts) { cEmitVerifyECDSA(emit, 32, REV32, P256_P, P256_P_MINUS_2, P256_N, P256_N_MINUS_2, - P256_B, P256_SQRT_EXP, P256_GX, P256_GY); + P256_B, P256_SQRT_EXP, P256_GX, P256_GY, Comb.P256_COMB_CURVE, opts); } // =================================================================== @@ -1503,36 +1804,69 @@ public static void emitVerifyECDSA_P256(Consumer emit) { // =================================================================== public static void emitP384Add(Consumer emit) { - ECTracker t = new ECTracker(List.of("_pa", "_pb"), emit); + emitP384Add(emit, null); + } + + public static void emitP384Add(Consumer emit, Ec.EcCodegenOptions opts) { + ECTracker t = new ECTracker(List.of("_pa", "_pb"), emit, opts, null); + t.poolConstant(Ec.POOL_FIELD_P, P384_P); cDecomposePoint(t, "_pa", "px", "py", 48, REV48); cDecomposePoint(t, "_pb", "qx", "qy", 48, REV48); cAffineAdd(t, P384_P, P384_P_MINUS_2); cComposePoint(t, "rx", "ry", "_result", 48, REV48); + t.releaseConstant(Ec.POOL_FIELD_P); } public static void emitP384Mul(Consumer emit) { - cEmitMul(emit, 48, REV48, P384_P, P384_P_MINUS_2, P384_N, P384_N_MINUS_2); + emitP384Mul(emit, null); + } + + public static void emitP384Mul(Consumer emit, Ec.EcCodegenOptions opts) { + cEmitMul(emit, 48, REV48, P384_P, P384_P_MINUS_2, P384_N, P384_N_MINUS_2, opts); } public static void emitP384MulGen(Consumer emit) { + emitP384MulGen(emit, null); + } + + public static void emitP384MulGen(Consumer emit, Ec.EcCodegenOptions opts) { + if (opts != null && opts.fixedBaseComb()) { + List ops = + cEmitCombBest(48, REV48, P384_P, P384_P_MINUS_2, P384_N, Comb.P384_COMB_CURVE, opts); + if (ops != null) { + for (StackOp op : ops) emit.accept(op); + return; + } + } byte[] gPoint = new byte[96]; System.arraycopy(bigintToNBytes(P384_GX, 48), 0, gPoint, 0, 48); System.arraycopy(bigintToNBytes(P384_GY, 48), 0, gPoint, 48, 48); emit.accept(new PushOp(PushValue.ofHex(Ec.hexOf(gPoint)))); emit.accept(new SwapOp()); // [point, scalar] - emitP384Mul(emit); + emitP384Mul(emit, opts); } public static void emitP384Negate(Consumer emit) { - ECTracker t = new ECTracker(List.of("_pt"), emit); + emitP384Negate(emit, null); + } + + public static void emitP384Negate(Consumer emit, Ec.EcCodegenOptions opts) { + ECTracker t = new ECTracker(List.of("_pt"), emit, opts, null); + t.poolConstant(Ec.POOL_FIELD_P, P384_P); cDecomposePoint(t, "_pt", "_nx", "_ny", 48, REV48); cPushFieldP(t, "_fp", P384_P); cFieldSub(t, "_fp", "_ny", "_neg_y", P384_P); cComposePoint(t, "_nx", "_neg_y", "_result", 48, REV48); + t.releaseConstant(Ec.POOL_FIELD_P); } public static void emitP384OnCurve(Consumer emit) { - ECTracker t = new ECTracker(List.of("_pt"), emit); + emitP384OnCurve(emit, null); + } + + public static void emitP384OnCurve(Consumer emit, Ec.EcCodegenOptions opts) { + ECTracker t = new ECTracker(List.of("_pt"), emit, opts, null); + t.poolConstant(Ec.POOL_FIELD_P, P384_P); cDecomposePoint(t, "_pt", "_x", "_y", 48, REV48); cEmitCanonicityGuard(t, "_x", "_y", P384_P); @@ -1557,6 +1891,7 @@ public static void emitP384OnCurve(Consumer emit) { t.toTop("_curve_eq"); t.rawBlock(List.of("_canon", "_curve_eq"), "_result", e -> e.accept(new OpcodeOp("OP_BOOLAND"))); + t.releaseConstant(Ec.POOL_FIELD_P); } public static void emitP384EncodeCompressed(Consumer emit) { @@ -1579,9 +1914,13 @@ public static void emitP384EncodeCompressed(Consumer emit) { } public static void emitVerifyECDSA_P384(Consumer emit) { + emitVerifyECDSA_P384(emit, null); + } + + public static void emitVerifyECDSA_P384(Consumer emit, Ec.EcCodegenOptions opts) { cEmitVerifyECDSA(emit, 48, REV48, P384_P, P384_P_MINUS_2, P384_N, P384_N_MINUS_2, - P384_B, P384_SQRT_EXP, P384_GX, P384_GY); + P384_B, P384_SQRT_EXP, P384_GX, P384_GY, Comb.P384_COMB_CURVE, opts); } // =================================================================== @@ -1608,21 +1947,28 @@ public static boolean isVerifyEcdsaBuiltin(String name) { } public static void dispatch(String funcName, Consumer emit) { + dispatch(funcName, emit, null); + } + + public static void dispatch(String funcName, Consumer emit, + Ec.EcCodegenOptions opts) { switch (funcName) { - case "p256Add" -> emitP256Add(emit); - case "p256Mul" -> emitP256Mul(emit); - case "p256MulGen" -> emitP256MulGen(emit); - case "p256Negate" -> emitP256Negate(emit); - case "p256OnCurve" -> emitP256OnCurve(emit); + case "p256Add" -> emitP256Add(emit, opts); + case "p256Mul" -> emitP256Mul(emit, opts); + case "p256MulGen" -> emitP256MulGen(emit, opts); + case "p256Negate" -> emitP256Negate(emit, opts); + case "p256OnCurve" -> emitP256OnCurve(emit, opts); + // Pure byte shuffling with no field arithmetic: the flags cannot + // reach it, so it deliberately takes no options. case "p256EncodeCompressed" -> emitP256EncodeCompressed(emit); - case "p384Add" -> emitP384Add(emit); - case "p384Mul" -> emitP384Mul(emit); - case "p384MulGen" -> emitP384MulGen(emit); - case "p384Negate" -> emitP384Negate(emit); - case "p384OnCurve" -> emitP384OnCurve(emit); + case "p384Add" -> emitP384Add(emit, opts); + case "p384Mul" -> emitP384Mul(emit, opts); + case "p384MulGen" -> emitP384MulGen(emit, opts); + case "p384Negate" -> emitP384Negate(emit, opts); + case "p384OnCurve" -> emitP384OnCurve(emit, opts); case "p384EncodeCompressed" -> emitP384EncodeCompressed(emit); - case "verifyECDSA_P256" -> emitVerifyECDSA_P256(emit); - case "verifyECDSA_P384" -> emitVerifyECDSA_P384(emit); + case "verifyECDSA_P256" -> emitVerifyECDSA_P256(emit, opts); + case "verifyECDSA_P384" -> emitVerifyECDSA_P384(emit, opts); default -> throw new RuntimeException("unknown NIST EC builtin: " + funcName); } } diff --git a/compilers/java/src/main/java/runar/compiler/passes/Emit.java b/compilers/java/src/main/java/runar/compiler/passes/Emit.java index 2930ec602..ecbc20289 100644 --- a/compilers/java/src/main/java/runar/compiler/passes/Emit.java +++ b/compilers/java/src/main/java/runar/compiler/passes/Emit.java @@ -80,7 +80,12 @@ public record EmitResultWithSourceMap( // Opcode table // ------------------------------------------------------------------ - static final Map OPCODES = new HashMap<>(); + /** + * The opcode table. Public so {@code codegen.CostModel} can reject an + * unknown mnemonic loudly rather than costing it zero — a typo in a codegen + * module must not become a size report that is quietly wrong. + */ + public static final Map OPCODES = new HashMap<>(); static { OPCODES.put("OP_0", 0x00); OPCODES.put("OP_FALSE", 0x00); diff --git a/compilers/java/src/main/java/runar/compiler/passes/StackLower.java b/compilers/java/src/main/java/runar/compiler/passes/StackLower.java index cf3638f1e..67b25fb2a 100644 --- a/compilers/java/src/main/java/runar/compiler/passes/StackLower.java +++ b/compilers/java/src/main/java/runar/compiler/passes/StackLower.java @@ -588,6 +588,17 @@ static List collectRefs(AnfValue value) { // ------------------------------------------------------------------ public static StackProgram run(AnfProgram program) { + return run(program, null); + } + + /** + * {@code run} with the EXPERIMENTAL EC script-size options. + * + *

{@code null} keeps every EC emitter byte-identical to the shipping output; see {@code + * Ec.EcCodegenOptions} and docs/experiments/script-size-optimizer-results.md. + */ + public static StackProgram run( + AnfProgram program, runar.compiler.codegen.Ec.EcCodegenOptions ecCodegen) { Map privateMethods = new HashMap<>(); for (AnfMethod m : program.methods()) { if (!m.isPublic() && !"constructor".equals(m.name())) { @@ -598,7 +609,7 @@ public static StackProgram run(AnfProgram program) { List out = new ArrayList<>(); for (AnfMethod m : program.methods()) { if ("constructor".equals(m.name()) || !m.isPublic()) continue; - out.add(lowerMethod(m, program.properties(), privateMethods)); + out.add(lowerMethod(m, program.properties(), privateMethods, ecCodegen)); } return new StackProgram(program.contractName(), out); @@ -607,7 +618,8 @@ public static StackProgram run(AnfProgram program) { private static StackMethod lowerMethod( AnfMethod method, List properties, - Map privateMethods + Map privateMethods, + runar.compiler.codegen.Ec.EcCodegenOptions ecCodegen ) { List paramNames = new ArrayList<>(); for (AnfParam p : method.params()) paramNames.add(p.name()); @@ -637,6 +649,7 @@ private static StackMethod lowerMethod( } LoweringContext ctx = new LoweringContext(paramNames, properties, privateMethods); + ctx.ecCodegen = ecCodegen; ctx.lowerBindings(method.body(), method.isPublic()); // Strip excess stack items below the top-of-stack boolean (CLEANSTACK). @@ -780,6 +793,14 @@ static final class LoweringContext { // StackOp emitted while that binding lowers. The Emit pass walks // op.sourceLoc() to build the artifact's sourceMap. runar.compiler.ir.ast.SourceLocation currentSourceLoc; + /** + * EXPERIMENTAL EC size options (constant pool, sign lattice / reduction + * sinking, fixed-base comb), handed down to the EC and NIST curve + * emitters. {@code null} — not an all-false record — when nothing is + * enabled, so those emitters take their untouched default path and the + * emitted bytes are provably identical to the shipping ones. + */ + runar.compiler.codegen.Ec.EcCodegenOptions ecCodegen; LoweringContext(List params, List properties) { this.sm = new StackMap(params); @@ -900,6 +921,7 @@ private static runar.compiler.ir.stack.StackOp stampSourceLoc( LoweringContext subContext() { LoweringContext c = new LoweringContext(null, properties); + c.ecCodegen = this.ecCodegen; c.sm.slots.addAll(this.sm.slots); c.privateMethods = this.privateMethods; // Issue #130: a `load_param` for a shadowed param inside a branch @@ -1603,7 +1625,7 @@ void lowerEcBuiltin(String bindingName, String funcName, List args, } for (int i = 0; i < args.size(); i++) sm.pop(); - runar.compiler.codegen.Ec.dispatch(funcName, this::emitOp); + runar.compiler.codegen.Ec.dispatch(funcName, this::emitOp, ecCodegen); sm.push(bindingName); trackDepth(); @@ -1622,7 +1644,7 @@ void lowerNistEcBuiltin(String bindingName, String funcName, List args, } for (int i = 0; i < args.size(); i++) sm.pop(); - runar.compiler.codegen.P256P384.dispatch(funcName, this::emitOp); + runar.compiler.codegen.P256P384.dispatch(funcName, this::emitOp, ecCodegen); sm.push(bindingName); trackDepth(); diff --git a/compilers/java/src/test/java/runar/compiler/codegen/EcFlagParityTest.java b/compilers/java/src/test/java/runar/compiler/codegen/EcFlagParityTest.java new file mode 100644 index 000000000..c0ff594fc --- /dev/null +++ b/compilers/java/src/test/java/runar/compiler/codegen/EcFlagParityTest.java @@ -0,0 +1,228 @@ +package runar.compiler.codegen; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import org.junit.jupiter.api.Test; +import runar.compiler.ir.stack.StackMethod; +import runar.compiler.ir.stack.StackOp; +import runar.compiler.ir.stack.StackProgram; +import runar.compiler.passes.Emit; + +/** + * Cross-tier parity for the EXPERIMENTAL EC size flags. + * + *

The flags default off, so the ordinary conformance suite — which compiles with defaults — + * cannot see them at all. Seven tiers could each ship a DIFFERENT {@code --ec-constant-pool} and the + * suite would stay green. + * + *

That matters because the flags are not cosmetic: they change which reduction form is emitted + * and which addition formula each ladder round uses. A tier that ports the constant pool but not the + * sign lattice's {@code REDUCED} precondition produces a script that is smaller, passes its own + * tests, and is wrong on {@code ecAdd((0,1), (2^256-1,1))}. Byte-identical output against a single + * reference is the only cheap check that catches that. + * + *

{@code conformance/ec-flag-parity/expected.json} is derived from the TypeScript reference + * compiler and re-derived by its own vitest, so it cannot go stale. + */ +class EcFlagParityTest { + + /** Emitters the flags cannot reach still take an (ignored) options argument here. + * + *

They are deliberately included: a tier that accidentally made {@code ecModReduce} or + * {@code ecPointX} flag-sensitive would be diverging just as badly as one that ignored a flag. + */ + private static BiConsumer, Ec.EcCodegenOptions> ignoreOpts( + Consumer> f) { + return (e, o) -> f.accept(e); + } + + private static Map, Ec.EcCodegenOptions>> emitters() { + Map, Ec.EcCodegenOptions>> m = new LinkedHashMap<>(); + m.put("EcAdd", Ec::emitEcAdd); + m.put("EcMul", Ec::emitEcMul); + m.put("EcMulGen", Ec::emitEcMulGen); + m.put("EcNegate", Ec::emitEcNegate); + m.put("EcOnCurve", Ec::emitEcOnCurve); + m.put("EcModReduce", ignoreOpts(Ec::emitEcModReduce)); + m.put("EcEncodeCompressed", ignoreOpts(Ec::emitEcEncodeCompressed)); + m.put("EcMakePoint", ignoreOpts(Ec::emitEcMakePoint)); + m.put("EcPointX", ignoreOpts(Ec::emitEcPointX)); + m.put("EcPointY", ignoreOpts(Ec::emitEcPointY)); + m.put("P256Add", P256P384::emitP256Add); + m.put("P256Mul", P256P384::emitP256Mul); + m.put("P256MulGen", P256P384::emitP256MulGen); + m.put("P256Negate", P256P384::emitP256Negate); + m.put("P256OnCurve", P256P384::emitP256OnCurve); + m.put("P256EncodeCompressed", ignoreOpts(P256P384::emitP256EncodeCompressed)); + m.put("VerifyECDSA_P256", P256P384::emitVerifyECDSA_P256); + m.put("P384Add", P256P384::emitP384Add); + m.put("P384Mul", P256P384::emitP384Mul); + m.put("P384MulGen", P256P384::emitP384MulGen); + m.put("P384Negate", P256P384::emitP384Negate); + m.put("P384OnCurve", P256P384::emitP384OnCurve); + m.put("P384EncodeCompressed", ignoreOpts(P256P384::emitP384EncodeCompressed)); + m.put("VerifyECDSA_P384", P256P384::emitVerifyECDSA_P384); + return m; + } + + /** The four flag combinations the fixture pins, in its own order. */ + private static final String[] VARIANTS = {"off", "pool", "sink", "comb"}; + + private static Ec.EcCodegenOptions optionsFor(String variant) { + return switch (variant) { + case "off" -> null; + case "pool" -> new Ec.EcCodegenOptions(true, false, false); + case "sink" -> new Ec.EcCodegenOptions(true, true, false); + case "comb" -> new Ec.EcCodegenOptions(true, true, true); + default -> throw new IllegalArgumentException("unknown variant " + variant); + }; + } + + private static Path fixturePath() { + // src/test/java/runar/compiler/codegen -> compilers/java -> compilers -> repo root + return Path.of(System.getProperty("user.dir")) + .resolve("../../conformance/ec-flag-parity/expected.json") + .normalize(); + } + + /** + * Pull {@code {"bytes": N, "sha256": "..."}} for one (emitter, variant) out of the fixture. + * + *

Hand-rolled rather than pulling in a JSON dependency: this module has none, and the shape + * is a fixed two-level map written by {@code JSON.stringify(..., 2)}. The scan is anchored on + * the emitter's key so {@code EcMul} cannot match inside {@code EcMulGen}. + */ + private static int[] lookupBytes(String json, String emitter, String variant) { + int at = json.indexOf("\"" + emitter + "\": {"); + if (at < 0) { + throw new AssertionError(emitter + ": no entry in the parity fixture"); + } + int vAt = json.indexOf("\"" + variant + "\": {", at); + if (vAt < 0) { + throw new AssertionError(emitter + "/" + variant + ": no entry in the parity fixture"); + } + int bAt = json.indexOf("\"bytes\":", vAt); + int comma = json.indexOf(',', bAt); + int bytes = Integer.parseInt(json.substring(bAt + 8, comma).trim()); + return new int[] {bytes, vAt}; + } + + private static String lookupHash(String json, String emitter, String variant) { + int vAt = lookupBytes(json, emitter, variant)[1]; + int hAt = json.indexOf("\"sha256\":", vAt); + int q1 = json.indexOf('"', hAt + 9); + int q2 = json.indexOf('"', q1 + 1); + return json.substring(q1 + 1, q2); + } + + private static int[] emitAndSize( + BiConsumer, Ec.EcCodegenOptions> fn, Ec.EcCodegenOptions opts) { + List ops = new ArrayList<>(); + fn.accept(ops::add, opts); + String hex = + Emit.run(new StackProgram("t", List.of(new StackMethod("t", ops, 0L)))); + return new int[] {hex.length() / 2}; + } + + private static String emitAndHash( + BiConsumer, Ec.EcCodegenOptions> fn, Ec.EcCodegenOptions opts) { + List ops = new ArrayList<>(); + fn.accept(ops::add, opts); + String hex = + Emit.run(new StackProgram("t", List.of(new StackMethod("t", ops, 0L)))); + byte[] raw = new byte[hex.length() / 2]; + for (int i = 0; i < raw.length; i++) { + raw[i] = (byte) Integer.parseInt(hex.substring(i * 2, i * 2 + 2), 16); + } + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(raw); + StringBuilder sb = new StringBuilder(); + for (byte b : digest) sb.append(String.format("%02x", b)); + return sb.toString(); + } catch (Exception ex) { + throw new RuntimeException(ex); + } + } + + @Test + void ecFlagParityAgainstTypeScriptReference() throws Exception { + String json = Files.readString(fixturePath()); + for (Map.Entry, Ec.EcCodegenOptions>> en : + emitters().entrySet()) { + String name = en.getKey(); + for (String variant : VARIANTS) { + Ec.EcCodegenOptions opts = optionsFor(variant); + int wantBytes = lookupBytes(json, name, variant)[0]; + String wantHash = lookupHash(json, name, variant); + int gotBytes = emitAndSize(en.getValue(), opts)[0]; + String gotHash = emitAndHash(en.getValue(), opts); + assertEquals( + wantBytes, + gotBytes, + name + " under " + variant + ": Java and the TypeScript reference disagree" + + " on size"); + assertEquals( + wantHash, + gotHash, + name + " under " + variant + ": Java and the TypeScript reference disagree" + + " on bytes"); + } + } + } + + /** + * A {@code null} options argument must be byte-identical to the shipping output. This is what + * keeps the existing goldens, the size baseline and every cross-tier hex comparison from moving + * while the flags are experimental. + */ + @Test + void ecFlagsDefaultOffIsByteIdentical() throws Exception { + String json = Files.readString(fixturePath()); + for (Map.Entry, Ec.EcCodegenOptions>> en : + emitters().entrySet()) { + String name = en.getKey(); + String noneHash = emitAndHash(en.getValue(), null); + String offHash = + emitAndHash(en.getValue(), new Ec.EcCodegenOptions(false, false, false)); + assertEquals(noneHash, offHash, name + ": null and all-false options disagree"); + assertEquals( + lookupHash(json, name, "off"), noneHash, name + ": default output moved"); + } + } + + /** + * The fixture must actually be reachable, and must really carry all four variants for a + * flag-sensitive emitter — otherwise both tests above could pass vacuously. + */ + @Test + void fixtureIsPresentAndNonVacuous() throws Exception { + String json = Files.readString(fixturePath()); + assertNotNull(json); + // The reference genuinely diverges under the flags; a fixture where every + // variant hashed the same would pass in a tier that ignored them entirely. + assertNotEquals( + lookupHash(json, "EcMul", "off"), + lookupHash(json, "EcMul", "pool"), + "fixture is vacuous: the pool flag does not move EcMul"); + assertNotEquals( + lookupHash(json, "EcMulGen", "sink"), + lookupHash(json, "EcMulGen", "comb"), + "fixture is vacuous: the comb flag does not move EcMulGen"); + // `ecMul` takes its base at run time, so the comb cannot apply there. + assertEquals( + lookupHash(json, "EcMul", "sink"), + lookupHash(json, "EcMul", "comb"), + "the comb must not fire where the base is not a compile-time constant"); + } +} diff --git a/compilers/python/runar_compiler/__main__.py b/compilers/python/runar_compiler/__main__.py index 46b2af684..a3cf3f07e 100644 --- a/compilers/python/runar_compiler/__main__.py +++ b/compilers/python/runar_compiler/__main__.py @@ -115,6 +115,21 @@ def main() -> None: action="store_true", help="Disable the ANF constant folding pass", ) + parser.add_argument( + "--ec-constant-pool", + action="store_true", + help="EXPERIMENTAL: pool repeated EC curve constants (changes emitted bytes)", + ) + parser.add_argument( + "--ec-reduction-sinking", + action="store_true", + help="EXPERIMENTAL: drop provably-dead sign fix-ups from EC modular reductions", + ) + parser.add_argument( + "--ec-fixed-base-comb", + action="store_true", + help="EXPERIMENTAL: comb multiplication where the base point is a compile-time constant", + ) parser.add_argument( "--emit-source-map", dest="emit_source_map", @@ -222,6 +237,9 @@ def main() -> None: artifact = compile_from_source( args.source, disable_constant_folding=args.disable_constant_folding, + ec_constant_pool=args.ec_constant_pool, + ec_reduction_sinking=args.ec_reduction_sinking, + ec_fixed_base_comb=args.ec_fixed_base_comb, ) else: artifact = compile_from_ir( diff --git a/compilers/python/runar_compiler/codegen/comb.py b/compilers/python/runar_compiler/codegen/comb.py new file mode 100644 index 000000000..a4fd3a69e --- /dev/null +++ b/compilers/python/runar_compiler/codegen/comb.py @@ -0,0 +1,279 @@ +"""Fixed-base comb: compile-time table, and the soundness check that decides +where the cheap incomplete addition may be used. + +Port of ``packages/runar-compiler/src/passes/comb.ts``. The binary ladders in +``ec.py`` / ``p256_p384.py`` use the cheap mixed add at every step but the last, +justified by an interval argument over ``c_i mod n``. That comment is emphatic +that the argument must be RE-DERIVED, not assumed, by anything which changes +the offset, the iteration count, or the reduce -- and a comb changes all three. +``comb_safe_rounds`` below is that re-derivation, written as executable interval +arithmetic rather than prose, so a round only gets the cheap add when the +exception is proved unreachable. Rounds it cannot prove fall back to the +complete add-or-double form. + +Nothing here emits Script. It is pure integer arithmetic, run once per +compilation, and unit-tested against published curve vectors. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class CombPoint: + """An affine point. ``None`` (not an instance) is the point at infinity.""" + + x: int + y: int + + +@dataclass(frozen=True) +class CombCurve: + """A short-Weierstrass curve, for the compile-time table.""" + + p: int + """Field prime.""" + a: int + """Curve coefficient a: -3 on the NIST curves, 0 on secp256k1.""" + b: int + """Curve coefficient b.""" + n: int + """Group order.""" + g: CombPoint + """Base point.""" + + +@dataclass(frozen=True) +class CombParams: + """Comb geometry for one window width, chosen so the top digit is never zero. + + The binary ladder hardcodes ``k + 3n``, which puts the scalar's top bit at a + fixed position and so keeps the accumulator off the point at infinity. A + comb needs the same guarantee, but its first round reads bit ``w*d - 1``, so + the offset has to be chosen against ``w*d`` rather than assumed. + ``offset_multiple`` is the smallest ``m`` for which every ``k + m*n`` has bit + ``w*d - 1`` set:: + + m*n >= 2^(w*d - 1) and (m+1)*n - 1 < 2^(w*d) + + ``m*n == 0 (mod n)`` so the result is unchanged. For P-256 at w=3 the search + returns m=3, d=86 -- i.e. exactly the ``+3n`` the binary ladder already uses. + For P-384 at w=3 it returns m=5, d=129; assuming ``+3n`` there would have + left the top digit free to be zero. + """ + + w: int + d: int + """Rounds, and the block width. Digit ``i`` reads bits i, i+d, ..., i+(w-1)d.""" + offset_multiple: int + lo: int + """Inclusive scalar domain after the offset.""" + hi: int + + +# secp256k1 is NOT built from the NIST template: it is y^2 = x^3 + 7, so a = 0. +# Getting `a` wrong here does not produce an obviously broken table -- it +# produces a table of points on a DIFFERENT curve, which that other curve's +# on-curve check would happily accept. Hence the published 2G vectors pinned in +# the tests. +P256_COMB_CURVE = CombCurve( + p=int("ffffffff00000001000000000000000000000000ffffffffffffffffffffffff", 16), + a=-3, + b=int("5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b", 16), + n=int("ffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551", 16), + g=CombPoint( + x=int("6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296", 16), + y=int("4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5", 16), + ), +) + +P384_COMB_CURVE = CombCurve( + p=int( + "fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe" + "ffffffff0000000000000000ffffffff", 16), + a=-3, + b=int( + "b3312fa7e23ee7e4988e056be3f82d19181d9c6efe8141120314088f5013875a" + "c656398d8a2ed19d2a85c8edd3ec2aef", 16), + n=int( + "ffffffffffffffffffffffffffffffffffffffffffffffffc7634d81f4372ddf" + "581a0db248b0a77aecec196accc52973", 16), + g=CombPoint( + x=int( + "aa87ca22be8b05378eb1c71ef320ad746e1d3b628ba79b9859f741e082542a38" + "5502f25dbf55296c3a545e3872760ab7", 16), + y=int( + "3617de4a96262c6f5d9e98bf9292dc29f8f41dbd289a147ce9da3113b5f0b8c0" + "0a60b1ce1d7e819d7a431d7c90ea0e5f", 16), + ), +) + +SECP256K1_COMB_CURVE = CombCurve( + p=int("fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f", 16), + a=0, + b=7, + n=int("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141", 16), + g=CombPoint( + x=int("79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", 16), + y=int("483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8", 16), + ), +) + + +def comb_geometry(w: int, c: CombCurve) -> CombParams | None: + """Geometry for window width *w*, or ``None`` if no offset in the search + range puts a guaranteed set bit at the top of the first digit. + + Returning ``None`` rather than guessing keeps the caller from silently + combing a scalar whose leading digit can vanish. + """ + base = -(-c.n.bit_length() // w) # ceil + for d in range(base, base + 3): + bits = w * d + top = 1 << (bits - 1) + cap = 1 << bits + for m in range(1, 17): + lo = m * c.n + hi = (m + 1) * c.n - 1 + if lo >= top and hi < cap: + return CombParams(w=w, d=d, offset_multiple=m, lo=lo, hi=hi) + return None + + +# --------------------------------------------------------------------------- +# Affine arithmetic (compile time only) +# --------------------------------------------------------------------------- + +def comb_affine_add(p: CombPoint | None, q: CombPoint | None, c: CombCurve) -> CombPoint | None: + """Affine addition. ``None`` is the point at infinity.""" + if p is None: + return q + if q is None: + return p + if p.x == q.x: + if (p.y + q.y) % c.p == 0: + return None # P == -Q + # Tangent. + num = (3 * p.x * p.x + c.a) % c.p + lam = (num * pow(2 * p.y % c.p, -1, c.p)) % c.p + x = (lam * lam - 2 * p.x) % c.p + return CombPoint(x=x, y=(lam * (p.x - x) - p.y) % c.p) + lam = ((q.y - p.y) % c.p * pow((q.x - p.x) % c.p, -1, c.p)) % c.p + x = (lam * lam - p.x - q.x) % c.p + return CombPoint(x=x, y=(lam * (p.x - x) - p.y) % c.p) + + +def comb_scalar_mul(k: int, p: CombPoint, c: CombCurve) -> CombPoint | None: + """Compile-time double-and-add. ``None`` is the point at infinity.""" + r: CombPoint | None = None + base: CombPoint | None = p + e = k % c.n + while e > 0: + if e & 1: + r = comb_affine_add(r, base, c) + base = comb_affine_add(base, base, c) + e >>= 1 + return r + + +# --------------------------------------------------------------------------- +# Comb table +# --------------------------------------------------------------------------- + +def comb_value(j: int, d: int) -> int: + """The multiple of G that table entry *j* represents. + + Comb round ``i`` consumes bits ``{i, i+d, i+2d, ...}`` of the scalar -- one + from each block -- so entry ``j`` stands for the sum of ``2^(t*d)`` over the + set bits ``t`` of ``j``. + """ + v = 0 + t = 0 + while (j >> t) != 0: + if (j >> t) & 1: + v += 1 << (t * d) + t += 1 + return v + + +def comb_table(w: int, d: int, c: CombCurve) -> list[CombPoint | None]: + """``T[j] = comb_value(j)*G``. Index 0 is infinity and is never added.""" + return [None if j == 0 else comb_scalar_mul(comb_value(j, d), c.g, c) + for j in range(1 << w)] + + +# --------------------------------------------------------------------------- +# Soundness: where may the cheap incomplete addition be used? +# --------------------------------------------------------------------------- + +def _accumulator_interval(i: int, params: CombParams) -> tuple[int, int]: + """Bounds on the comb accumulator's multiplier before round *i*'s doubling. + + After processing rounds ``d-1 .. i``, the accumulator is ``c_i*G`` with:: + + c_i = sum_m 2^(m*d) * floor(K_m / 2^i) + + where ``K_m`` is the m-th ``d``-bit block of the expanded scalar. Each floor + discards less than one unit of its block, so:: + + k/2^i - sum_m 2^(m*d) < c_i <= k/2^i + + and with ``k`` confined to ``[lo, hi]`` that gives a contiguous interval. The + slack term is bounded by ``2^(w*d)/(2^d - 1)``, far below ``n``, which is why + the interval stays narrower than the group order for all but the last few + rounds -- exactly the property the binary ladder's argument relies on. + """ + slack = sum(1 << (m * params.d) for m in range(params.w)) + hi = params.hi >> i + lo = (params.lo >> i) - slack + return (max(lo, 0), hi) + + +def _interval_hits_residue(lo: int, hi: int, target: int, n: int) -> bool: + """Does ``[lo, hi]`` contain an integer congruent to *target* modulo *n*?""" + if hi < lo: + return False + if hi - lo + 1 >= n: + return True # wraps a full residue class + t = target % n + # Smallest value >= lo that is congruent to t (mod n). + first = lo + (t - lo) % n + return first <= hi + + +def comb_safe_rounds(params: CombParams, c: CombCurve) -> list[bool]: + """Per-round verdict: may round *i* use the cheap incomplete mixed add? + + The exception the cheap formula cannot represent is a pre-add accumulator + equal to the addend, its negation, or the point at infinity. After round + ``i``'s doubling the accumulator is ``2*c_{i+1}*G``, and the addend is + ``comb_value(j)*G`` for whichever digit ``j`` the scalar selects -- so the + round is safe exactly when, for every ``j``:: + + 2*c_{i+1} != 0, +comb_value(j), -comb_value(j) (mod n) + + over the whole interval of ``c_{i+1}``. Both ``G`` and every table entry are + compile-time constants and the curves have cofactor 1, so ``ord(G) = n`` and + this is decidable here. Anything the checker cannot prove gets the complete + add-or-double form instead; ``True`` is never assumed. + + Index ``d-1`` is ``False`` by construction: that round initialises the + accumulator from the table and performs no addition at all. + """ + values = [comb_value(j, params.d) for j in range(1, 1 << params.w)] + + safe = [False] * params.d + for i in range(params.d): + if i == params.d - 1: + continue + lo, hi = _accumulator_interval(i + 1, params) + d_lo, d_hi = 2 * lo, 2 * hi + ok = not _interval_hits_residue(d_lo, d_hi, 0, c.n) + for v in values: + if not ok: + break + ok = (not _interval_hits_residue(d_lo, d_hi, v, c.n) + and not _interval_hits_residue(d_lo, d_hi, -v, c.n)) + safe[i] = ok + return safe diff --git a/compilers/python/runar_compiler/codegen/cost_model.py b/compilers/python/runar_compiler/codegen/cost_model.py new file mode 100644 index 000000000..4d5f9c9a4 --- /dev/null +++ b/compilers/python/runar_compiler/codegen/cost_model.py @@ -0,0 +1,95 @@ +"""Script-byte cost model for Stack IR. + +Port of ``packages/runar-compiler/src/metrics/cost-model.ts``. Optimizer passes +need to compare two candidate lowerings by the metric that actually matters -- +serialized locking-script bytes -- before either one is emitted. ``OP_DUP`` and +a 33-byte constant push are one instruction each and 1 vs 34 bytes; an +instruction count cannot tell them apart. + +This is deliberately NOT an approximation: every push routes through the same +encoders ``emit.py`` uses, so:: + + estimate_script_bytes(ops) == len(emit_method(...).script_hex) // 2 + +holds exactly. ``test_cost_model.py`` asserts that over every crypto emitter. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from runar_compiler.codegen.stack import PushValue, StackOp + + +def size_of_push_value(value: "PushValue") -> int: + """Serialized byte cost of a single push value. + + Mirrors ``encode_push_value`` in ``emit.py``: booleans are the 1-byte + OP_TRUE / OP_FALSE, integers go through the small-int opcodes where + possible, and byte strings are MINIMALDATA-aware before falling back to a + length-prefixed push. + """ + from runar_compiler.codegen.emit import encode_push_value + + hex_str, _ = encode_push_value(value) + return len(hex_str) // 2 + + +def size_of_push_int(n: int) -> int: + """``size_of_push_value`` for a bare integer. + + This is what the constant pool and the comb width search compare against. + """ + from runar_compiler.codegen.stack import big_int_push + + return size_of_push_value(big_int_push(n)) + + +def size_of_stack_op(op: "StackOp") -> int: + """Serialized byte cost of one Stack IR operation, including nested arms. + + Note on ``pick`` / ``roll``: they cost ONE byte here. The depth operand is a + separate ``push`` op that the tracker emits immediately before, so charging + the depth here would double-count it. + + Raises on an unknown opcode mnemonic rather than costing it zero -- a typo + in a codegen module should surface loudly, not as a cost model that quietly + under-reports. + """ + from runar_compiler.codegen.emit import OPCODES + + kind = op.op + if kind == "push": + return size_of_push_value(op.value) + + if kind in ("dup", "swap", "roll", "pick", "drop", "nip", "over", "rot", "tuck"): + return 1 + + if kind == "opcode": + if OPCODES.get(op.code) is None: + raise RuntimeError(f"cost-model: unknown opcode '{op.code}'") + return 1 + + if kind == "if": + # OP_IF + then + [OP_ELSE + else] + OP_ENDIF. The emitter writes + # OP_ELSE only for a NON-EMPTY else arm. + total = 2 + total += estimate_script_bytes(op.then or []) + if op.else_ops: + total += 1 + estimate_script_bytes(op.else_ops) + return total + + if kind in ("placeholder", "push_codesep_index"): + # Both emit a single 0x00 byte that the SDK rewrites later. + return 1 + + if kind == "raw_bytes": + return len(op.raw_bytes or b"") + + raise RuntimeError(f"cost-model: unknown stack op kind '{kind}'") + + +def estimate_script_bytes(ops: list["StackOp"]) -> int: + """Serialized byte cost of a Stack IR sequence.""" + return sum(size_of_stack_op(op) for op in ops) diff --git a/compilers/python/runar_compiler/codegen/ec.py b/compilers/python/runar_compiler/codegen/ec.py index 56d025f71..6fb812208 100644 --- a/compilers/python/runar_compiler/codegen/ec.py +++ b/compilers/python/runar_compiler/codegen/ec.py @@ -11,6 +11,8 @@ from __future__ import annotations +from dataclasses import dataclass +from enum import IntEnum from typing import Callable, TYPE_CHECKING if TYPE_CHECKING: @@ -32,6 +34,9 @@ # secp256k1 generator y-coordinate EC_GEN_Y: int = int("483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8", 16) +# secp256k1 curve order +EC_CURVE_N: int = int("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141", 16) + def _bigint_to_bytes32(n: int) -> bytes: """Convert an int to a 32-byte big-endian byte string.""" @@ -63,6 +68,85 @@ def _big_int_push(n: int) -> "PushValue": return big_int_push(n) +# =========================================================================== +# Codegen options and sign lattice +# =========================================================================== + +@dataclass(frozen=True) +class EcCodegenOptions: + """Codegen options shared by every EC / NIST-curve emitter. + + Off by default: with ``None`` (or an all-false instance) each emitter is + byte-identical to what the seven tiers ship today, so no golden, size + baseline, or cross-tier parity gate can move. + """ + + constant_pool: bool = False + """Park large repeated constants (the field prime, the group order) in a + stack slot and copy them with ``OP_PICK`` instead of re-pushing the literal. + + ``_ec_field_mod`` pushes the 256-bit prime at every modular reduction -- 34 + bytes a time, 20,025 times in ``p256-wallet`` (71 % of that fixture). A pick + from a slot a dozen deep costs 2. + """ + + reduction_sinking: bool = False + """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. + + Which reductions qualify is decided by the sign lattice below -- never + assumed. Only useful alongside ``constant_pool``: the cheap subtraction + references the prime twice, so without a pooled slot it does not pay (and the + emitters compare the two costs, so it is never taken when it does not). + """ + + fixed_base_comb: bool = False + """Use a fixed-base comb instead of the binary ladder wherever the base point + is a compile-time constant (``ecMulGen``, ``p256MulGen``, ``p384MulGen``, and + the ``u1*G`` half of ECDSA verification). + + The window width is not fixed here: the emitter renders each candidate and + keeps whichever the byte-cost model scores smallest. + """ + + +class Dom(IntEnum): + """What is known about a tracked value's sign and range. + + ``REDUCED`` implies ``NON_NEGATIVE``; 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 + ``raw_block`` 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 ``NON_NEGATIVE`` 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 ``NON_NEGATIVE``; 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))``. + """ + + UNKNOWN = 0 + """Nothing known. May be negative.""" + NON_NEGATIVE = 1 + """Provably >= 0. May be >= p.""" + REDUCED = 2 + """Provably in [0, p).""" + + +def is_non_negative(d: Dom) -> bool: + """True when *d* proves the value is >= 0.""" + return d >= Dom.NON_NEGATIVE + + +# Stack slot names reserved for pooled constants. +POOL_FIELD_P = "_pool$p" +POOL_GROUP_N = "_pool$n" + + # =========================================================================== # ECTracker -- named stack state tracker (mirrors TS ECTracker) # =========================================================================== @@ -70,9 +154,86 @@ def _big_int_push(n: int) -> "PushValue": class ECTracker: """Tracks named stack positions and emits StackOps for EC codegen.""" - def __init__(self, init: list[str], emit: Callable[["StackOp"], None]) -> None: + def __init__( + self, + init: list[str], + emit: Callable[["StackOp"], None], + opts: "EcCodegenOptions | None" = None, + init_domains: "list[Dom] | None" = None, + ) -> None: self.nm: list[str] = list(init) + # 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 dict 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. + self.dm: list[Dom] = (list(init_domains) if init_domains is not None + else [Dom.UNKNOWN] * len(self.nm)) + # Lattice facts for values parked on the alt stack, bottom -> top. + self.alt_dm: list[Dom] = [] self.e = emit + o = opts or EcCodegenOptions() + self.pooling = o.constant_pool + self.sinking = o.reduction_sinking + self.comb = o.fixed_base_comb + + @property + def options(self) -> EcCodegenOptions: + """The options this tracker was built with, for a nested tracker.""" + return EcCodegenOptions( + constant_pool=self.pooling, + reduction_sinking=self.sinking, + fixed_base_comb=self.comb, + ) + + # -- sign lattice ------------------------------------------------------- + + def domain_of(self, name: str) -> Dom: + """What is known about *name*. ``UNKNOWN`` when absent.""" + # 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 len(self.dm) != len(self.nm): + raise RuntimeError( + f"ECTracker: lattice desynchronised ({len(self.nm)} slots, " + f"{len(self.dm)} facts). Every nm mutation must go through a " + "tracker method or push_tracked/pop_tracked." + ) + for i in range(len(self.nm) - 1, -1, -1): + if self.nm[i] == name: + return self.dm[i] + return Dom.UNKNOWN + + def set_domain(self, name: str, d: Dom) -> None: + """Record a fact about *name*'s slot.""" + for i in range(len(self.nm) - 1, -1, -1): + if self.nm[i] == name: + self.dm[i] = d + return + + def push_tracked(self, name: str, d: Dom = Dom.UNKNOWN) -> None: + """Push a slot the caller tracks itself (where raw opcodes create items).""" + self.nm.append(name) + self.dm.append(d) + + def pop_tracked(self) -> str: + """Pop a slot the caller tracks itself. Mirror of ``push_tracked``.""" + if not self.nm: + return "" + self.dm.pop() + return self.nm.pop() + + def remove_slot_at(self, index: int) -> tuple[str, Dom]: + """Remove the slot at an absolute (bottom-relative) index.""" + n = self.nm.pop(index) + d = self.dm.pop(index) + return (n, d) + + @property + def depth(self) -> int: + return len(self.nm) def find_depth(self, name: str) -> int: for i in range(len(self.nm) - 1, -1, -1): @@ -82,48 +243,48 @@ def find_depth(self, name: str) -> int: def push_bytes(self, n: str, v: bytes) -> None: self.e(_make_stack_op(op="push", value=_make_push_value(kind="bytes", bytes_=v))) - self.nm.append(n) + # A byte blob is not a number until BIN2NUM decides how to read it. + self.push_tracked(n, Dom.UNKNOWN) def push_big_int(self, n: str, v: int) -> None: self.e(_make_stack_op(op="push", value=_make_push_value(kind="bigint", big_int=v))) - self.nm.append(n) + self.push_tracked(n, Dom.NON_NEGATIVE if v >= 0 else Dom.UNKNOWN) def push_int(self, n: str, v: int) -> None: self.e(_make_stack_op(op="push", value=_big_int_push(v))) - self.nm.append(n) + self.push_tracked(n, Dom.NON_NEGATIVE if v >= 0 else Dom.UNKNOWN) def dup(self, n: str) -> None: self.e(_make_stack_op(op="dup")) - self.nm.append(n) + self.push_tracked(n, self.dm[-1] if self.dm else Dom.UNKNOWN) def drop(self) -> None: self.e(_make_stack_op(op="drop")) - if self.nm: - self.nm.pop() + self.pop_tracked() def nip(self) -> None: self.e(_make_stack_op(op="nip")) L = len(self.nm) if L >= 2: - self.nm[L - 2:L] = [self.nm[L - 1]] + self.remove_slot_at(L - 2) def over(self, n: str) -> None: self.e(_make_stack_op(op="over")) - self.nm.append(n) + self.push_tracked(n, self.dm[-2] if len(self.dm) >= 2 else Dom.UNKNOWN) def swap(self) -> None: self.e(_make_stack_op(op="swap")) L = len(self.nm) if L >= 2: self.nm[L - 1], self.nm[L - 2] = self.nm[L - 2], self.nm[L - 1] + self.dm[L - 1], self.dm[L - 2] = self.dm[L - 2], self.dm[L - 1] def rot(self) -> None: self.e(_make_stack_op(op="rot")) L = len(self.nm) if L >= 3: - r = self.nm[L - 3] - del self.nm[L - 3] - self.nm.append(r) + r, rd = self.remove_slot_at(L - 3) + self.push_tracked(r, rd) def op(self, code: str) -> None: self.e(_make_stack_op(op="opcode", code=code)) @@ -138,13 +299,12 @@ def roll(self, d: int) -> None: self.rot() return self.e(_make_stack_op(op="push", value=_big_int_push(d))) - self.nm.append("") + self.push_tracked("", Dom.NON_NEGATIVE) self.e(_make_stack_op(op="roll", depth=d)) - self.nm.pop() # pop the push placeholder + self.pop_tracked() # the depth literal idx = len(self.nm) - 1 - d - r = self.nm[idx] - del self.nm[idx] - self.nm.append(r) + r, rd = self.remove_slot_at(idx) + self.push_tracked(r, rd) def pick(self, d: int, n: str) -> None: if d == 0: @@ -154,10 +314,12 @@ def pick(self, d: int, n: str) -> None: self.over(n) return self.e(_make_stack_op(op="push", value=_big_int_push(d))) - self.nm.append("") + self.push_tracked("", Dom.NON_NEGATIVE) self.e(_make_stack_op(op="pick", depth=d)) - self.nm.pop() # pop the push placeholder - self.nm.append(n) + self.pop_tracked() # the depth literal + # Once the depth literal is gone the copied slot sits at depth d. + src = self.dm[len(self.dm) - 1 - d] if len(self.dm) > d else Dom.UNKNOWN + self.push_tracked(n, src) def to_top(self, name: str) -> None: self.roll(self.find_depth(name)) @@ -165,14 +327,68 @@ def to_top(self, name: str) -> None: def copy_to_top(self, name: str, n: str) -> None: self.pick(self.find_depth(name), n) + # -- constant pool ------------------------------------------------------ + # + # A pooled constant is an ordinary tracked slot; nothing about the stack + # model changes. `push_const` just chooses, per call site and by emitted + # bytes, between copying that slot and re-pushing the literal. Nested + # trackers built from `list(t.nm)` inherit the slot for free, so pooled + # constants work unchanged inside an `OP_IF` arm. + + def pool_constant(self, slot: str, value: int) -> None: + """Park *value* in *slot* for this emitter. No-op when pooling is off.""" + if not self.pooling or slot in self.nm: + return + self.push_big_int(slot, value) + + def release_constant(self, slot: str) -> None: + """Remove a pooled slot. No-op when pooling is off or the slot is absent.""" + if not self.pooling or slot not in self.nm: + return + self.to_top(slot) + self.drop() + + def const_cost(self, slot: str, value: int) -> int: + """Emitted bytes a ``push_const`` of this constant would cost right now. + + The comparison is exact -- ``size_of_push_int`` is the same encoder the + emit pass uses -- so pooling can never make a call site bigger. A pick at + depth d costs ``size_of_push_int(d) + 1``; depths 0 and 1 are OP_DUP / + OP_OVER, 1 byte each. + """ + from runar_compiler.codegen.cost_model import size_of_push_int + + if self.pooling and slot in self.nm: + d = self.find_depth(slot) + pick_cost = 1 if d <= 1 else size_of_push_int(d) + 1 + if pick_cost < size_of_push_int(value): + return pick_cost + return size_of_push_int(value) + + def push_const(self, slot: str, value: int, name: str) -> None: + """Materialize *value* on top as *name*, from the pooled slot when that + is cheaper in emitted bytes than pushing the literal.""" + from runar_compiler.codegen.cost_model import size_of_push_int + + if self.pooling and slot in self.nm: + d = self.find_depth(slot) + pick_cost = 1 if d <= 1 else size_of_push_int(d) + 1 + if pick_cost < size_of_push_int(value): + self.pick(d, name) + return + self.push_big_int(name, value) + def to_alt(self) -> None: self.op("OP_TOALTSTACK") if self.nm: - self.nm.pop() + d = self.dm[-1] + self.pop_tracked() + self.alt_dm.append(d) def from_alt(self, n: str) -> None: self.op("OP_FROMALTSTACK") - self.nm.append(n) + d = self.alt_dm.pop() if self.alt_dm else Dom.UNKNOWN + self.push_tracked(n, d) def rename(self, n: str) -> None: if self.nm: @@ -189,11 +405,12 @@ def raw_block( *produce* = "" means no output pushed. """ for _ in reversed(consume): - if self.nm: - self.nm.pop() + self.pop_tracked() fn(self.e) if produce: - self.nm.append(produce) + # Opaque opcodes: nothing is known about the result unless the caller + # proves it and records that with `set_domain` afterwards. + self.push_tracked(produce, Dom.UNKNOWN) def emit_if( self, @@ -207,16 +424,15 @@ def emit_if( *result_name* = "" means no result pushed. """ self.to_top(cond_name) - # condition consumed - if self.nm: - self.nm.pop() + self.pop_tracked() # condition consumed then_ops: list["StackOp"] = [] else_ops: list["StackOp"] = [] then_fn(lambda op: then_ops.append(op)) else_fn(lambda op: else_ops.append(op)) self.e(_make_stack_op(op="if", then=then_ops, else_=else_ops)) if result_name: - self.nm.append(result_name) + # A join over two arms this tracker did not analyse: nothing is known. + self.push_tracked(result_name, Dom.UNKNOWN) # =========================================================================== @@ -225,11 +441,40 @@ def emit_if( def _ec_push_field_p(t: ECTracker, name: str) -> None: """Push the field prime p onto the stack as a script number.""" - t.push_big_int(name, EC_FIELD_P) + t.push_const(POOL_FIELD_P, EC_FIELD_P, name) + + +def _ec_field_mod_short(t: ECTracker, a_name: str, result_name: str) -> None: + """``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 does not check. + """ + t.to_top(a_name) + _ec_push_field_p(t, "_fmods_p") + t.raw_block([a_name, "_fmods_p"], result_name, + lambda e: e(_make_stack_op(op="opcode", code="OP_MOD"))) + t.set_domain(result_name, Dom.REDUCED) + + +def _ec_cheap_sub_pays(t: ECTracker) -> bool: + """Does the cheap ``a - b + p`` subtraction shape pay here? + + It references the prime TWICE where 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. + """ + c = t.const_cost(POOL_FIELD_P, EC_FIELD_P) + return 2 * c + 2 < c + 8 def _ec_field_mod(t: ECTracker, a_name: str, result_name: str) -> None: """Reduce TOS mod p, ensuring non-negative result.""" + if t.sinking and is_non_negative(t.domain_of(a_name)): + _ec_field_mod_short(t, a_name, result_name) + return t.to_top(a_name) _ec_push_field_p(t, "_fmod_p") # (a % p + p) % p @@ -243,13 +488,18 @@ def _fn(e: Callable) -> None: e(_make_stack_op(op="swap")) # (a%p+p) p e(_make_stack_op(op="opcode", code="OP_MOD")) # ((a%p+p)%p) t.raw_block([a_name, "_fmod_p"], result_name, _fn) + t.set_domain(result_name, Dom.REDUCED) def _ec_field_add(t: ECTracker, a_name: str, b_name: str, result_name: str) -> None: """Compute (a + b) mod p.""" + # Read the operand facts BEFORE raw_block consumes their slots. + sum_non_neg = is_non_negative(t.domain_of(a_name)) and is_non_negative(t.domain_of(b_name)) t.to_top(a_name) t.to_top(b_name) t.raw_block([a_name, b_name], "_fadd_sum", lambda e: e(_make_stack_op(op="opcode", code="OP_ADD"))) + if sum_non_neg: + t.set_domain("_fadd_sum", Dom.NON_NEGATIVE) _ec_field_mod(t, "_fadd_sum", result_name) @@ -257,20 +507,48 @@ def _ec_field_sub(t: ECTracker, a_name: str, b_name: str, result_name: str) -> N """Compute (a - b) mod p (non-negative).""" t.to_top(a_name) t.to_top(b_name) + # 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. + cheap = (t.sinking + and is_non_negative(t.domain_of(a_name)) + and t.domain_of(b_name) == Dom.REDUCED + and _ec_cheap_sub_pays(t)) + t.raw_block([a_name, b_name], "_fsub_diff", lambda e: e(_make_stack_op(op="opcode", code="OP_SUB"))) + + if cheap: + _ec_push_field_p(t, "_fsub_p") + t.raw_block(["_fsub_diff", "_fsub_p"], "_fsub_shift", + lambda e: e(_make_stack_op(op="opcode", code="OP_ADD"))) + t.set_domain("_fsub_shift", Dom.NON_NEGATIVE) + _ec_field_mod_short(t, "_fsub_shift", result_name) + return _ec_field_mod(t, "_fsub_diff", result_name) -def _ec_field_mul(t: ECTracker, a_name: str, b_name: str, result_name: str) -> None: - """Compute (a * b) mod p.""" +def _ec_field_mul(t: ECTracker, a_name: str, b_name: str, result_name: str, + product_non_negative: bool = False) -> None: + """Compute (a * b) mod p. + + *product_non_negative* lets a caller assert the product's sign independently + of the operands -- ``_ec_field_sqr`` uses it, since a*a >= 0 for any a. + """ + non_neg = product_non_negative or ( + is_non_negative(t.domain_of(a_name)) and is_non_negative(t.domain_of(b_name))) t.to_top(a_name) t.to_top(b_name) t.raw_block([a_name, b_name], "_fmul_prod", lambda e: e(_make_stack_op(op="opcode", code="OP_MUL"))) + if non_neg: + t.set_domain("_fmul_prod", Dom.NON_NEGATIVE) _ec_field_mod(t, "_fmul_prod", result_name) def _ec_field_mul_const(t: ECTracker, a_name: str, c: int, result_name: str) -> None: """Compute (a * c) mod p where c is a small constant.""" + # Every call site passes a small positive c, so the product keeps a's sign. + non_neg = c > 0 and is_non_negative(t.domain_of(a_name)) t.to_top(a_name) def _fmc_body(e: Callable[["StackOp"], None]) -> None: @@ -282,13 +560,15 @@ def _fmc_body(e: Callable[["StackOp"], None]) -> None: e(_make_stack_op(op="opcode", code="OP_MUL")) t.raw_block([a_name], "_fmc_prod", _fmc_body) + if non_neg: + t.set_domain("_fmc_prod", Dom.NON_NEGATIVE) _ec_field_mod(t, "_fmc_prod", result_name) def _ec_field_sqr(t: ECTracker, a_name: str, result_name: str) -> None: - """Compute (a * a) mod p.""" + """Compute (a * a) mod p. A square is non-negative whatever a's sign is.""" t.copy_to_top(a_name, "_fsqr_copy") - _ec_field_mul(t, a_name, "_fsqr_copy", result_name) + _ec_field_mul(t, a_name, "_fsqr_copy", result_name, product_non_negative=True) def _ec_field_inv(t: ECTracker, a_name: str, result_name: str) -> None: @@ -369,8 +649,8 @@ def _split(e: Callable) -> None: e(_make_stack_op(op="opcode", code="OP_SPLIT")) t.raw_block([point_name], "", _split) # Manually track the two new items - t.nm.append("_dp_xb") - t.nm.append("_dp_yb") + t.push_tracked("_dp_xb", Dom.UNKNOWN) + t.push_tracked("_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 @@ -380,6 +660,10 @@ def _convert_y(e: Callable) -> None: e(_make_stack_op(op="opcode", code="OP_CAT")) e(_make_stack_op(op="opcode", code="OP_BIN2NUM")) t.raw_block(["_dp_yb"], y_name, _convert_y) + # 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.set_domain(y_name, Dom.NON_NEGATIVE) # Convert x_bytes to num t.to_top("_dp_xb") @@ -389,6 +673,7 @@ def _convert_x(e: Callable) -> None: e(_make_stack_op(op="opcode", code="OP_CAT")) e(_make_stack_op(op="opcode", code="OP_BIN2NUM")) t.raw_block(["_dp_xb"], x_name, _convert_x) + t.set_domain(x_name, Dom.NON_NEGATIVE) # Stack: [yName, xName] -- swap to standard order [xName, yName] t.swap() @@ -665,7 +950,11 @@ def _ec_build_jacobian_add_affine_inline(e: Callable, t: ECTracker) -> None: After: [..., ax, ay, _k, jx', jy', jz'] """ # Create inner tracker with cloned stack state - _ec_jacobian_add_affine_body(ECTracker(list(t.nm), e), False) + # The inner tracker inherits the stack state AND the lattice facts: the + # operands' proved domains are what decide which reduction shape the body + # emits, so dropping them here would silently fall back everywhere. + _ec_jacobian_add_affine_body( + ECTracker(list(t.nm), e, t.options, list(t.dm)), False) def _ec_jacobian_add_affine_body(it: ECTracker, keep_hr: bool) -> None: @@ -819,7 +1108,7 @@ def _ec_build_jacobian_add_or_double_inline(e: Callable, t: ECTracker) -> None: Stack layout: [..., ax, ay, _k, jx, jy, jz] -- same in and out. """ - it = ECTracker(list(t.nm), e) + it = ECTracker(list(t.nm), e, t.options, list(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. @@ -880,17 +1169,19 @@ def _ec_build_jacobian_add_or_double_inline(e: Callable, t: ECTracker) -> None: # Public entry points (called from stack lowerer) # =========================================================================== -def emit_ec_add(emit: Callable) -> None: +def emit_ec_add(emit: Callable, opts: "EcCodegenOptions | None" = None) -> None: """Add two points. Stack in: [point_a, point_b] (b on top) Stack out: [result_point] """ - t = ECTracker(["_pa", "_pb"], emit) + t = ECTracker(["_pa", "_pb"], emit, opts) + t.pool_constant(POOL_FIELD_P, EC_FIELD_P) _ec_decompose_point(t, "_pa", "px", "py") _ec_decompose_point(t, "_pb", "qx", "qy") _ec_affine_add(t) _ec_compose_point(t, "rx", "ry", "_result") + t.release_constant(POOL_FIELD_P) def _ec_emit_scalar_reduce(t: ECTracker, k_name: str, result_name: str, curve_n: int) -> None: @@ -907,7 +1198,7 @@ def _ec_emit_scalar_reduce(t: ECTracker, k_name: str, result_name: str, curve_n: Reducing costs 1 push + 8 opcodes (42 bytes) against a ~429 KB script, and makes k >= n, k < 0 and k = 0 all well defined. """ - t.push_big_int("_n_red", curve_n) + t.push_const(POOL_GROUP_N, curve_n, "_n_red") def _body(e: Callable) -> None: e(_make_stack_op(op="opcode", code="OP_2DUP")) @@ -922,7 +1213,7 @@ def _body(e: Callable) -> None: t.raw_block([k_name, "_n_red"], result_name, _body) -def emit_ec_mul(emit: Callable) -> None: +def emit_ec_mul(emit: Callable, opts: "EcCodegenOptions | None" = None) -> None: """Perform scalar multiplication P * k. Stack in: [point, scalar] (scalar on top) @@ -930,7 +1221,9 @@ def emit_ec_mul(emit: Callable) -> None: Uses 256-iteration double-and-add with Jacobian coordinates. """ - t = ECTracker(["_pt", "_k"], emit) + t = ECTracker(["_pt", "_k"], emit, opts) + t.pool_constant(POOL_FIELD_P, EC_FIELD_P) + t.pool_constant(POOL_GROUP_N, EC_CURVE_N) # Decompose to affine base point _ec_decompose_point(t, "_pt", "ax", "ay") @@ -940,14 +1233,13 @@ def emit_ec_mul(emit: Callable) -> None: # # "k in [1, n-1]" is a PRECONDITION the caller cannot enforce -- the scalar # is usually an unlock argument -- so reduce it first. - curve_n = int("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141", 16) t.to_top("_k") - _ec_emit_scalar_reduce(t, "_k", "_kr", curve_n) - t.push_big_int("_n", curve_n) + _ec_emit_scalar_reduce(t, "_k", "_kr", EC_CURVE_N) + t.push_const(POOL_GROUP_N, EC_CURVE_N, "_n") t.raw_block(["_kr", "_n"], "_kn", lambda e: e(_make_stack_op(op="opcode", code="OP_ADD"))) - t.push_big_int("_n2", curve_n) + t.push_const(POOL_GROUP_N, EC_CURVE_N, "_n2") t.raw_block(["_kn", "_n2"], "_kn2", lambda e: e(_make_stack_op(op="opcode", code="OP_ADD"))) - t.push_big_int("_n3", curve_n) + t.push_const(POOL_GROUP_N, EC_CURVE_N, "_n3") t.raw_block(["_kn2", "_n3"], "_kn3", lambda e: e(_make_stack_op(op="opcode", code="OP_ADD"))) t.rename("_k") @@ -978,7 +1270,7 @@ def emit_ec_mul(emit: Callable) -> None: # 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.to_top("_bit") - t.nm.pop() # _bit consumed by IF + t.pop_tracked() # _bit consumed by IF add_ops: list = [] add_emit = lambda op: add_ops.append(op) # Only the final step can be handed two equal operands -- see @@ -1003,41 +1295,283 @@ def emit_ec_mul(emit: Callable) -> None: # Compose result _ec_compose_point(t, "_rx", "_ry", "_result") + t.release_constant(POOL_GROUP_N) + t.release_constant(POOL_FIELD_P) + + +# =========================================================================== +# Fixed-base comb (secp256k1) +# =========================================================================== + +def _comb_emit_select(t: ECTracker, i: int, w: int, d: int) -> None: + """Round *i*'s digit and the selected table entry, as ``ax``/``ay``/``_flag``. + + Exactly one equality holds, so ``sum(eq_j * T_j)`` is that entry's coordinate + and every term is non-negative and below p -- no reduction is needed, and the + result is ``REDUCED`` by construction. When the digit is zero every term + vanishes and ``_flag`` is 0, so no add runs. + + Shared by both comb emitters: the selection is pure scalar bit-twiddling and + table indexing, with no curve arithmetic in it at all. + """ + entries = (1 << w) - 1 + for b in range(w): + shift = i + b * d + kc, sh = f"_kc{b}", f"_sh{b}" + t.copy_to_top("_k", kc) + if shift == 0: + t.rename(sh) + elif shift == 1: + t.raw_block([kc], sh, lambda e: e(_make_stack_op(op="opcode", code="OP_2DIV"))) + else: + sd = f"_sd{b}" + t.push_int(sd, shift) + t.raw_block([kc, sd], sh, lambda e: e(_make_stack_op(op="opcode", code="OP_RSHIFTNUM"))) + two, bit = f"_two{b}", f"_b{b}" + t.push_int(two, 2) + t.raw_block([sh, two], bit, lambda e: e(_make_stack_op(op="opcode", code="OP_MOD"))) + t.set_domain(bit, Dom.REDUCED) + + t.to_top("_b0") + t.rename("_idx") + for b in range(1, w): + bit, wt, bw = f"_b{b}", f"_wt{b}", f"_bw{b}" + t.to_top(bit) + t.push_int(wt, 1 << b) + t.raw_block([bit, wt], bw, lambda e: e(_make_stack_op(op="opcode", code="OP_MUL"))) + t.to_top("_idx") + t.raw_block([bw, "_idx"], "_idx", lambda e: e(_make_stack_op(op="opcode", code="OP_ADD"))) + t.set_domain("_idx", Dom.REDUCED) + + for j in range(1, entries + 1): + ic, jv, eq = f"_ic{j}", f"_jv{j}", f"_eq{j}" + t.copy_to_top("_idx", ic) + t.push_int(jv, j) + t.raw_block([ic, jv], eq, lambda e: e(_make_stack_op(op="opcode", code="OP_NUMEQUAL"))) + t.set_domain(eq, Dom.REDUCED) + + for coord in ("x", "y"): + acc = "ax" if coord == "x" else "ay" + for j in range(1, entries + 1): + ecn, tc, pr = f"_e{coord}{j}", f"_t{coord}{j}", f"_pr{coord}{j}" + t.copy_to_top(f"_eq{j}", ecn) + t.copy_to_top(f"_T{coord}{j}", tc) + t.raw_block([ecn, tc], pr, lambda e: e(_make_stack_op(op="opcode", code="OP_MUL"))) + if j == 1: + t.rename(acc) + else: + t.to_top(acc) + t.raw_block([pr, acc], acc, lambda e: e(_make_stack_op(op="opcode", code="OP_ADD"))) + t.set_domain(acc, Dom.REDUCED) + + for j in range(entries, 0, -1): + t.to_top(f"_eq{j}") + t.drop() + + t.to_top("_idx") + t.raw_block(["_idx"], "_flag", lambda e: e(_make_stack_op(op="opcode", code="OP_0NOTEQUAL"))) + + +def _ec_emit_comb_mul_gen(emit: Callable, w: int, + opts: "EcCodegenOptions | None" = None) -> bool: + """``k*G`` by a Lim-Lee fixed-base comb instead of the 257-round ladder. + + The ladder doubles and conditionally adds once per SCALAR BIT. A comb splits + the scalar into ``w`` blocks of ``d`` bits and reads one bit from each block + per round, so it performs one doubling and one conditional add per COLUMN: + the round count falls from ``w*d`` to ``d`` at the price of a ``2^w - 1`` + entry table. G is a compile-time constant here, so the table costs nothing to + build -- ``2*(2^w - 1)`` literal pushes, resident for the whole emitter, read + by every round with a 2-3 byte ``OP_PICK``. + + This is the secp256k1 twin of ``_c_emit_comb_mul_gen`` in ``p256_p384.py``. + The curve arithmetic is NOT shared: secp256k1 has ``a = 0``, so + ``_ec_jacobian_double`` computes ``D = 3X^2`` where the NIST version computes + ``3(X-Z^2)(X+Z^2)``. Only ``comb.py`` -- the compile-time table and the + interval checker -- is common, and it takes ``a`` from the curve record. + + SOUNDNESS. The cheap incomplete mixed add cannot represent a pre-add + accumulator equal to the addend, its negation, or the point at infinity. + ``_ec_build_jacobian_add_or_double_inline``'s comment justifies using it + everywhere but the ladder's LAST step by an interval argument over + ``c_i mod n``, and insists that argument be re-derived by anything changing + the offset or the iteration count. A comb changes both, so it is re-derived: + ``comb_safe_rounds`` evaluates the same argument as executable interval + arithmetic over the comb's own geometry, and any round it cannot prove gets + the complete add-or-double form instead. Nothing is assumed safe. + + The other half of that argument is that the accumulator never starts at + infinity, which needs the first digit non-zero. ``comb_geometry`` searches + for the scalar offset that guarantees it rather than reusing the ladder's + hardcoded ``+3n`` -- right for secp256k1 at w=3, wrong for P-384. + + Stack in: [_k]. Stack out: [_result]. False when no geometry exists. + """ + from runar_compiler.codegen.comb import ( + SECP256K1_COMB_CURVE, comb_geometry, comb_safe_rounds, comb_table, + ) + + curve = SECP256K1_COMB_CURVE + params = comb_geometry(w, curve) + if params is None: + return False + d = params.d + table = comb_table(w, d, curve) + safe = comb_safe_rounds(params, curve) + entries = (1 << w) - 1 + + t = ECTracker(["_k"], emit, opts) + t.pool_constant(POOL_FIELD_P, EC_FIELD_P) + t.pool_constant(POOL_GROUP_N, EC_CURVE_N) + + # k' = (k mod n) + m*n. The reduce is what confines k to [0, n-1] and so what + # makes the interval argument apply at all; see _ec_emit_scalar_reduce. + t.to_top("_k") + _ec_emit_scalar_reduce(t, "_k", "_kr", EC_CURVE_N) + t.rename("_k") + for i in range(params.offset_multiple): + off = f"_off{i}" + t.push_const(POOL_GROUP_N, EC_CURVE_N, off) + t.raw_block(["_k", off], "_k", lambda e: e(_make_stack_op(op="opcode", code="OP_ADD"))) + t.set_domain("_k", Dom.NON_NEGATIVE) + + # Table, resident for the whole comb: picking an entry costs 2-3 bytes + # against a 34-byte literal push, and every round reads all of them. + for j in range(1, entries + 1): + pt = table[j] + t.push_big_int(f"_Tx{j}", pt.x) + t.push_big_int(f"_Ty{j}", pt.y) + t.set_domain(f"_Tx{j}", Dom.REDUCED) + t.set_domain(f"_Ty{j}", Dom.REDUCED) + + # Round d-1 initialises the accumulator. The first digit is non-zero by + # construction (comb_geometry), so this is a real point and never infinity. + _comb_emit_select(t, d - 1, w, d) + t.to_top("_flag") + t.drop() + t.to_top("ax") + t.rename("jx") + t.to_top("ay") + t.rename("jy") + t.push_int("jz", 1) + t.set_domain("jz", Dom.REDUCED) + + for i in range(d - 2, -1, -1): + _ec_jacobian_double(t) + _comb_emit_select(t, i, w, d) + + # `_ec_jacobian_add_affine_body` documents its layout as + # [..., ax, ay, jx, jy, jz] and replaces the accumulator IN PLACE at the + # top. The selection leaves ax/ay above jz, so restore the contract + # before the branch -- otherwise the add arm would reorder the stack and + # the empty else arm would not, leaving the two arms with different + # layouts at OP_ENDIF. + t.to_top("_flag") + t.to_alt() + t.to_top("jx") + t.to_top("jy") + t.to_top("jz") + t.from_alt("_flag") + + t.pop_tracked() # consumed by OP_IF + add_ops: list = [] + if safe[i]: + _ec_build_jacobian_add_affine_inline(add_ops.append, t) + else: + _ec_build_jacobian_add_or_double_inline(add_ops.append, t) + emit(_make_stack_op(op="if", then=add_ops, else_=[])) + + # The addend was selected fresh for this round; the add only copied it. + t.to_top("ay") + t.drop() + t.to_top("ax") + t.drop() + + _ec_jacobian_to_affine(t, "_rx", "_ry") + + for j in range(entries, 0, -1): + t.to_top(f"_Ty{j}") + t.drop() + t.to_top(f"_Tx{j}") + t.drop() + t.to_top("_k") + t.drop() + + _ec_compose_point(t, "_rx", "_ry", "_result") + t.release_constant(POOL_GROUP_N) + t.release_constant(POOL_FIELD_P) + return True + + +def _ec_emit_comb_best(opts: "EcCodegenOptions | None" = None): + """Emit the cheapest comb over the candidate window widths. + Each candidate is rendered in full and scored with the same byte-cost model + the emitter is measured by, and the smallest wins -- the window width is not + hardcoded. w=1 is the binary ladder and is excluded; beyond w=4 the ``2^w`` + selection logic outgrows the saving. -def emit_ec_mul_gen(emit: Callable) -> None: + ``None`` when no candidate could be built, so the caller falls back to the + ladder rather than emitting nothing. + """ + from runar_compiler.codegen.cost_model import estimate_script_bytes + + best = None + for w in (2, 3, 4): + ops: list = [] + if not _ec_emit_comb_mul_gen(ops.append, w, opts): + continue + if best is None or estimate_script_bytes(ops) < estimate_script_bytes(best): + best = ops + return best + + +def emit_ec_mul_gen(emit: Callable, opts: "EcCodegenOptions | None" = None) -> None: """Perform scalar multiplication G * k. Stack in: [scalar] Stack out: [result_point] """ + # G is a compile-time constant, so this is the one secp256k1 call site where + # a fixed-base comb applies. `emit_ec_mul` cannot use it: its base arrives at + # run time. + if opts is not None and opts.fixed_base_comb: + ops = _ec_emit_comb_best(opts) + if ops is not None: + for op in ops: + emit(op) + return + # Push generator point as 64-byte blob, then delegate to ecMul g_point = _bigint_to_bytes32(EC_GEN_X) + _bigint_to_bytes32(EC_GEN_Y) emit(_make_stack_op(op="push", value=_make_push_value(kind="bytes", bytes_=g_point))) emit(_make_stack_op(op="swap")) # [point, scalar] - emit_ec_mul(emit) + emit_ec_mul(emit, opts) -def emit_ec_negate(emit: Callable) -> None: +def emit_ec_negate(emit: Callable, opts: "EcCodegenOptions | None" = None) -> None: """Negate a point (x, p - y). Stack in: [point] Stack out: [negated_point] """ - t = ECTracker(["_pt"], emit) + t = ECTracker(["_pt"], emit, opts) + t.pool_constant(POOL_FIELD_P, EC_FIELD_P) _ec_decompose_point(t, "_pt", "_nx", "_ny") _ec_push_field_p(t, "_fp") _ec_field_sub(t, "_fp", "_ny", "_neg_y") _ec_compose_point(t, "_nx", "_neg_y", "_result") + t.release_constant(POOL_FIELD_P) -def emit_ec_on_curve(emit: Callable) -> None: +def emit_ec_on_curve(emit: Callable, opts: "EcCodegenOptions | None" = None) -> None: """Check if point is on secp256k1 (y^2 = x^3 + 7 mod p). Stack in: [point] Stack out: [boolean] """ - t = ECTracker(["_pt"], emit) + t = ECTracker(["_pt"], emit, opts) + t.pool_constant(POOL_FIELD_P, EC_FIELD_P) _ec_decompose_point(t, "_pt", "_x", "_y") # GAP-301: coordinate canonicity. ``_ec_decompose_point`` BIN2NUMs each @@ -1076,6 +1610,7 @@ def emit_ec_on_curve(emit: Callable) -> None: t.to_top("_canon") t.to_top("_curve_eq") t.raw_block(["_canon", "_curve_eq"], "_result", lambda e: e(_make_stack_op(op="opcode", code="OP_BOOLAND"))) + t.release_constant(POOL_FIELD_P) def emit_ec_mod_reduce(emit: Callable) -> None: diff --git a/compilers/python/runar_compiler/codegen/p256_p384.py b/compilers/python/runar_compiler/codegen/p256_p384.py index 30636c490..a2f0cd65d 100644 --- a/compilers/python/runar_compiler/codegen/p256_p384.py +++ b/compilers/python/runar_compiler/codegen/p256_p384.py @@ -23,11 +23,26 @@ # Re-use ECTracker and the lazy-import helpers from ec.py from runar_compiler.codegen.ec import ( + Dom, + EcCodegenOptions, ECTracker, + POOL_FIELD_P, + POOL_GROUP_N, + is_non_negative, + _comb_emit_select, _make_stack_op, _make_push_value, _big_int_push, ) +from runar_compiler.codegen.comb import ( + P256_COMB_CURVE, + P384_COMB_CURVE, + CombCurve, + comb_geometry, + comb_safe_rounds, + comb_table, +) +from runar_compiler.codegen.cost_model import estimate_script_bytes # =========================================================================== # P-256 constants (secp256r1 / NIST P-256) @@ -100,10 +115,30 @@ def _emit_reverse32(e: Callable) -> None: # =========================================================================== def _c_push_field_p(t: ECTracker, name: str, field_p: int) -> None: - t.push_big_int(name, field_p) + t.push_const(POOL_FIELD_P, field_p, name) + + +def _c_field_mod_short(t: ECTracker, a_name: str, result_name: str, field_p: int) -> None: + """``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. + """ + t.to_top(a_name) + _c_push_field_p(t, "_fmods_p", field_p) + t.raw_block([a_name, "_fmods_p"], result_name, + lambda e: e(_make_stack_op(op="opcode", code="OP_MOD"))) + t.set_domain(result_name, Dom.REDUCED) + + +def _c_cheap_sub_pays(t: ECTracker, field_p: int) -> bool: + """Does the cheap ``a - b + p`` subtraction pay? Only when p is pooled.""" + cost = t.const_cost(POOL_FIELD_P, field_p) + return 2 * cost + 2 < cost + 8 def _c_field_mod(t: ECTracker, a_name: str, result_name: str, field_p: int) -> None: + if t.sinking and is_non_negative(t.domain_of(a_name)): + _c_field_mod_short(t, a_name, result_name, field_p) + return t.to_top(a_name) _c_push_field_p(t, "_fmod_p", field_p) @@ -118,30 +153,60 @@ def _fn(e: Callable) -> None: e(_make_stack_op(op="opcode", code="OP_MOD")) t.raw_block([a_name, "_fmod_p"], result_name, _fn) + t.set_domain(result_name, Dom.REDUCED) def _c_field_add(t: ECTracker, a_name: str, b_name: str, result_name: str, field_p: int) -> None: + # Read the operand facts before raw_block consumes their slots. + sum_non_neg = is_non_negative(t.domain_of(a_name)) and is_non_negative(t.domain_of(b_name)) t.to_top(a_name) t.to_top(b_name) t.raw_block([a_name, b_name], "_fadd_sum", lambda e: e(_make_stack_op(op="opcode", code="OP_ADD"))) + if sum_non_neg: + t.set_domain("_fadd_sum", Dom.NON_NEGATIVE) _c_field_mod(t, "_fadd_sum", result_name, field_p) def _c_field_sub(t: ECTracker, a_name: str, b_name: str, result_name: str, field_p: int) -> None: t.to_top(a_name) t.to_top(b_name) + # 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. + cheap = (t.sinking + and is_non_negative(t.domain_of(a_name)) + and t.domain_of(b_name) == Dom.REDUCED + and _c_cheap_sub_pays(t, field_p)) + t.raw_block([a_name, b_name], "_fsub_diff", lambda e: e(_make_stack_op(op="opcode", code="OP_SUB"))) + + if cheap: + _c_push_field_p(t, "_fsub_p", field_p) + t.raw_block(["_fsub_diff", "_fsub_p"], "_fsub_shift", + lambda e: e(_make_stack_op(op="opcode", code="OP_ADD"))) + t.set_domain("_fsub_shift", Dom.NON_NEGATIVE) + _c_field_mod_short(t, "_fsub_shift", result_name, field_p) + return _c_field_mod(t, "_fsub_diff", result_name, field_p) -def _c_field_mul(t: ECTracker, a_name: str, b_name: str, result_name: str, field_p: int) -> None: +def _c_field_mul(t: ECTracker, a_name: str, b_name: str, result_name: str, field_p: int, + product_non_negative: bool = False) -> None: + # *product_non_negative* lets `_c_field_sqr` assert the sign independently of + # the operand: a*a >= 0 for any a whatsoever. + non_neg = product_non_negative or ( + is_non_negative(t.domain_of(a_name)) and is_non_negative(t.domain_of(b_name))) t.to_top(a_name) t.to_top(b_name) t.raw_block([a_name, b_name], "_fmul_prod", lambda e: e(_make_stack_op(op="opcode", code="OP_MUL"))) + if non_neg: + t.set_domain("_fmul_prod", Dom.NON_NEGATIVE) _c_field_mod(t, "_fmul_prod", result_name, field_p) def _c_field_mul_const(t: ECTracker, a_name: str, cv: int, result_name: str, field_p: int) -> None: + # Every call site passes a small positive cv, so the product keeps a's sign. + non_neg = cv > 0 and is_non_negative(t.domain_of(a_name)) t.to_top(a_name) def _fmc_body(e: Callable) -> None: @@ -152,12 +217,14 @@ def _fmc_body(e: Callable) -> None: e(_make_stack_op(op="opcode", code="OP_MUL")) t.raw_block([a_name], "_fmc_prod", _fmc_body) + if non_neg: + t.set_domain("_fmc_prod", Dom.NON_NEGATIVE) _c_field_mod(t, "_fmc_prod", result_name, field_p) def _c_field_sqr(t: ECTracker, a_name: str, result_name: str, field_p: int) -> None: t.copy_to_top(a_name, "_fsqr_copy") - _c_field_mul(t, a_name, "_fsqr_copy", result_name, field_p) + _c_field_mul(t, a_name, "_fsqr_copy", result_name, field_p, product_non_negative=True) def _c_field_inv(t: ECTracker, a_name: str, result_name: str, field_p: int, p_minus_2: int) -> None: @@ -186,7 +253,7 @@ def _c_field_inv(t: ECTracker, a_name: str, result_name: str, field_p: int, p_mi # =========================================================================== def _c_push_group_n(t: ECTracker, name: str, curve_n: int) -> None: - t.push_big_int(name, curve_n) + t.push_const(POOL_GROUP_N, curve_n, name) def _c_group_mod(t: ECTracker, a_name: str, result_name: str, curve_n: int) -> None: @@ -284,8 +351,8 @@ def _split(e: Callable) -> None: e(_make_stack_op(op="opcode", code="OP_SPLIT")) t.raw_block([point_name], "", _split) - t.nm.append("_dp_xb") - t.nm.append("_dp_yb") + t.push_tracked("_dp_xb", Dom.UNKNOWN) + t.push_tracked("_dp_yb", Dom.UNKNOWN) def _convert_y(e: Callable) -> None: reverse_bytes_fn(e) @@ -294,6 +361,10 @@ def _convert_y(e: Callable) -> None: e(_make_stack_op(op="opcode", code="OP_BIN2NUM")) t.raw_block(["_dp_yb"], y_name, _convert_y) + # A 0x00 sign byte is appended before BIN2NUM, so the coordinate decodes + # UNSIGNED: >= 0, but it may be up to 2^(8*coord_bytes) - 1 and therefore + # >= p. That gap is exactly what the subtraction precondition turns on. + t.set_domain(y_name, Dom.NON_NEGATIVE) t.to_top("_dp_xb") @@ -304,6 +375,7 @@ def _convert_x(e: Callable) -> None: e(_make_stack_op(op="opcode", code="OP_BIN2NUM")) t.raw_block(["_dp_xb"], x_name, _convert_x) + t.set_domain(x_name, Dom.NON_NEGATIVE) # Swap to standard order [x_name, y_name] t.swap() @@ -614,7 +686,11 @@ def _c_build_jacobian_add_affine_inline( p_minus_2: int, ) -> None: """Build Jacobian mixed-add ops for use inside OP_IF.""" - _c_jacobian_add_affine_body(ECTracker(list(t.nm), e), False, field_p, p_minus_2) + # The inner tracker inherits the stack state AND the lattice facts: the + # operands' proved domains are what decide which reduction shape the body + # emits, so dropping them here would silently fall back everywhere. + _c_jacobian_add_affine_body( + ECTracker(list(t.nm), e, t.options, list(t.dm)), False, field_p, p_minus_2) def _c_jacobian_add_affine_body( @@ -762,7 +838,7 @@ def _c_build_jacobian_add_or_double_inline( Stack layout: [..., ax, ay, _k, jx, jy, jz] -- same in and out. """ - it = ECTracker(list(t.nm), e) + it = ECTracker(list(t.nm), e, t.options, list(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. @@ -831,9 +907,12 @@ def _c_emit_mul( p_minus_2: int, curve_n: int, n_minus_2: int, + opts: "EcCodegenOptions | None" = None, ) -> None: """Generic scalar multiplication for NIST curves.""" - t = ECTracker(["_pt", "_k"], emit) + t = ECTracker(["_pt", "_k"], emit, opts) + t.pool_constant(POOL_FIELD_P, field_p) + t.pool_constant(POOL_GROUP_N, curve_n) _c_decompose_point(t, "_pt", "ax", "ay", coord_bytes, reverse_bytes_fn) # k' = k + 3n @@ -875,7 +954,7 @@ def _c_emit_mul( t.raw_block(["_shifted", "_two"], "_bit", lambda e: e(_make_stack_op(op="opcode", code="OP_MOD"))) t.to_top("_bit") - t.nm.pop() # _bit consumed by IF + t.pop_tracked() # _bit consumed by IF add_ops: list = [] @@ -901,6 +980,8 @@ def _add_emit(op: object, _t: ECTracker = t, _fp: int = field_p, _pm2: int = p_m t.drop() _c_compose_point(t, "_rx", "_ry", "_result", coord_bytes, reverse_bytes_fn) + t.release_constant(POOL_GROUP_N) + t.release_constant(POOL_FIELD_P) # =========================================================================== @@ -978,8 +1059,8 @@ def _split_prefix(e: Callable) -> None: e(_make_stack_op(op="opcode", code="OP_SPLIT")) t.raw_block([pk_name], "", _split_prefix) - t.nm.append("_dk_prefix") - t.nm.append("_dk_xbytes") + t.push_tracked("_dk_prefix", Dom.UNKNOWN) + t.push_tracked("_dk_xbytes", Dom.UNKNOWN) # SEC1 sec 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: @@ -1063,7 +1144,7 @@ def _check_parity(e: Callable) -> None: t.raw_block(["_dk_pfn", "_dk_y_for_neg"], "_dk_neg_y", lambda e: e(_make_stack_op(op="opcode", code="OP_SUB"))) t.to_top("_dk_match") - t.nm.pop() # condition consumed by IF + t.pop_tracked() # condition consumed by IF then_ops = [_make_stack_op(op="drop")] # remove neg_y, keep y_cand else_ops = [_make_stack_op(op="nip")] # remove y_cand, keep neg_y @@ -1072,7 +1153,7 @@ def _check_parity(e: Callable) -> None: # Remove neg_y from tracker for i in range(len(t.nm) - 1, -1, -1): if t.nm[i] == "_dk_neg_y": - del t.nm[i] + t.remove_slot_at(i) break # Rename y_cand to qy_name @@ -1151,8 +1232,8 @@ def _gate(e: Callable) -> None: e(_make_stack_op(op="drop")) t.raw_block([name], "", _gate) - t.nm.append(flag_name) - t.nm.append(name) + t.push_tracked(flag_name, Dom.UNKNOWN) + t.push_tracked(name, Dom.UNKNOWN) def _c_emit_sig_range_gate(t: ECTracker, curve_n: int) -> None: @@ -1227,8 +1308,17 @@ def _c_emit_verify_ecdsa( sqrt_exp: int, gx: int, gy: int, + comb_curve: "CombCurve | None" = None, + opts: "EcCodegenOptions | None" = None, ) -> None: - t = ECTracker(["_msg", "_sig", "_pk"], emit) + t = ECTracker(["_msg", "_sig", "_pk"], emit, opts) + # The verifier does hundreds of reductions OUTSIDE the two ladders -- + # decompression's sqrt ladder, _c_group_inv, _c_affine_add, the final + # _c_group_mod. Each ladder pools separately: _c_emit_mul runs on its own + # tracker that deliberately cannot see this stack, so it cannot reach this + # slot. + t.pool_constant(POOL_FIELD_P, field_p) + t.pool_constant(POOL_GROUP_N, curve_n) # Step 0: length gate. ``_sig`` and ``_pk`` are bare ByteString in the # builtin table and the type checker imposes no width, so both arrive @@ -1264,8 +1354,8 @@ def _split_sig(e: Callable) -> None: e(_make_stack_op(op="opcode", code="OP_SPLIT")) t.raw_block(["_sig"], "", _split_sig) - t.nm.append("_r_bytes") - t.nm.append("_s_bytes") + t.push_tracked("_r_bytes", Dom.UNKNOWN) + t.push_tracked("_s_bytes", Dom.UNKNOWN) t.to_top("_r_bytes") @@ -1324,7 +1414,17 @@ def _s_to_num(e: Callable) -> None: # Step 7: R = u1*G + u2*Q point_bytes = coord_bytes * 2 g_point_data = _bigint_to_n_bytes(gx, coord_bytes) + _bigint_to_n_bytes(gy, coord_bytes) - t.push_bytes("_G", g_point_data) + + # u1*G. G is a compile-time constant, so this half can use a fixed-base comb + # -- one doubling and one add per COLUMN instead of per bit. u2*Q below + # cannot: Q arrives in the witness. + comb_ops = None + if opts is not None and opts.fixed_base_comb and comb_curve is not None: + comb_ops = _c_emit_comb_best( + coord_bytes, reverse_bytes_fn, field_p, p_minus_2, curve_n, comb_curve, opts) + + if comb_ops is None: + t.push_bytes("_G", g_point_data) t.to_top("_u1") # Stash items on altstack. @@ -1340,13 +1440,20 @@ def _s_to_num(e: Callable) -> None: t.to_top("_qx") t.to_alt() - # Remove _G and _u1 from tracker before cEmitMul - t.nm.pop() # _u1 - t.nm.pop() # _G + # The multiply creates its own ECTracker and cannot see items below its + # operands. Remove them from ours. + t.pop_tracked() # _u1 + if comb_ops is None: + t.pop_tracked() # _G - _c_emit_mul(emit, coord_bytes, reverse_bytes_fn, field_p, p_minus_2, curve_n, n_minus_2) + if comb_ops is not None: + for op in comb_ops: + emit(op) + else: + _c_emit_mul(emit, coord_bytes, reverse_bytes_fn, field_p, p_minus_2, + curve_n, n_minus_2, opts) - t.nm.append("_R1_point") + t.push_tracked("_R1_point", Dom.UNKNOWN) t.from_alt("_qx") t.from_alt("_qy") @@ -1359,11 +1466,12 @@ def _s_to_num(e: Callable) -> None: t.to_top("_u2") - t.nm.pop() # _u2 - t.nm.pop() # _Q_point + t.pop_tracked() # _u2 + t.pop_tracked() # _Q_point - _c_emit_mul(emit, coord_bytes, reverse_bytes_fn, field_p, p_minus_2, curve_n, n_minus_2) - t.nm.append("_R2_point") + _c_emit_mul(emit, coord_bytes, reverse_bytes_fn, field_p, p_minus_2, + curve_n, n_minus_2, opts) + t.push_tracked("_R2_point", Dom.UNKNOWN) t.from_alt("_R1_point") @@ -1420,46 +1528,219 @@ def _s_to_num(e: Callable) -> None: "_result", lambda e: e(_make_stack_op(op="opcode", code="OP_BOOLAND")), ) + t.release_constant(POOL_GROUP_N) + t.release_constant(POOL_FIELD_P) + + + +# =========================================================================== +# Fixed-base comb (the base is a compile-time constant) +# =========================================================================== + +def _c_emit_comb_mul_gen( + emit: Callable, + coord_bytes: int, + reverse_bytes_fn: Callable, + field_p: int, + p_minus_2: int, + curve_n: int, + curve: CombCurve, + w: int, + opts: "EcCodegenOptions | None" = None, +) -> bool: + """``k*G`` by a Lim-Lee comb, for a base known at compile time. + + The binary ladder runs one doubling and one conditional add per scalar BIT. + A comb splits the scalar into ``w`` blocks of ``d`` bits and runs one + doubling and one conditional add per COLUMN, so the round count falls from + ``w*d`` to ``d`` at the price of a ``2^w - 1`` entry table -- which costs + nothing to build here, because ``G`` is a constant. Measured optimum is w=3: + the selection logic grows as ``2^w`` and overtakes the saving by w=5. + + SOUNDNESS. The cheap incomplete mixed add cannot represent a pre-add + accumulator equal to the addend, its negation, or the point at infinity. + ``_c_build_jacobian_add_or_double_inline``'s comment justifies using it + everywhere but the last step of the BINARY ladder by an interval argument + over ``c_i mod n``, and insists that argument be re-derived by anything + changing the offset or the iteration count. A comb changes both, so it is + re-derived -- as executable interval arithmetic in ``comb_safe_rounds``, + evaluated here. Rounds it cannot prove get the complete add-or-double form + instead; nothing is assumed. For P-256 at w=3 it proves 81 of 86 rounds. + + The other half of that argument is that the accumulator never starts at + infinity, which needs the first digit non-zero. ``comb_geometry`` searches + for the scalar offset that guarantees it rather than reusing the ladder's + hardcoded ``+3n`` -- right for P-256 at w=3 and WRONG for P-384. + + Stack in: [_k]. Stack out: [_result]. False when no geometry exists. + """ + params = comb_geometry(w, curve) + if params is None: + return False + d = params.d + table = comb_table(w, d, curve) + safe = comb_safe_rounds(params, curve) + entries = (1 << w) - 1 + + t = ECTracker(["_k"], emit, opts) + t.pool_constant(POOL_FIELD_P, field_p) + t.pool_constant(POOL_GROUP_N, curve_n) + + # k' = (k mod n) + m*n. The reduce is what confines k to [0, n-1] and so what + # makes the interval argument apply at all; see _c_emit_scalar_reduce. + t.to_top("_k") + _c_emit_scalar_reduce(t, "_k", "_kr", curve_n) + t.rename("_k") + for i in range(params.offset_multiple): + off = f"_off{i}" + t.push_const(POOL_GROUP_N, curve_n, off) + t.raw_block(["_k", off], "_k", lambda e: e(_make_stack_op(op="opcode", code="OP_ADD"))) + t.set_domain("_k", Dom.NON_NEGATIVE) + + # Table, resident for the whole comb: picking an entry costs 2-3 bytes + # against a 34-byte literal push, and every round reads all of them. + for j in range(1, entries + 1): + pt = table[j] + t.push_big_int(f"_Tx{j}", pt.x) + t.push_big_int(f"_Ty{j}", pt.y) + t.set_domain(f"_Tx{j}", Dom.REDUCED) + t.set_domain(f"_Ty{j}", Dom.REDUCED) + + # Round d-1 initialises the accumulator. The first digit is non-zero by + # construction (comb_geometry), so this is a real point and never infinity. + _comb_emit_select(t, d - 1, w, d) + t.to_top("_flag") + t.drop() + t.to_top("ax") + t.rename("jx") + t.to_top("ay") + t.rename("jy") + t.push_int("jz", 1) + t.set_domain("jz", Dom.REDUCED) + + for i in range(d - 2, -1, -1): + _c_jacobian_double(t, field_p, p_minus_2) + _comb_emit_select(t, i, w, d) + + # `_c_jacobian_add_affine_body` documents its layout as + # [..., ax, ay, jx, jy, jz] and replaces the accumulator IN PLACE at the + # top. The selection leaves ax/ay above jz, so restore the contract + # before the branch -- otherwise the add arm would reorder the stack and + # the empty else arm would not, leaving the two arms with different + # layouts at OP_ENDIF. + t.to_top("_flag") + t.to_alt() + t.to_top("jx") + t.to_top("jy") + t.to_top("jz") + t.from_alt("_flag") + + t.pop_tracked() # consumed by OP_IF + add_ops: list = [] + if safe[i]: + _c_build_jacobian_add_affine_inline(add_ops.append, t, field_p, p_minus_2) + else: + _c_build_jacobian_add_or_double_inline(add_ops.append, t, field_p, p_minus_2) + emit(_make_stack_op(op="if", then=add_ops, else_=[])) + + # The addend was selected fresh for this round; the add only copied it. + t.to_top("ay") + t.drop() + t.to_top("ax") + t.drop() + + _c_jacobian_to_affine(t, "_rx", "_ry", field_p, p_minus_2) + + for j in range(entries, 0, -1): + t.to_top(f"_Ty{j}") + t.drop() + t.to_top(f"_Tx{j}") + t.drop() + t.to_top("_k") + t.drop() + + _c_compose_point(t, "_rx", "_ry", "_result", coord_bytes, reverse_bytes_fn) + t.release_constant(POOL_GROUP_N) + t.release_constant(POOL_FIELD_P) + return True + + +def _c_emit_comb_best( + coord_bytes: int, + reverse_bytes_fn: Callable, + field_p: int, + p_minus_2: int, + curve_n: int, + curve: CombCurve, + opts: "EcCodegenOptions | None" = None, +): + """Emit the cheapest comb over the candidate window widths. + + Each candidate is rendered in full and scored with the same byte-cost model + the emitter is measured by, and the smallest wins. + """ + best = None + for w in (2, 3, 4): + ops: list = [] + built = _c_emit_comb_mul_gen( + ops.append, coord_bytes, reverse_bytes_fn, field_p, p_minus_2, + curve_n, curve, w, opts) + if not built: + continue + if best is None or estimate_script_bytes(ops) < estimate_script_bytes(best): + best = ops + return best # =========================================================================== # P-256 public API # =========================================================================== -def emit_p256_add(emit: Callable) -> None: +def emit_p256_add(emit: Callable, opts: "EcCodegenOptions | None" = None) -> None: """Add two P-256 points. Stack in: [pa, pb], out: [result].""" - t = ECTracker(["_pa", "_pb"], emit) + t = ECTracker(["_pa", "_pb"], emit, opts) + t.pool_constant(POOL_FIELD_P, P256_P) _c_decompose_point(t, "_pa", "px", "py", 32, _emit_reverse32) _c_decompose_point(t, "_pb", "qx", "qy", 32, _emit_reverse32) _c_affine_add(t, P256_P, P256_P_MINUS_2) _c_compose_point(t, "rx", "ry", "_result", 32, _emit_reverse32) + t.release_constant(POOL_FIELD_P) -def emit_p256_mul(emit: Callable) -> None: +def emit_p256_mul(emit: Callable, opts: "EcCodegenOptions | None" = None) -> None: """P-256 scalar multiplication. Stack in: [point, scalar], out: [result].""" - _c_emit_mul(emit, 32, _emit_reverse32, P256_P, P256_P_MINUS_2, P256_N, P256_N_MINUS_2) + _c_emit_mul(emit, 32, _emit_reverse32, P256_P, P256_P_MINUS_2, P256_N, P256_N_MINUS_2, opts) -def emit_p256_mul_gen(emit: Callable) -> None: +def emit_p256_mul_gen(emit: Callable, opts: "EcCodegenOptions | None" = None) -> None: """P-256 generator multiplication. Stack in: [scalar], out: [result].""" + if opts is not None and opts.fixed_base_comb: + ops = _c_emit_comb_best(32, _emit_reverse32, P256_P, P256_P_MINUS_2, P256_N, P256_COMB_CURVE, opts) + if ops is not None: + for op in ops: + emit(op) + return g_point = _bigint_to_n_bytes(P256_GX, 32) + _bigint_to_n_bytes(P256_GY, 32) emit(_make_stack_op(op="push", value=_make_push_value(kind="bytes", bytes_=g_point))) emit(_make_stack_op(op="swap")) # [point, scalar] - emit_p256_mul(emit) + emit_p256_mul(emit, opts) -def emit_p256_negate(emit: Callable) -> None: +def emit_p256_negate(emit: Callable, opts: "EcCodegenOptions | None" = None) -> None: """Negate a P-256 point. Stack in: [point], out: [negated_point].""" - t = ECTracker(["_pt"], emit) + t = ECTracker(["_pt"], emit, opts) + t.pool_constant(POOL_FIELD_P, P256_P) _c_decompose_point(t, "_pt", "_nx", "_ny", 32, _emit_reverse32) _c_push_field_p(t, "_fp", P256_P) _c_field_sub(t, "_fp", "_ny", "_neg_y", P256_P) _c_compose_point(t, "_nx", "_neg_y", "_result", 32, _emit_reverse32) + t.release_constant(POOL_FIELD_P) -def emit_p256_on_curve(emit: Callable) -> None: +def emit_p256_on_curve(emit: Callable, opts: "EcCodegenOptions | None" = None) -> None: """Check if a P-256 point is on the curve (y^2 = x^3 - 3x + b mod p).""" - t = ECTracker(["_pt"], emit) + t = ECTracker(["_pt"], emit, opts) + t.pool_constant(POOL_FIELD_P, P256_P) _c_decompose_point(t, "_pt", "_x", "_y", 32, _emit_reverse32) _c_emit_canonicity_guard(t, "_x", "_y", P256_P) @@ -1482,6 +1763,7 @@ def emit_p256_on_curve(emit: Callable) -> None: t.to_top("_canon") t.to_top("_curve_eq") t.raw_block(["_canon", "_curve_eq"], "_result", lambda e: e(_make_stack_op(op="opcode", code="OP_BOOLAND"))) + t.release_constant(POOL_FIELD_P) def emit_p256_encode_compressed(emit: Callable) -> None: @@ -1506,7 +1788,7 @@ def emit_p256_encode_compressed(emit: Callable) -> None: emit(_make_stack_op(op="opcode", code="OP_CAT")) -def emit_verify_ecdsa_p256(emit: Callable) -> None: +def emit_verify_ecdsa_p256(emit: Callable, opts: "EcCodegenOptions | None" = None) -> None: """Verify an ECDSA signature on P-256. Stack in: [msg, sig (64 bytes r||s), pk (33 bytes compressed)] @@ -1524,6 +1806,8 @@ def emit_verify_ecdsa_p256(emit: Callable) -> None: sqrt_exp=P256_SQRT_EXP, gx=P256_GX, gy=P256_GY, + comb_curve=P256_COMB_CURVE, + opts=opts, ) @@ -1531,40 +1815,51 @@ def emit_verify_ecdsa_p256(emit: Callable) -> None: # P-384 public API # =========================================================================== -def emit_p384_add(emit: Callable) -> None: +def emit_p384_add(emit: Callable, opts: "EcCodegenOptions | None" = None) -> None: """Add two P-384 points. Stack in: [pa, pb], out: [result].""" - t = ECTracker(["_pa", "_pb"], emit) + t = ECTracker(["_pa", "_pb"], emit, opts) + t.pool_constant(POOL_FIELD_P, P384_P) _c_decompose_point(t, "_pa", "px", "py", 48, _emit_reverse48) _c_decompose_point(t, "_pb", "qx", "qy", 48, _emit_reverse48) _c_affine_add(t, P384_P, P384_P_MINUS_2) _c_compose_point(t, "rx", "ry", "_result", 48, _emit_reverse48) + t.release_constant(POOL_FIELD_P) -def emit_p384_mul(emit: Callable) -> None: +def emit_p384_mul(emit: Callable, opts: "EcCodegenOptions | None" = None) -> None: """P-384 scalar multiplication. Stack in: [point, scalar], out: [result].""" - _c_emit_mul(emit, 48, _emit_reverse48, P384_P, P384_P_MINUS_2, P384_N, P384_N_MINUS_2) + _c_emit_mul(emit, 48, _emit_reverse48, P384_P, P384_P_MINUS_2, P384_N, P384_N_MINUS_2, opts) -def emit_p384_mul_gen(emit: Callable) -> None: +def emit_p384_mul_gen(emit: Callable, opts: "EcCodegenOptions | None" = None) -> None: """P-384 generator multiplication. Stack in: [scalar], out: [result].""" + if opts is not None and opts.fixed_base_comb: + ops = _c_emit_comb_best(48, _emit_reverse48, P384_P, P384_P_MINUS_2, P384_N, P384_COMB_CURVE, opts) + if ops is not None: + for op in ops: + emit(op) + return g_point = _bigint_to_n_bytes(P384_GX, 48) + _bigint_to_n_bytes(P384_GY, 48) emit(_make_stack_op(op="push", value=_make_push_value(kind="bytes", bytes_=g_point))) emit(_make_stack_op(op="swap")) # [point, scalar] - emit_p384_mul(emit) + emit_p384_mul(emit, opts) -def emit_p384_negate(emit: Callable) -> None: +def emit_p384_negate(emit: Callable, opts: "EcCodegenOptions | None" = None) -> None: """Negate a P-384 point. Stack in: [point], out: [negated_point].""" - t = ECTracker(["_pt"], emit) + t = ECTracker(["_pt"], emit, opts) + t.pool_constant(POOL_FIELD_P, P384_P) _c_decompose_point(t, "_pt", "_nx", "_ny", 48, _emit_reverse48) _c_push_field_p(t, "_fp", P384_P) _c_field_sub(t, "_fp", "_ny", "_neg_y", P384_P) _c_compose_point(t, "_nx", "_neg_y", "_result", 48, _emit_reverse48) + t.release_constant(POOL_FIELD_P) -def emit_p384_on_curve(emit: Callable) -> None: +def emit_p384_on_curve(emit: Callable, opts: "EcCodegenOptions | None" = None) -> None: """Check if a P-384 point is on the curve (y^2 = x^3 - 3x + b mod p).""" - t = ECTracker(["_pt"], emit) + t = ECTracker(["_pt"], emit, opts) + t.pool_constant(POOL_FIELD_P, P384_P) _c_decompose_point(t, "_pt", "_x", "_y", 48, _emit_reverse48) _c_emit_canonicity_guard(t, "_x", "_y", P384_P) @@ -1587,6 +1882,7 @@ def emit_p384_on_curve(emit: Callable) -> None: t.to_top("_canon") t.to_top("_curve_eq") t.raw_block(["_canon", "_curve_eq"], "_result", lambda e: e(_make_stack_op(op="opcode", code="OP_BOOLAND"))) + t.release_constant(POOL_FIELD_P) def emit_p384_encode_compressed(emit: Callable) -> None: @@ -1611,7 +1907,7 @@ def emit_p384_encode_compressed(emit: Callable) -> None: emit(_make_stack_op(op="opcode", code="OP_CAT")) -def emit_verify_ecdsa_p384(emit: Callable) -> None: +def emit_verify_ecdsa_p384(emit: Callable, opts: "EcCodegenOptions | None" = None) -> None: """Verify an ECDSA signature on P-384. Stack in: [msg, sig (96 bytes r||s), pk (49 bytes compressed)] @@ -1629,4 +1925,6 @@ def emit_verify_ecdsa_p384(emit: Callable) -> None: sqrt_exp=P384_SQRT_EXP, gx=P384_GX, gy=P384_GY, + comb_curve=P384_COMB_CURVE, + opts=opts, ) diff --git a/compilers/python/runar_compiler/codegen/stack.py b/compilers/python/runar_compiler/codegen/stack.py index 7aaa5b2e8..935dbfb89 100644 --- a/compilers/python/runar_compiler/codegen/stack.py +++ b/compilers/python/runar_compiler/codegen/stack.py @@ -598,6 +598,12 @@ def __init__(self, params: Optional[list[str]], properties: list[ANFProperty]) - self.array_lengths: dict[str, int] = {} # Element refs for array_literal bindings (used by checkMultiSig). self.array_elements: dict[str, list[str]] = {} + # EXPERIMENTAL EC size options (constant pool, sign lattice / reduction + # sinking, fixed-base comb), handed down to the EC and NIST curve + # emitters. None -- not an all-false instance -- when nothing is + # enabled, so those emitters take their untouched default path and the + # emitted bytes are provably identical to the shipping ones. + self.ec_codegen = None # Issue #130 (stack layer): a method param whose name collides with a # MUTABLE property gets a duplicate stackMap slot once @@ -4274,7 +4280,10 @@ def _lower_ec_builtin(self, binding_name: str, func_name: str, fn = dispatch.get(func_name) if fn is None: raise RuntimeError(f"unknown EC builtin: {func_name}") - fn(emit_fn) + if func_name in ("ecAdd", "ecMul", "ecMulGen", "ecNegate", "ecOnCurve"): + fn(emit_fn, self.ec_codegen) + else: + fn(emit_fn) self.sm.push(binding_name) self._track_depth() @@ -4312,7 +4321,10 @@ def _lower_nist_ec_builtin(self, binding_name: str, func_name: str, fn = dispatch.get(func_name) if fn is None: raise RuntimeError(f"unknown NIST EC builtin: {func_name}") - fn(lambda op: self.emit_op(op)) + if func_name.endswith("EncodeCompressed"): + fn(lambda op: self.emit_op(op)) + else: + fn(lambda op: self.emit_op(op), self.ec_codegen) self.sm.push(binding_name) self._track_depth() @@ -4330,9 +4342,9 @@ def _lower_verify_ecdsa(self, binding_name: str, func_name: str, emit_fn = lambda op: self.emit_op(op) if func_name == "verifyECDSA_P256": - nist_mod.emit_verify_ecdsa_p256(emit_fn) + nist_mod.emit_verify_ecdsa_p256(emit_fn, self.ec_codegen) else: - nist_mod.emit_verify_ecdsa_p384(emit_fn) + nist_mod.emit_verify_ecdsa_p384(emit_fn, self.ec_codegen) self.sm.push(binding_name) self._track_depth() @@ -4724,7 +4736,7 @@ def _method_reads_var_len_state( # Public API # --------------------------------------------------------------------------- -def lower_to_stack(program: ANFProgram) -> list[StackMethod]: +def lower_to_stack(program: ANFProgram, ec_codegen=None) -> list[StackMethod]: """Convert an ANF program to a list of StackMethods. Private methods are inlined at call sites rather than compiled separately. @@ -4736,7 +4748,7 @@ def lower_to_stack(program: ANFProgram) -> list[StackMethod]: """ from runar_compiler.ir.unknown_anf_kind_error import UnknownANFKindError try: - return _lower_to_stack_inner(program) + return _lower_to_stack_inner(program, ec_codegen) except RuntimeError: # RuntimeError messages are already descriptive (e.g. "stack underflow", # "unknown binary operator: ...", "value 'x' not found on stack"). @@ -4750,7 +4762,7 @@ def lower_to_stack(program: ANFProgram) -> list[StackMethod]: raise RuntimeError(f"stack lowering: {e}") from e -def _lower_to_stack_inner(program: ANFProgram) -> list[StackMethod]: +def _lower_to_stack_inner(program: ANFProgram, ec_codegen=None) -> list[StackMethod]: """Inner implementation of lower_to_stack (unwrapped).""" # Build map of private methods for inlining private_methods: dict[str, ANFMethod] = {} @@ -4764,7 +4776,8 @@ def _lower_to_stack_inner(program: ANFProgram) -> list[StackMethod]: # Skip constructor and private methods if method.name == "constructor" or (not method.is_public and method.name != "constructor"): continue - sm = _lower_method_with_private_methods(method, program.properties, private_methods) + sm = _lower_method_with_private_methods( + method, program.properties, private_methods, ec_codegen) methods.append(sm) return methods @@ -4774,6 +4787,7 @@ def _lower_method_with_private_methods( method: ANFMethod, properties: list[ANFProperty], private_methods: dict[str, ANFMethod], + ec_codegen=None, ) -> StackMethod: param_names = [p.name for p in method.params] @@ -4799,6 +4813,7 @@ def _lower_method_with_private_methods( param_names = ["_codePart"] + param_names ctx = _LoweringContext(param_names, properties) + ctx.ec_codegen = ec_codegen ctx.private_methods = private_methods # Pass terminalAssert=true for public methods ctx.lower_bindings(method.body, method.is_public) @@ -4831,10 +4846,12 @@ def _lower_method_with_private_methods( def _lower_method( method: ANFMethod, properties: list[ANFProperty], + ec_codegen=None, ) -> StackMethod: param_names = [p.name for p in method.params] ctx = _LoweringContext(param_names, properties) + ctx.ec_codegen = ec_codegen ctx.lower_bindings(method.body, method.is_public) # Clean up excess stack items below the top-of-stack boolean (CLEANSTACK). diff --git a/compilers/python/runar_compiler/compiler.py b/compilers/python/runar_compiler/compiler.py index 3cc8c9abd..d0a32f626 100644 --- a/compilers/python/runar_compiler/compiler.py +++ b/compilers/python/runar_compiler/compiler.py @@ -221,10 +221,32 @@ def _eliminate_dead_code(program: ANFProgram) -> ANFProgram: return eliminate_dead_code(program) -def _lower_to_stack(program: ANFProgram) -> list[Any]: +def _lower_to_stack(program: ANFProgram, ec_codegen=None) -> list[Any]: """Stack lowering: ANF -> Stack IR.""" from runar_compiler.codegen.stack import lower_to_stack - return lower_to_stack(program) + return lower_to_stack(program, ec_codegen) + + +def _ec_codegen_options(ec_constant_pool: bool, ec_reduction_sinking: bool, + ec_fixed_base_comb: bool): + """Options handed to the EC / NIST codegen modules. + + Returns None -- not an all-false instance -- when nothing is enabled, so + those emitters take their untouched default path and the emitted bytes are + provably identical to the shipping ones. + + Cross-tier byte parity for the flags THEMSELVES is gated by + conformance/ec-flag-parity/expected.json, replayed in + tests/test_ec_flag_parity.py. + """ + if not (ec_constant_pool or ec_reduction_sinking or ec_fixed_base_comb): + return None + from runar_compiler.codegen.ec import EcCodegenOptions + return EcCodegenOptions( + constant_pool=ec_constant_pool, + reduction_sinking=ec_reduction_sinking, + fixed_base_comb=ec_fixed_base_comb, + ) def _optimize_stack_ops(ops: list[Any]) -> list[Any]: @@ -438,7 +460,13 @@ def compile_from_ir_bytes(data: bytes, disable_constant_folding: bool = False) - return compile_from_program(program, disable_constant_folding=True) -def compile_from_program(program: ANFProgram, disable_constant_folding: bool = False) -> Artifact: +def compile_from_program( + program: ANFProgram, + disable_constant_folding: bool = False, + ec_constant_pool: bool = False, + ec_reduction_sinking: bool = False, + ec_fixed_base_comb: bool = False, +) -> Artifact: """Compile a parsed ANF program to a Runar artifact.""" # Pass 4.25: Constant folding (on by default) if not disable_constant_folding: @@ -449,7 +477,8 @@ def compile_from_program(program: ANFProgram, disable_constant_folding: bool = F program = _optimize_ec(program) # Pass 5: Stack lowering - stack_methods = _lower_to_stack(program) + stack_methods = _lower_to_stack(program, _ec_codegen_options( + ec_constant_pool, ec_reduction_sinking, ec_fixed_base_comb)) # Peephole optimization -- runs on Stack IR before emission. for sm in stack_methods: @@ -476,6 +505,9 @@ def compile_from_source( source_path: str, disable_constant_folding: bool = False, constructor_args: dict[str, object] | None = None, + ec_constant_pool: bool = False, + ec_reduction_sinking: bool = False, + ec_fixed_base_comb: bool = False, ) -> Artifact: """Compile a source file through all passes to a Runar artifact. @@ -514,7 +546,13 @@ def compile_from_source( _apply_constructor_args(program, constructor_args) # Feed into existing compilation pipeline (passes 4.25-6) - return compile_from_program(program, disable_constant_folding=disable_constant_folding) + return compile_from_program( + program, + disable_constant_folding=disable_constant_folding, + ec_constant_pool=ec_constant_pool, + ec_reduction_sinking=ec_reduction_sinking, + ec_fixed_base_comb=ec_fixed_base_comb, + ) def compile_source_to_ir( diff --git a/compilers/python/tests/test_ec_flag_parity.py b/compilers/python/tests/test_ec_flag_parity.py new file mode 100644 index 000000000..a422b323e --- /dev/null +++ b/compilers/python/tests/test_ec_flag_parity.py @@ -0,0 +1,118 @@ +"""Cross-tier parity for the EXPERIMENTAL EC size flags. + +The flags default off, so the ordinary conformance suite -- which compiles with +defaults -- cannot see them at all. Seven tiers could each ship a DIFFERENT +``--ec-constant-pool`` and the suite would stay green. + +That matters because the flags are not cosmetic: they change which reduction +form is emitted and which addition formula each ladder round uses. A tier that +ports the constant pool but not the sign lattice's ``REDUCED`` precondition +produces a script that is smaller, passes its own tests, and is wrong on +``ecAdd((0,1), (2^256-1,1))``. Byte-identical output against a single reference +is the only cheap check that catches that. + +``conformance/ec-flag-parity/expected.json`` is derived from the TypeScript +reference compiler and re-derived by its own vitest, so it cannot go stale. +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import pytest + +from runar_compiler.codegen import ec, p256_p384 +from runar_compiler.codegen.emit import emit_method +from runar_compiler.codegen.stack import StackMethod + +FIXTURE = (Path(__file__).resolve().parents[3] + / "conformance" / "ec-flag-parity" / "expected.json") + +_FIELD = { + "constantPool": "constant_pool", + "reductionSinking": "reduction_sinking", + "fixedBaseComb": "fixed_base_comb", +} + + +def _ignore_opts(fn): + """Adapt an emitter the flags cannot reach to the options-taking shape. + + These are deliberately included: a tier that accidentally made + ``ecModReduce`` or ``ecPointX`` flag-sensitive would be diverging just as + badly as one that ignored a flag. + """ + return lambda e, _opts=None: fn(e) + + +EMITTERS = { + "EcAdd": ec.emit_ec_add, + "EcMul": ec.emit_ec_mul, + "EcMulGen": ec.emit_ec_mul_gen, + "EcNegate": ec.emit_ec_negate, + "EcOnCurve": ec.emit_ec_on_curve, + "EcModReduce": _ignore_opts(ec.emit_ec_mod_reduce), + "EcEncodeCompressed": _ignore_opts(ec.emit_ec_encode_compressed), + "EcMakePoint": _ignore_opts(ec.emit_ec_make_point), + "EcPointX": _ignore_opts(ec.emit_ec_point_x), + "EcPointY": _ignore_opts(ec.emit_ec_point_y), + "P256Add": p256_p384.emit_p256_add, + "P256Mul": p256_p384.emit_p256_mul, + "P256MulGen": p256_p384.emit_p256_mul_gen, + "P256Negate": p256_p384.emit_p256_negate, + "P256OnCurve": p256_p384.emit_p256_on_curve, + "P256EncodeCompressed": _ignore_opts(p256_p384.emit_p256_encode_compressed), + "VerifyECDSA_P256": p256_p384.emit_verify_ecdsa_p256, + "P384Add": p256_p384.emit_p384_add, + "P384Mul": p256_p384.emit_p384_mul, + "P384MulGen": p256_p384.emit_p384_mul_gen, + "P384Negate": p256_p384.emit_p384_negate, + "P384OnCurve": p256_p384.emit_p384_on_curve, + "P384EncodeCompressed": _ignore_opts(p256_p384.emit_p384_encode_compressed), + "VerifyECDSA_P384": p256_p384.emit_verify_ecdsa_p384, +} + + +def _fixture() -> dict: + return json.loads(FIXTURE.read_text()) + + +def _emit_and_hash(fn, opts) -> tuple[int, str]: + ops: list = [] + fn(ops.append, opts) + res = emit_method(StackMethod(name="t", ops=ops)) + raw = bytes.fromhex(res.script_hex) + return len(raw), hashlib.sha256(raw).hexdigest() + + +@pytest.mark.parametrize("name", sorted(EMITTERS)) +def test_ec_flag_parity_against_typescript_reference(name: str) -> None: + fx = _fixture() + want = fx["emitters"][name] + for variant, spec in fx["variants"].items(): + opts = (ec.EcCodegenOptions(**{_FIELD[k]: v for k, v in spec.items()}) + if spec else None) + got = _emit_and_hash(EMITTERS[name], opts) + expect = (want[variant]["bytes"], want[variant]["sha256"]) + assert got == expect, ( + f"{name} under {variant}: Python emits {got[0]} bytes, " + f"the TypeScript reference emits {expect[0]}" + ) + + +@pytest.mark.parametrize("name", sorted(EMITTERS)) +def test_ec_flags_default_off_is_byte_identical(name: str) -> None: + """``None`` options must reproduce the shipping output. + + This is what keeps the existing goldens, the size baseline and every + cross-tier hex comparison from moving while the flags are experimental. + """ + fx = _fixture() + none_size, none_hash = _emit_and_hash(EMITTERS[name], None) + off_size, off_hash = _emit_and_hash(EMITTERS[name], ec.EcCodegenOptions()) + assert (none_size, none_hash) == (off_size, off_hash), \ + f"{name}: None and all-false options disagree" + assert none_hash == fx["emitters"][name]["off"]["sha256"], \ + f"{name}: default output moved" diff --git a/compilers/ruby/lib/runar_compiler/cli.rb b/compilers/ruby/lib/runar_compiler/cli.rb index 51db7fffd..61e50c17f 100644 --- a/compilers/ruby/lib/runar_compiler/cli.rb +++ b/compilers/ruby/lib/runar_compiler/cli.rb @@ -113,6 +113,21 @@ def run(argv = ARGV) options[:disable_constant_folding] = true end + opts.on("--ec-constant-pool", + "EXPERIMENTAL: pool repeated EC curve constants (changes emitted bytes)") do + options[:ec_constant_pool] = true + end + + opts.on("--ec-reduction-sinking", + "EXPERIMENTAL: drop provably-dead sign fix-ups from EC modular reductions") do + options[:ec_reduction_sinking] = true + end + + opts.on("--ec-fixed-base-comb", + "EXPERIMENTAL: comb multiplication where the base point is a compile-time constant") do + options[:ec_fixed_base_comb] = true + end + opts.on("--emit-source-map PATH", "After a successful compile, write artifact.sourceMap JSON to PATH") do |path| options[:emit_source_map] = path end @@ -207,7 +222,10 @@ def run(argv = ARGV) if options[:source] artifact = RunarCompiler.compile_from_source( options[:source], - disable_constant_folding: disable_cf + disable_constant_folding: disable_cf, + ec_constant_pool: options[:ec_constant_pool] || false, + ec_reduction_sinking: options[:ec_reduction_sinking] || false, + ec_fixed_base_comb: options[:ec_fixed_base_comb] || false ) else artifact = RunarCompiler.compile_from_ir( diff --git a/compilers/ruby/lib/runar_compiler/codegen/comb.rb b/compilers/ruby/lib/runar_compiler/codegen/comb.rb new file mode 100644 index 000000000..df536866e --- /dev/null +++ b/compilers/ruby/lib/runar_compiler/codegen/comb.rb @@ -0,0 +1,294 @@ +# frozen_string_literal: true + +# Fixed-base comb: compile-time table, and the soundness check that decides +# where the cheap incomplete addition may be used. +# +# Port of packages/runar-compiler/src/passes/comb.ts. The binary ladders in +# ec.rb / p256_p384.rb use the cheap mixed add at every step but the last, +# justified by an interval argument over c_i mod n. That comment is emphatic +# that the argument must be RE-DERIVED, not assumed, by anything which changes +# the offset, the iteration count, or the reduce -- and a comb changes all +# three. comb_safe_rounds below is that re-derivation, written as executable +# interval arithmetic rather than prose, so a round only gets the cheap add when +# the exception is proved unreachable. Rounds it cannot prove fall back to the +# complete add-or-double form. +# +# Nothing here emits Script. It is pure integer arithmetic, run once per +# compilation, and unit-tested against published curve vectors. + +module RunarCompiler + module Codegen + module Comb + # An affine point. nil is the point at infinity. + Point = Struct.new(:x, :y) + + # A short-Weierstrass curve, for the compile-time table. + # + # p: field prime. a: curve coefficient (-3 on the NIST curves, 0 on + # secp256k1). b: curve coefficient. n: group order. g: base point. + Curve = Struct.new(:p, :a, :b, :n, :g) + + # Comb geometry for one window width, chosen so the top digit is never + # zero. + # + # The binary ladder hardcodes k + 3n, which puts the scalar's top bit at a + # fixed position and so keeps the accumulator off the point at infinity. A + # comb needs the same guarantee, but its first round reads bit w*d - 1, so + # the offset has to be chosen against w*d rather than assumed. + # offset_multiple is the smallest m for which every k + m*n has bit + # w*d - 1 set: + # + # m*n >= 2^(w*d - 1) and (m+1)*n - 1 < 2^(w*d) + # + # m*n == 0 (mod n) so the result is unchanged. For P-256 at w=3 the search + # returns m=3, d=86 -- i.e. exactly the +3n the binary ladder already + # uses. For P-384 at w=3 it returns m=5, d=129; assuming +3n there would + # have left the top digit free to be zero. + # + # d is the round count and the block width: digit i reads bits + # i, i+d, ..., i+(w-1)d. lo/hi are the inclusive scalar domain after the + # offset. + Params = Struct.new(:w, :d, :offset_multiple, :lo, :hi) + + P256_COMB_CURVE = Curve.new( + 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffff, + -3, + 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b, + 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551, + Point.new( + 0x6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296, + 0x4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5 + ) + ).freeze + + P384_COMB_CURVE = Curve.new( + 0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffeffffffff0000000000000000ffffffff, + -3, + 0xb3312fa7e23ee7e4988e056be3f82d19181d9c6efe8141120314088f5013875ac656398d8a2ed19d2a85c8edd3ec2aef, + 0xffffffffffffffffffffffffffffffffffffffffffffffffc7634d81f4372ddf581a0db248b0a77aecec196accc52973, + Point.new( + 0xaa87ca22be8b05378eb1c71ef320ad746e1d3b628ba79b9859f741e082542a385502f25dbf55296c3a545e3872760ab7, + 0x3617de4a96262c6f5d9e98bf9292dc29f8f41dbd289a147ce9da3113b5f0b8c00a60b1ce1d7e819d7a431d7c90ea0e5f + ) + ).freeze + + # secp256k1. NOT built from the NIST template: it is y^2 = x^3 + 7, so + # a = 0. Getting `a` wrong here does not produce an obviously broken table + # -- it produces a table of points on a DIFFERENT curve, which that other + # curve's on-curve check would happily accept. Hence the published 2G + # vectors pinned in the tests. + SECP256K1_COMB_CURVE = Curve.new( + 0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f, + 0, + 7, + 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141, + Point.new( + 0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798, + 0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8 + ) + ).freeze + + # Geometry for window width w, or nil if no offset in the search range + # puts a guaranteed set bit at the top of the first digit. Returning nil + # rather than guessing keeps the caller from silently combing a scalar + # whose leading digit can vanish. + # + # @param w [Integer] + # @param c [Curve] + # @return [Params, nil] + def self.comb_geometry(w, c) + base = (c.n.bit_length + w - 1) / w + (base..base + 2).each do |d| + bits = w * d + top = 1 << (bits - 1) + cap = 1 << bits + (1..16).each do |m| + lo = m * c.n + hi = (m + 1) * c.n - 1 + return Params.new(w, d, m, lo, hi) if lo >= top && hi < cap + end + end + nil + end + + # --------------------------------------------------------------- + # Affine arithmetic (compile time only) + # --------------------------------------------------------------- + + # Modular inverse by extended Euclid. + # + # Ruby's Integer#pow rejects a negative exponent, so there is no + # `x.pow(-1, m)` shortcut here as there is in Python. + # + # @param v [Integer] + # @param m [Integer] + # @return [Integer] + def self.mod_inverse(v, m) + old_r = v % m + r = m + old_s = 1 + s = 0 + while r != 0 + q = old_r / r + old_r, r = r, old_r - q * r + old_s, s = s, old_s - q * s + end + old_s % m + end + private_class_method :mod_inverse + + # Affine addition. nil is the point at infinity. + # + # @param p [Point, nil] + # @param q [Point, nil] + # @param c [Curve] + # @return [Point, nil] + def self.comb_affine_add(p, q, c) + return q if p.nil? + return p if q.nil? + + if p.x == q.x + return nil if (p.y + q.y) % c.p == 0 # P == -Q + + # Tangent. + num = (3 * p.x * p.x + c.a) % c.p + lam = (num * mod_inverse((2 * p.y) % c.p, c.p)) % c.p + x = (lam * lam - 2 * p.x) % c.p + return Point.new(x, (lam * (p.x - x) - p.y) % c.p) + end + + lam = (((q.y - p.y) % c.p) * mod_inverse((q.x - p.x) % c.p, c.p)) % c.p + x = (lam * lam - p.x - q.x) % c.p + Point.new(x, (lam * (p.x - x) - p.y) % c.p) + end + + # Compile-time double-and-add. nil is the point at infinity. + # + # @param k [Integer] + # @param p [Point] + # @param c [Curve] + # @return [Point, nil] + def self.comb_scalar_mul(k, p, c) + r = nil + base = p + e = k % c.n + while e > 0 + r = comb_affine_add(r, base, c) if e.odd? + base = comb_affine_add(base, base, c) + e >>= 1 + end + r + end + + # --------------------------------------------------------------- + # Comb table + # --------------------------------------------------------------- + + # The multiple of G that table entry j represents. + # + # Comb round i consumes bits {i, i+d, i+2d, ...} of the scalar -- one from + # each block -- so entry j stands for the sum of 2^(t*d) over the set bits + # t of j. + # + # @param j [Integer] + # @param d [Integer] + # @return [Integer] + def self.comb_value(j, d) + v = 0 + t = 0 + while (j >> t) != 0 + v += 1 << (t * d) if ((j >> t) & 1) == 1 + t += 1 + end + v + end + + # T[j] = comb_value(j)*G. Index 0 is infinity and is never added. + # + # @return [Array] + def self.comb_table(w, d, c) + (0...(1 << w)).map { |j| j.zero? ? nil : comb_scalar_mul(comb_value(j, d), c.g, c) } + end + + # --------------------------------------------------------------- + # Soundness: where may the cheap incomplete addition be used? + # --------------------------------------------------------------- + + # Bounds on the comb accumulator's multiplier before round i's doubling. + # + # After processing rounds d-1 .. i, the accumulator is c_i*G with + # + # c_i = sum_m 2^(m*d) * floor(K_m / 2^i) + # + # where K_m is the m-th d-bit block of the expanded scalar. Each floor + # discards less than one unit of its block, so + # + # k/2^i - sum_m 2^(m*d) < c_i <= k/2^i + # + # and with k confined to [lo, hi] that gives a contiguous interval. The + # slack term is bounded by 2^(w*d)/(2^d - 1), far below n, which is why the + # interval stays narrower than the group order for all but the last few + # rounds -- exactly the property the binary ladder's argument relies on. + def self.accumulator_interval(i, params) + slack = (0...params.w).sum { |m| 1 << (m * params.d) } + hi = params.hi >> i + lo = (params.lo >> i) - slack + [lo.negative? ? 0 : lo, hi] + end + private_class_method :accumulator_interval + + # Does [lo, hi] contain an integer congruent to target modulo n? + def self.interval_hits_residue(lo, hi, target, n) + return false if hi < lo + return true if hi - lo + 1 >= n # wraps a full residue class + + t = target % n + # Smallest value >= lo that is congruent to t (mod n). + first = lo + ((t - lo) % n) + first <= hi + end + private_class_method :interval_hits_residue + + # Per-round verdict: may round i use the cheap incomplete mixed add? + # + # The exception the cheap formula cannot represent is a pre-add + # accumulator equal to the addend, its negation, or the point at infinity. + # After round i's doubling the accumulator is 2*c_{i+1}*G, and the addend + # is comb_value(j)*G for whichever digit j the scalar selects -- so the + # round is safe exactly when, for every j, + # + # 2*c_{i+1} != 0, +comb_value(j), -comb_value(j) (mod n) + # + # over the whole interval of c_{i+1}. Both G and every table entry are + # compile-time constants and the curves have cofactor 1, so ord(G) = n and + # this is decidable here. Anything the checker cannot prove gets the + # complete add-or-double form instead; true is never assumed. + # + # Index d-1 is false by construction: that round initialises the + # accumulator from the table and performs no addition at all. + # + # @return [Array] + def self.comb_safe_rounds(params, c) + values = (1...(1 << params.w)).map { |j| comb_value(j, params.d) } + + safe = Array.new(params.d, false) + (0...params.d).each do |i| + next if i == params.d - 1 + + lo, hi = accumulator_interval(i + 1, params) + d_lo = 2 * lo + d_hi = 2 * hi + ok = !interval_hits_residue(d_lo, d_hi, 0, c.n) + values.each do |v| + break unless ok + + ok = !interval_hits_residue(d_lo, d_hi, v, c.n) && + !interval_hits_residue(d_lo, d_hi, -v, c.n) + end + safe[i] = ok + end + safe + end + end + end +end diff --git a/compilers/ruby/lib/runar_compiler/codegen/cost_model.rb b/compilers/ruby/lib/runar_compiler/codegen/cost_model.rb new file mode 100644 index 000000000..ee828260c --- /dev/null +++ b/compilers/ruby/lib/runar_compiler/codegen/cost_model.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +# Script-byte cost model for Stack IR. +# +# Port of packages/runar-compiler/src/metrics/cost-model.ts. Optimizer passes +# need to compare two candidate lowerings by the metric that actually matters -- +# serialized locking-script bytes -- before either one is emitted. OP_DUP and a +# 33-byte constant push are one instruction each and 1 vs 34 bytes; an +# instruction count cannot tell them apart. +# +# This is deliberately NOT an approximation: every push routes through the same +# encoders emit.rb uses, so +# +# estimate_script_bytes(ops) == emit_method(...).script_hex.length / 2 +# +# holds exactly. test_cost_model.rb asserts that over every crypto emitter. + +module RunarCompiler + module Codegen + module CostModel + # Serialized byte cost of a single push value. + # + # Mirrors encode_push_value in emit.rb: booleans are the 1-byte OP_TRUE / + # OP_FALSE, integers go through the small-int opcodes where possible, and + # byte strings are MINIMALDATA-aware before falling back to a + # length-prefixed push. + # + # @param value [Hash] PushValue hash + # @return [Integer] + def self.size_of_push_value(value) + hex, _asm = Codegen.encode_push_value(value) + hex.length / 2 + end + + # size_of_push_value for a bare integer -- what the constant pool and the + # comb width search compare against. + # + # @param n [Integer] + # @return [Integer] + def self.size_of_push_int(n) + size_of_push_value({ kind: "bigint", big_int: n }) + end + + # Serialized byte cost of one Stack IR operation, including nested arms. + # + # Note on pick / roll: they cost ONE byte here. The depth operand is a + # separate push op that the tracker emits immediately before, so charging + # the depth here would double-count it. + # + # Raises on an unknown opcode mnemonic rather than costing it zero -- a + # typo in a codegen module should surface loudly, not as a cost model that + # quietly under-reports. + # + # @param op [Hash] StackOp hash + # @return [Integer] + def self.size_of_stack_op(op) + kind = op[:op] + case kind + when "push" + size_of_push_value(op[:value]) + when "dup", "swap", "roll", "pick", "drop", "nip", "over", "rot", "tuck" + 1 + when "opcode" + raise "cost-model: unknown opcode '#{op[:code]}'" if Codegen::OPCODES[op[:code]].nil? + + 1 + when "if" + # OP_IF + then + [OP_ELSE + else] + OP_ENDIF. The emitter writes + # OP_ELSE only for a NON-EMPTY else arm. + total = 2 + total += estimate_script_bytes(op[:then] || []) + else_ops = op[:else_ops] || [] + total += 1 + estimate_script_bytes(else_ops) unless else_ops.empty? + total + when "placeholder", "push_codesep_index" + # Both emit a single 0x00 byte that the SDK rewrites later. + 1 + when "raw_bytes" + (op[:raw_bytes] || "").bytesize + else + raise "cost-model: unknown stack op kind '#{kind}'" + end + end + + # Serialized byte cost of a Stack IR sequence. + # + # @param ops [Array] + # @return [Integer] + def self.estimate_script_bytes(ops) + ops.sum { |op| size_of_stack_op(op) } + end + end + end +end diff --git a/compilers/ruby/lib/runar_compiler/codegen/ec.rb b/compilers/ruby/lib/runar_compiler/codegen/ec.rb index 1e257216e..36ab23a47 100644 --- a/compilers/ruby/lib/runar_compiler/codegen/ec.rb +++ b/compilers/ruby/lib/runar_compiler/codegen/ec.rb @@ -11,6 +11,8 @@ # Direct port of compilers/python/runar_compiler/codegen/ec.py require "set" +require_relative "comb" +require_relative "cost_model" module RunarCompiler module Codegen @@ -31,6 +33,9 @@ module EC # secp256k1 generator y-coordinate EC_GEN_Y = 0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8 + # secp256k1 curve order + EC_CURVE_N = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141 + # Convert an integer to a 32-byte big-endian binary string. # # @param n [Integer] @@ -74,6 +79,74 @@ def self.big_int_push(n) make_push_value(kind: "bigint", big_int: n) end + # ================================================================= + # Codegen options and sign lattice + # ================================================================= + + # Codegen options shared by every EC / NIST-curve emitter. + # + # Off by default: with nil (or an all-false instance) each emitter is + # byte-identical to what the seven tiers ship today, so no golden, size + # baseline, or cross-tier parity gate can move. + # + # constant_pool: park large repeated constants (the field prime, the group + # order) in a stack slot and copy them with OP_PICK instead of + # re-pushing the literal. field_mod pushes the 256-bit prime at every + # modular reduction -- 34 bytes a time, 20,025 times in p256-wallet + # (71 % of that fixture). A pick from a slot a dozen deep costs 2. + # reduction_sinking: 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. Which + # reductions qualify is decided by the sign lattice below -- never + # assumed. Only useful alongside constant_pool: the cheap subtraction + # references the prime twice, so without a pooled slot it does not pay + # (and the emitters compare the two costs, so it is never taken when it + # does not). + # fixed_base_comb: use a fixed-base comb instead of the binary ladder + # wherever the base point is a compile-time constant. The window width + # is not fixed here: the emitter renders each candidate and keeps + # whichever the byte-cost model scores smallest. + EcCodegenOptions = Struct.new(:constant_pool, :reduction_sinking, :fixed_base_comb) do + def initialize(constant_pool: false, reduction_sinking: false, fixed_base_comb: false) + super(constant_pool, reduction_sinking, fixed_base_comb) + end + end + + # What is known about a tracked value's sign and range. + # + # DOM_REDUCED implies DOM_NON_NEGATIVE; the ordering is what the transfer + # functions meet over. DOM_UNKNOWN is the default for every slot the + # analysis has not explicitly proved something about -- including + # everything a raw_block 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 DOM_NON_NEGATIVE but NOT DOM_REDUCED: a coordinate may + # legitimately be up to 2^256 - 1 while p is 2^32 + 977 smaller. + # Multiplication and addition need only DOM_NON_NEGATIVE; subtraction's + # cheap form needs the subtrahend DOM_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)). + + # Nothing known. May be negative. + DOM_UNKNOWN = 0 + # Provably >= 0. May be >= p. + DOM_NON_NEGATIVE = 1 + # Provably in [0, p). + DOM_REDUCED = 2 + + # True when d proves the value is >= 0. + # + # @param d [Integer] + # @return [Boolean] + def self.non_negative?(d) + d >= DOM_NON_NEGATIVE + end + + # Stack slot names reserved for pooled constants. + POOL_FIELD_P = "_pool$p" + POOL_GROUP_N = "_pool$n" + # ================================================================= # ECTracker -- named stack state tracker (mirrors TS ECTracker) # ================================================================= @@ -81,14 +154,111 @@ def self.big_int_push(n) class ECTracker # @return [Array] named stack entries attr_accessor :nm + # @return [Array] sign-lattice fact per stack SLOT + attr_accessor :dm # @return [Proc] emit callback attr_reader :e + # @return [Boolean] may this tracker serve constants from a pooled slot? + attr_reader :pooling + # @return [Boolean] may this tracker emit sunk reductions? + attr_reader :sinking + # @return [Boolean] may a compile-time-known base use a fixed-base comb? + attr_reader :comb # @param init [Array] initial stack names # @param emit [Proc] callback receiving a StackOp hash - def initialize(init, emit) + # @param opts [EcCodegenOptions, nil] codegen options + # @param init_domains [Array, nil] lattice facts for init slots + def initialize(init, emit, opts = nil, init_domains = nil) @nm = init.dup + # dm is kept parallel to nm, slot by slot. + # + # 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 hash 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 = init_domains ? init_domains.dup : Array.new(@nm.length, DOM_UNKNOWN) + # Lattice facts for values parked on the alt stack, bottom -> top. + @alt_dm = [] @e = emit + @pooling = opts ? !!opts.constant_pool : false + @sinking = opts ? !!opts.reduction_sinking : false + @comb = opts ? !!opts.fixed_base_comb : false + end + + # The options this tracker was built with, for a nested tracker. + # + # @return [EcCodegenOptions] + def options + EcCodegenOptions.new(constant_pool: @pooling, reduction_sinking: @sinking, + fixed_base_comb: @comb) + end + + # -- sign lattice --------------------------------------------- + + # What is known about the named value. DOM_UNKNOWN when absent. + # + # @param name [String] + # @return [Integer] + def domain_of(name) + # 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 @dm.length != @nm.length + raise "ECTracker: lattice desynchronised (#{@nm.length} slots, " \ + "#{@dm.length} facts). Every nm mutation must go through a " \ + "tracker method or push_tracked/pop_tracked." + end + i = @nm.length - 1 + while i >= 0 + return @dm[i] if @nm[i] == name + + i -= 1 + end + DOM_UNKNOWN + end + + # Record a fact about the named value's slot. + # + # @param name [String] + # @param d [Integer] + def set_domain(name, d) + i = @nm.length - 1 + while i >= 0 + if @nm[i] == name + @dm[i] = d + return + end + i -= 1 + end + end + + # Push a slot the caller tracks itself (where raw opcodes create items). + def push_tracked(name, d = DOM_UNKNOWN) + @nm.push(name) + @dm.push(d) + end + + # Pop a slot the caller tracks itself. Mirror of push_tracked. + def pop_tracked + return "" if @nm.empty? + + @dm.pop + @nm.pop + end + + # Remove the slot at an absolute (bottom-relative) index. + # + # @return [Array(String, Integer)] + def remove_slot_at(index) + n = @nm.delete_at(index) + d = @dm.delete_at(index) + [n, d] + end + + def depth + @nm.length end # Find the depth (distance from top) of a named stack entry. @@ -99,67 +269,54 @@ def find_depth(name) i = @nm.length - 1 while i >= 0 return @nm.length - 1 - i if @nm[i] == name + i -= 1 end raise "ECTracker: '#{name}' not on stack #{@nm}" end # Push raw bytes onto the stack. - # - # @param n [String] stack entry name - # @param v [String] binary string of bytes def push_bytes(n, v) @e.call(EC.make_stack_op(op: "push", value: EC.make_push_value(kind: "bytes", bytes_val: v))) - @nm.push(n) + # A byte blob is not a number until BIN2NUM decides how to read it. + push_tracked(n, DOM_UNKNOWN) end # Push a big integer onto the stack. - # - # @param n [String] stack entry name - # @param v [Integer] def push_big_int(n, v) @e.call(EC.make_stack_op(op: "push", value: EC.make_push_value(kind: "bigint", big_int: v))) - @nm.push(n) + push_tracked(n, v >= 0 ? DOM_NON_NEGATIVE : DOM_UNKNOWN) end # Push an integer onto the stack using big_int_push encoding. - # - # @param n [String] stack entry name - # @param v [Integer] def push_int(n, v) @e.call(EC.make_stack_op(op: "push", value: EC.big_int_push(v))) - @nm.push(n) + push_tracked(n, v >= 0 ? DOM_NON_NEGATIVE : DOM_UNKNOWN) end # Duplicate top of stack. - # - # @param n [String] name for the duplicate def dup(n) @e.call(EC.make_stack_op(op: "dup")) - @nm.push(n) + push_tracked(n, @dm.empty? ? DOM_UNKNOWN : @dm[-1]) end # Drop top of stack. def drop @e.call(EC.make_stack_op(op: "drop")) - @nm.pop if @nm.length > 0 + pop_tracked end # Remove second-to-top stack element. def nip @e.call(EC.make_stack_op(op: "nip")) l = @nm.length - if l >= 2 - @nm[l - 2..l - 1] = [@nm[l - 1]] - end + remove_slot_at(l - 2) if l >= 2 end # Copy second-to-top onto top. - # - # @param n [String] name for the copy def over(n) @e.call(EC.make_stack_op(op: "over")) - @nm.push(n) + push_tracked(n, @dm.length >= 2 ? @dm[-2] : DOM_UNKNOWN) end # Swap top two stack elements. @@ -168,6 +325,7 @@ def swap l = @nm.length if l >= 2 @nm[l - 1], @nm[l - 2] = @nm[l - 2], @nm[l - 1] + @dm[l - 1], @dm[l - 2] = @dm[l - 2], @dm[l - 1] end end @@ -176,24 +334,20 @@ def rot @e.call(EC.make_stack_op(op: "rot")) l = @nm.length if l >= 3 - r = @nm[l - 3] - @nm.delete_at(l - 3) - @nm.push(r) + r, rd = remove_slot_at(l - 3) + push_tracked(r, rd) end end # Emit a raw opcode. - # - # @param code [String] opcode name (e.g. "OP_ADD") def op(code) @e.call(EC.make_stack_op(op: "opcode", code: code)) end # Roll an item from depth d to top. - # - # @param d [Integer] depth def roll(d) return if d == 0 + if d == 1 swap return @@ -203,19 +357,15 @@ def roll(d) return end @e.call(EC.make_stack_op(op: "push", value: EC.big_int_push(d))) - @nm.push("") + push_tracked("", DOM_NON_NEGATIVE) @e.call(EC.make_stack_op(op: "roll", depth: d)) - @nm.pop # pop the push placeholder + pop_tracked # the depth literal idx = @nm.length - 1 - d - r = @nm[idx] - @nm.delete_at(idx) - @nm.push(r) + r, rd = remove_slot_at(idx) + push_tracked(r, rd) end # Pick (copy) an item from depth d to top. - # - # @param d [Integer] depth - # @param n [String] name for the copy def pick(d, n) if d == 0 dup(n) @@ -226,44 +376,93 @@ def pick(d, n) return end @e.call(EC.make_stack_op(op: "push", value: EC.big_int_push(d))) - @nm.push("") + push_tracked("", DOM_NON_NEGATIVE) @e.call(EC.make_stack_op(op: "pick", depth: d)) - @nm.pop # pop the push placeholder - @nm.push(n) + pop_tracked # the depth literal + # Once the depth literal is gone the copied slot sits at depth d. + src = @dm.length > d ? @dm[@dm.length - 1 - d] : DOM_UNKNOWN + push_tracked(n, src) end # Roll the named item to the top of the stack. - # - # @param name [String] def to_top(name) roll(find_depth(name)) end # Copy the named item to the top of the stack. - # - # @param name [String] source name - # @param n [String] name for the copy def copy_to_top(name, n) pick(find_depth(name), n) end + # -- constant pool -------------------------------------------- + # + # A pooled constant is an ordinary tracked slot; nothing about the stack + # model changes. push_const just chooses, per call site and by emitted + # bytes, between copying that slot and re-pushing the literal. Nested + # trackers built from t.nm.dup inherit the slot for free, so pooled + # constants work unchanged inside an OP_IF arm. + + # Park value in slot for this emitter. No-op when pooling is off. + def pool_constant(slot, value) + return if !@pooling || @nm.include?(slot) + + push_big_int(slot, value) + end + + # Remove a pooled slot. No-op when pooling is off or the slot is absent. + def release_constant(slot) + return if !@pooling || !@nm.include?(slot) + + to_top(slot) + drop + end + + # Emitted bytes a push_const of this constant would cost right now. + # + # The comparison is exact -- size_of_push_int is the same encoder the + # emit pass uses -- so pooling can never make a call site bigger. A pick + # at depth d costs size_of_push_int(d) + 1; depths 0 and 1 are OP_DUP / + # OP_OVER, 1 byte each. + def const_cost(slot, value) + if @pooling && @nm.include?(slot) + d = find_depth(slot) + pick_cost = d <= 1 ? 1 : CostModel.size_of_push_int(d) + 1 + return pick_cost if pick_cost < CostModel.size_of_push_int(value) + end + CostModel.size_of_push_int(value) + end + + # Materialize value on top as name, from the pooled slot when that is + # cheaper in emitted bytes than pushing the literal. + def push_const(slot, value, name) + if @pooling && @nm.include?(slot) + d = find_depth(slot) + pick_cost = d <= 1 ? 1 : CostModel.size_of_push_int(d) + 1 + if pick_cost < CostModel.size_of_push_int(value) + pick(d, name) + return + end + end + push_big_int(name, value) + end + # Move top of stack to alt stack. def to_alt op("OP_TOALTSTACK") - @nm.pop if @nm.length > 0 + return if @nm.empty? + + d = @dm[-1] + pop_tracked + @alt_dm.push(d) end # Pop from alt stack to main stack. - # - # @param n [String] name for the value def from_alt(n) op("OP_FROMALTSTACK") - @nm.push(n) + push_tracked(n, @alt_dm.empty? ? DOM_UNKNOWN : @alt_dm.pop) end # Rename the top of stack. - # - # @param n [String] new name def rename(n) @nm[-1] = n if @nm.length > 0 end @@ -274,11 +473,11 @@ def rename(n) # @param produce [String] name produced ("" means no output pushed) # @param fn [Proc] block receiving an emit callback def raw_block(consume, produce, fn) - consume.reverse_each do - @nm.pop if @nm.length > 0 - end + consume.reverse_each { pop_tracked } fn.call(@e) - @nm.push(produce) unless produce.empty? + # Opaque opcodes: nothing is known about the result unless the caller + # proves it and records that with set_domain afterwards. + push_tracked(produce, DOM_UNKNOWN) unless produce.empty? end # Emit if/else with tracked stack effect. @@ -289,14 +488,14 @@ def raw_block(consume, produce, fn) # @param result_name [String] name for the result ("" means no result) def emit_if(cond_name, then_fn, else_fn, result_name) to_top(cond_name) - # condition consumed - @nm.pop if @nm.length > 0 + pop_tracked # condition consumed then_ops = [] else_ops = [] then_fn.call(->(op) { then_ops.push(op) }) else_fn.call(->(op) { else_ops.push(op) }) @e.call(EC.make_stack_op(op: "if", then: then_ops, else_ops: else_ops)) - @nm.push(result_name) unless result_name.empty? + # A join over two arms this tracker did not analyse: nothing is known. + push_tracked(result_name, DOM_UNKNOWN) unless result_name.empty? end end @@ -309,7 +508,38 @@ def emit_if(cond_name, then_fn, else_fn, result_name) # @param t [ECTracker] # @param name [String] def self.ec_push_field_p(t, name) - t.push_big_int(name, EC_FIELD_P) + t.push_const(POOL_FIELD_P, EC_FIELD_P, name) + end + + # `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 does not check. + # + # @param t [ECTracker] + # @param a_name [String] + # @param result_name [String] + def self.ec_field_mod_short(t, a_name, result_name) + t.to_top(a_name) + ec_push_field_p(t, "_fmods_p") + t.raw_block([a_name, "_fmods_p"], result_name, + ->(e) { e.call(make_stack_op(op: "opcode", code: "OP_MOD")) }) + t.set_domain(result_name, DOM_REDUCED) + end + + # Does the cheap `a - b + p` subtraction shape pay here? + # + # It references the prime TWICE where 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. + # + # @param t [ECTracker] + # @return [Boolean] + def self.ec_cheap_sub_pays(t) + c = t.const_cost(POOL_FIELD_P, EC_FIELD_P) + 2 * c + 2 < c + 8 end # Reduce TOS mod p, ensuring non-negative result. @@ -318,6 +548,10 @@ def self.ec_push_field_p(t, name) # @param a_name [String] # @param result_name [String] def self.ec_field_mod(t, a_name, result_name) + if t.sinking && non_negative?(t.domain_of(a_name)) + ec_field_mod_short(t, a_name, result_name) + return + end t.to_top(a_name) ec_push_field_p(t, "_fmod_p") # (a % p + p) % p @@ -332,6 +566,7 @@ def self.ec_field_mod(t, a_name, result_name) e.call(make_stack_op(op: "opcode", code: "OP_MOD")) # ((a%p+p)%p) } t.raw_block([a_name, "_fmod_p"], result_name, fn) + t.set_domain(result_name, DOM_REDUCED) end # Compute (a + b) mod p. @@ -341,9 +576,12 @@ def self.ec_field_mod(t, a_name, result_name) # @param b_name [String] # @param result_name [String] def self.ec_field_add(t, a_name, b_name, result_name) + # Read the operand facts BEFORE raw_block consumes their slots. + sum_non_neg = non_negative?(t.domain_of(a_name)) && non_negative?(t.domain_of(b_name)) t.to_top(a_name) t.to_top(b_name) t.raw_block([a_name, b_name], "_fadd_sum", ->(e) { e.call(make_stack_op(op: "opcode", code: "OP_ADD")) }) + t.set_domain("_fadd_sum", DOM_NON_NEGATIVE) if sum_non_neg ec_field_mod(t, "_fadd_sum", result_name) end @@ -356,7 +594,26 @@ def self.ec_field_add(t, a_name, b_name, result_name) def self.ec_field_sub(t, a_name, b_name, result_name) t.to_top(a_name) t.to_top(b_name) + # 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. + cheap = t.sinking && + non_negative?(t.domain_of(a_name)) && + t.domain_of(b_name) == DOM_REDUCED && + ec_cheap_sub_pays(t) + t.raw_block([a_name, b_name], "_fsub_diff", ->(e) { e.call(make_stack_op(op: "opcode", code: "OP_SUB")) }) + + if cheap + ec_push_field_p(t, "_fsub_p") + t.raw_block(["_fsub_diff", "_fsub_p"], "_fsub_shift", + ->(e) { e.call(make_stack_op(op: "opcode", code: "OP_ADD")) }) + t.set_domain("_fsub_shift", DOM_NON_NEGATIVE) + ec_field_mod_short(t, "_fsub_shift", result_name) + return + end ec_field_mod(t, "_fsub_diff", result_name) end @@ -366,10 +623,15 @@ def self.ec_field_sub(t, a_name, b_name, result_name) # @param a_name [String] # @param b_name [String] # @param result_name [String] - def self.ec_field_mul(t, a_name, b_name, result_name) + def self.ec_field_mul(t, a_name, b_name, result_name, product_non_negative = false) + # product_non_negative lets ec_field_sqr assert the sign independently + # of the operand: a*a >= 0 for any a whatsoever. + non_neg = product_non_negative || + (non_negative?(t.domain_of(a_name)) && non_negative?(t.domain_of(b_name))) t.to_top(a_name) t.to_top(b_name) t.raw_block([a_name, b_name], "_fmul_prod", ->(e) { e.call(make_stack_op(op: "opcode", code: "OP_MUL")) }) + t.set_domain("_fmul_prod", DOM_NON_NEGATIVE) if non_neg ec_field_mod(t, "_fmul_prod", result_name) end @@ -382,6 +644,8 @@ def self.ec_field_mul(t, a_name, b_name, result_name) # @param c [Integer] small constant multiplier # @param result_name [String] def self.ec_field_mul_const(t, a_name, c, result_name) + # Every call site passes a small positive c, so the product keeps a's sign. + non_neg = c > 0 && non_negative?(t.domain_of(a_name)) t.to_top(a_name) t.raw_block([a_name], "_fmc_prod", ->(e) { if c == 2 @@ -392,6 +656,7 @@ def self.ec_field_mul_const(t, a_name, c, result_name) e.call(make_stack_op(op: "opcode", code: "OP_MUL")) end }) + t.set_domain("_fmc_prod", DOM_NON_NEGATIVE) if non_neg ec_field_mod(t, "_fmc_prod", result_name) end @@ -402,7 +667,7 @@ def self.ec_field_mul_const(t, a_name, c, result_name) # @param result_name [String] def self.ec_field_sqr(t, a_name, result_name) t.copy_to_top(a_name, "_fsqr_copy") - ec_field_mul(t, a_name, "_fsqr_copy", result_name) + ec_field_mul(t, a_name, "_fsqr_copy", result_name, true) end # Compute a^(p-2) mod p via square-and-multiply. @@ -497,8 +762,8 @@ def self.ec_decompose_point(t, point_name, x_name, y_name) } t.raw_block([point_name], "", split_fn) # Manually track the two new items - t.nm.push("_dp_xb") - t.nm.push("_dp_yb") + t.push_tracked("_dp_xb", DOM_UNKNOWN) + t.push_tracked("_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 @@ -509,6 +774,10 @@ def self.ec_decompose_point(t, point_name, x_name, y_name) e.call(make_stack_op(op: "opcode", code: "OP_BIN2NUM")) } t.raw_block(["_dp_yb"], y_name, convert_y) + # A 0x00 sign byte is appended before BIN2NUM, so the coordinate decodes + # UNSIGNED: >= 0, but it may be up to 2^(8*coordBytes) - 1 and therefore + # >= p. That gap is exactly what the subtraction precondition turns on. + t.set_domain(y_name, DOM_NON_NEGATIVE) # Convert x_bytes to num t.to_top("_dp_xb") @@ -519,6 +788,7 @@ def self.ec_decompose_point(t, point_name, x_name, y_name) e.call(make_stack_op(op: "opcode", code: "OP_BIN2NUM")) } t.raw_block(["_dp_xb"], x_name, convert_x) + t.set_domain(x_name, DOM_NON_NEGATIVE) # Stack: [yName, xName] -- swap to standard order [xName, yName] t.swap @@ -804,7 +1074,12 @@ def self.ec_jacobian_to_affine(t, rx_name, ry_name) # @param t [ECTracker] def self.ec_build_jacobian_add_affine_inline(e, t) # Create inner tracker with cloned stack state - ec_jacobian_add_affine_body(ECTracker.new(t.nm.dup, e), false) + # The inner tracker inherits the stack state AND the lattice facts: + # the operands' proved domains are what decide which reduction shape the + # body emits, so dropping them here would silently fall back everywhere. + ec_jacobian_add_affine_body( + ECTracker.new(t.nm.dup, e, t.options, t.dm.dup), false + ) end # The mixed-add itself, emitting through a tracker the caller owns. @@ -957,7 +1232,7 @@ def self.ec_select_coord(t, add_name, dbl_name, cond_name, result_name) # # Stack layout: [..., ax, ay, _k, jx, jy, jz] -- same in and out. def self.ec_build_jacobian_add_or_double_inline(e, t) - it = ECTracker.new(t.nm.dup, e) + it = ECTracker.new(t.nm.dup, e, t.options, t.dm.dup) # Keep the pre-add accumulator: it is what must be DOUBLED in the # exceptional case, and the add below consumes jx/jy/jz. @@ -1024,12 +1299,14 @@ def self.ec_build_jacobian_add_or_double_inline(e, t) # Stack out: [result_point] # # @param emit [Proc] callback receiving a StackOp hash - def self.emit_ec_add(emit) - t = ECTracker.new(["_pa", "_pb"], emit) + def self.emit_ec_add(emit, opts = nil) + t = ECTracker.new(["_pa", "_pb"], emit, opts) + t.pool_constant(POOL_FIELD_P, EC_FIELD_P) ec_decompose_point(t, "_pa", "px", "py") ec_decompose_point(t, "_pb", "qx", "qy") ec_affine_add(t) ec_compose_point(t, "rx", "ry", "_result") + t.release_constant(POOL_FIELD_P) end # Reduce a scalar to [0, n-1]: ((k mod n) + n) mod n. @@ -1045,7 +1322,7 @@ def self.emit_ec_add(emit) # attacker-chosen. Reducing costs 1 push + 8 opcodes (42 bytes) against a # ~429 KB script, and makes k >= n, k < 0 and k = 0 all well defined. def self.ec_emit_scalar_reduce(t, k_name, result_name, curve_n) - t.push_big_int("_n_red", curve_n) + t.push_const(POOL_GROUP_N, curve_n, "_n_red") t.raw_block([k_name, "_n_red"], result_name, lambda { |e| e.call(make_stack_op(op: "opcode", code: "OP_2DUP")) e.call(make_stack_op(op: "opcode", code: "OP_MOD")) @@ -1066,8 +1343,10 @@ def self.ec_emit_scalar_reduce(t, k_name, result_name, curve_n) # Uses 256-iteration double-and-add with Jacobian coordinates. # # @param emit [Proc] callback receiving a StackOp hash - def self.emit_ec_mul(emit) - t = ECTracker.new(["_pt", "_k"], emit) + def self.emit_ec_mul(emit, opts = nil) + t = ECTracker.new(["_pt", "_k"], emit, opts) + t.pool_constant(POOL_FIELD_P, EC_FIELD_P) + t.pool_constant(POOL_GROUP_N, EC_CURVE_N) # Decompose to affine base point ec_decompose_point(t, "_pt", "ax", "ay") @@ -1077,14 +1356,13 @@ def self.emit_ec_mul(emit) # # "k in [1, n-1]" is a PRECONDITION the caller cannot enforce -- the # scalar is usually an unlock argument -- so reduce it first. - curve_n = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141 t.to_top("_k") - ec_emit_scalar_reduce(t, "_k", "_kr", curve_n) - t.push_big_int("_n", curve_n) + ec_emit_scalar_reduce(t, "_k", "_kr", EC_CURVE_N) + t.push_const(POOL_GROUP_N, EC_CURVE_N, "_n") t.raw_block(["_kr", "_n"], "_kn", ->(e) { e.call(make_stack_op(op: "opcode", code: "OP_ADD")) }) - t.push_big_int("_n2", curve_n) + t.push_const(POOL_GROUP_N, EC_CURVE_N, "_n2") t.raw_block(["_kn", "_n2"], "_kn2", ->(e) { e.call(make_stack_op(op: "opcode", code: "OP_ADD")) }) - t.push_big_int("_n3", curve_n) + t.push_const(POOL_GROUP_N, EC_CURVE_N, "_n3") t.raw_block(["_kn2", "_n3"], "_kn3", ->(e) { e.call(make_stack_op(op: "opcode", code: "OP_ADD")) }) t.rename("_k") @@ -1116,7 +1394,7 @@ def self.emit_ec_mul(emit) # 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.to_top("_bit") - t.nm.pop # _bit consumed by IF + t.pop_tracked # _bit consumed by IF add_ops = [] add_emit = ->(op) { add_ops.push(op) } # Only the final step can be handed two equal operands -- see @@ -1143,20 +1421,282 @@ def self.emit_ec_mul(emit) # Compose result ec_compose_point(t, "_rx", "_ry", "_result") + t.release_constant(POOL_GROUP_N) + t.release_constant(POOL_FIELD_P) + end + # ================================================================= + # Fixed-base comb (secp256k1) + # ================================================================= + + # Round i's digit and the selected table entry, as ax/ay/_flag. + # + # Exactly one equality holds, so sum(eq_j * T_j) is that entry's + # coordinate and every term is non-negative and below p -- no reduction is + # needed, and the result is DOM_REDUCED by construction. When the digit is + # zero every term vanishes and _flag is 0, so no add runs. + # + # Shared by both comb emitters: the selection is pure scalar bit-twiddling + # and table indexing, with no curve arithmetic in it at all. + # + # @param t [ECTracker] + # @param i [Integer] round index + # @param w [Integer] window width + # @param d [Integer] block width / round count + def self.comb_emit_select(t, i, w, d) + entries = (1 << w) - 1 + (0...w).each do |b| + shift = i + b * d + kc = "_kc#{b}" + sh = "_sh#{b}" + t.copy_to_top("_k", kc) + if shift.zero? + t.rename(sh) + elsif shift == 1 + t.raw_block([kc], sh, ->(e) { e.call(make_stack_op(op: "opcode", code: "OP_2DIV")) }) + else + sd = "_sd#{b}" + t.push_int(sd, shift) + t.raw_block([kc, sd], sh, ->(e) { e.call(make_stack_op(op: "opcode", code: "OP_RSHIFTNUM")) }) + end + two = "_two#{b}" + bit = "_b#{b}" + t.push_int(two, 2) + t.raw_block([sh, two], bit, ->(e) { e.call(make_stack_op(op: "opcode", code: "OP_MOD")) }) + t.set_domain(bit, DOM_REDUCED) + end + + t.to_top("_b0") + t.rename("_idx") + (1...w).each do |b| + bit = "_b#{b}" + wt = "_wt#{b}" + bw = "_bw#{b}" + t.to_top(bit) + t.push_int(wt, 1 << b) + t.raw_block([bit, wt], bw, ->(e) { e.call(make_stack_op(op: "opcode", code: "OP_MUL")) }) + t.to_top("_idx") + t.raw_block([bw, "_idx"], "_idx", ->(e) { e.call(make_stack_op(op: "opcode", code: "OP_ADD")) }) + end + t.set_domain("_idx", DOM_REDUCED) + + (1..entries).each do |j| + ic = "_ic#{j}" + jv = "_jv#{j}" + eq = "_eq#{j}" + t.copy_to_top("_idx", ic) + t.push_int(jv, j) + t.raw_block([ic, jv], eq, ->(e) { e.call(make_stack_op(op: "opcode", code: "OP_NUMEQUAL")) }) + t.set_domain(eq, DOM_REDUCED) + end + + %w[x y].each do |coord| + acc = coord == "x" ? "ax" : "ay" + (1..entries).each do |j| + ecn = "_e#{coord}#{j}" + tc = "_t#{coord}#{j}" + pr = "_pr#{coord}#{j}" + t.copy_to_top("_eq#{j}", ecn) + t.copy_to_top("_T#{coord}#{j}", tc) + t.raw_block([ecn, tc], pr, ->(e) { e.call(make_stack_op(op: "opcode", code: "OP_MUL")) }) + if j == 1 + t.rename(acc) + else + t.to_top(acc) + t.raw_block([pr, acc], acc, ->(e) { e.call(make_stack_op(op: "opcode", code: "OP_ADD")) }) + end + end + t.set_domain(acc, DOM_REDUCED) + end + + entries.downto(1) do |j| + t.to_top("_eq#{j}") + t.drop + end + + t.to_top("_idx") + t.raw_block(["_idx"], "_flag", ->(e) { e.call(make_stack_op(op: "opcode", code: "OP_0NOTEQUAL")) }) + end + + # k*G by a Lim-Lee fixed-base comb instead of the 257-round binary ladder. + # + # The ladder doubles and conditionally adds once per SCALAR BIT. A comb + # splits the scalar into w blocks of d bits and reads one bit from each + # block per round, so it performs one doubling and one conditional add per + # COLUMN: the round count falls from w*d to d at the price of a 2^w - 1 + # entry table. G is a compile-time constant here, so the table costs + # nothing to build. + # + # This is the secp256k1 twin of c_emit_comb_mul_gen in p256_p384.rb. The + # curve arithmetic is NOT shared: secp256k1 has a = 0, so + # ec_jacobian_double computes D = 3X^2 where the NIST version computes + # 3(X-Z^2)(X+Z^2). Only comb.rb -- the compile-time table and the interval + # checker -- is common, and it takes a from the curve record. + # + # SOUNDNESS. The cheap incomplete mixed add cannot represent a pre-add + # accumulator equal to the addend, its negation, or the point at infinity. + # ec_build_jacobian_add_or_double_inline's comment justifies using it + # everywhere but the ladder's LAST step by an interval argument over + # c_i mod n, and insists that argument be re-derived by anything changing + # the offset or the iteration count. A comb changes both, so it is + # re-derived: comb_safe_rounds evaluates the same argument as executable + # interval arithmetic over the comb's own geometry, and any round it + # cannot prove gets the complete add-or-double form instead. Nothing is + # assumed safe. + # + # The other half of that argument is that the accumulator never starts at + # infinity, which needs the first digit non-zero. comb_geometry searches + # for the scalar offset that guarantees it rather than reusing the + # ladder's hardcoded +3n -- right for secp256k1 at w=3, wrong for P-384. + # + # Stack in: [_k]. Stack out: [_result]. + # + # @return [Boolean] false when no geometry exists for w + def self.ec_emit_comb_mul_gen(emit, w, opts = nil) + curve = Comb::SECP256K1_COMB_CURVE + params = Comb.comb_geometry(w, curve) + return false if params.nil? + + d = params.d + table = Comb.comb_table(w, d, curve) + safe = Comb.comb_safe_rounds(params, curve) + entries = (1 << w) - 1 + + t = ECTracker.new(["_k"], emit, opts) + t.pool_constant(POOL_FIELD_P, EC_FIELD_P) + t.pool_constant(POOL_GROUP_N, EC_CURVE_N) + + # k' = (k mod n) + m*n. The reduce is what confines k to [0, n-1] and so + # what makes the interval argument apply at all. + t.to_top("_k") + ec_emit_scalar_reduce(t, "_k", "_kr", EC_CURVE_N) + t.rename("_k") + (0...params.offset_multiple).each do |i| + off = "_off#{i}" + t.push_const(POOL_GROUP_N, EC_CURVE_N, off) + t.raw_block(["_k", off], "_k", ->(e) { e.call(make_stack_op(op: "opcode", code: "OP_ADD")) }) + end + t.set_domain("_k", DOM_NON_NEGATIVE) + + # Table, resident for the whole comb: picking an entry costs 2-3 bytes + # against a 34-byte literal push, and every round reads all of them. + (1..entries).each do |j| + pt = table[j] + t.push_big_int("_Tx#{j}", pt.x) + t.push_big_int("_Ty#{j}", pt.y) + t.set_domain("_Tx#{j}", DOM_REDUCED) + t.set_domain("_Ty#{j}", DOM_REDUCED) + end + + # Round d-1 initialises the accumulator. The first digit is non-zero by + # construction (comb_geometry), so this is a real point, never infinity. + comb_emit_select(t, d - 1, w, d) + t.to_top("_flag") + t.drop + t.to_top("ax") + t.rename("jx") + t.to_top("ay") + t.rename("jy") + t.push_int("jz", 1) + t.set_domain("jz", DOM_REDUCED) + + (d - 2).downto(0) do |i| + ec_jacobian_double(t) + comb_emit_select(t, i, w, d) + + # ec_jacobian_add_affine_body documents its layout as + # [..., ax, ay, jx, jy, jz] and replaces the accumulator IN PLACE at + # the top. The selection leaves ax/ay above jz, so restore the + # contract before the branch -- otherwise the add arm would reorder + # the stack and the empty else arm would not, leaving the two arms + # with different layouts at OP_ENDIF. + t.to_top("_flag") + t.to_alt + t.to_top("jx") + t.to_top("jy") + t.to_top("jz") + t.from_alt("_flag") + + t.pop_tracked # consumed by OP_IF + add_ops = [] + add_emit = ->(o) { add_ops.push(o) } + if safe[i] + ec_build_jacobian_add_affine_inline(add_emit, t) + else + ec_build_jacobian_add_or_double_inline(add_emit, t) + end + emit.call(make_stack_op(op: "if", then: add_ops, else_ops: [])) + + # The addend was selected fresh for this round; the add only copied it. + t.to_top("ay") + t.drop + t.to_top("ax") + t.drop + end + + ec_jacobian_to_affine(t, "_rx", "_ry") + + entries.downto(1) do |j| + t.to_top("_Ty#{j}") + t.drop + t.to_top("_Tx#{j}") + t.drop + end + t.to_top("_k") + t.drop + + ec_compose_point(t, "_rx", "_ry", "_result") + t.release_constant(POOL_GROUP_N) + t.release_constant(POOL_FIELD_P) + true end + # Emit the cheapest comb over the candidate window widths. + # + # Each candidate is rendered in full and scored with the same byte-cost + # model the emitter is measured by, and the smallest wins -- the window + # width is not hardcoded. w=1 is the binary ladder and is excluded; beyond + # w=4 the 2^w selection logic outgrows the saving. + # + # @return [Array, nil] nil when no candidate could be built, so the + # caller falls back to the ladder rather than emitting nothing + def self.ec_emit_comb_best(opts = nil) + best = nil + [2, 3, 4].each do |w| + ops = [] + next unless ec_emit_comb_mul_gen(->(o) { ops.push(o) }, w, opts) + + if best.nil? || + CostModel.estimate_script_bytes(ops) < CostModel.estimate_script_bytes(best) + best = ops + end + end + best + end + + # Perform scalar multiplication G * k. # # Stack in: [scalar] # Stack out: [result_point] # # @param emit [Proc] callback receiving a StackOp hash - def self.emit_ec_mul_gen(emit) + def self.emit_ec_mul_gen(emit, opts = nil) + # G is a compile-time constant, so this is the one secp256k1 call site + # where a fixed-base comb applies. emit_ec_mul cannot use it: its base + # arrives at run time. + if opts && opts.fixed_base_comb + ops = ec_emit_comb_best(opts) + if ops + ops.each { |o| emit.call(o) } + return + end + end + # Push generator point as 64-byte blob, then delegate to ecMul g_point = bigint_to_bytes32(EC_GEN_X) + bigint_to_bytes32(EC_GEN_Y) emit.call(make_stack_op(op: "push", value: make_push_value(kind: "bytes", bytes_val: g_point))) emit.call(make_stack_op(op: "swap")) # [point, scalar] - emit_ec_mul(emit) + emit_ec_mul(emit, opts) end # Negate a point (x, p - y). @@ -1165,12 +1705,14 @@ def self.emit_ec_mul_gen(emit) # Stack out: [negated_point] # # @param emit [Proc] callback receiving a StackOp hash - def self.emit_ec_negate(emit) - t = ECTracker.new(["_pt"], emit) + def self.emit_ec_negate(emit, opts = nil) + t = ECTracker.new(["_pt"], emit, opts) + t.pool_constant(POOL_FIELD_P, EC_FIELD_P) ec_decompose_point(t, "_pt", "_nx", "_ny") ec_push_field_p(t, "_fp") ec_field_sub(t, "_fp", "_ny", "_neg_y") ec_compose_point(t, "_nx", "_neg_y", "_result") + t.release_constant(POOL_FIELD_P) end # Check if point is on secp256k1 (y^2 = x^3 + 7 mod p). @@ -1179,8 +1721,9 @@ def self.emit_ec_negate(emit) # Stack out: [boolean] # # @param emit [Proc] callback receiving a StackOp hash - def self.emit_ec_on_curve(emit) - t = ECTracker.new(["_pt"], emit) + def self.emit_ec_on_curve(emit, opts = nil) + t = ECTracker.new(["_pt"], emit, opts) + t.pool_constant(POOL_FIELD_P, EC_FIELD_P) ec_decompose_point(t, "_pt", "_x", "_y") # GAP-301: coordinate canonicity. `ec_decompose_point` BIN2NUMs each @@ -1219,6 +1762,7 @@ def self.emit_ec_on_curve(emit) t.to_top("_canon") t.to_top("_curve_eq") t.raw_block(["_canon", "_curve_eq"], "_result", ->(e) { e.call(make_stack_op(op: "opcode", code: "OP_BOOLAND")) }) + t.release_constant(POOL_FIELD_P) end # Compute ((value % mod) + mod) % mod. @@ -1372,10 +1916,20 @@ def self.is_ec_builtin(name) # @param func_name [String] # @param emit [Proc] callback receiving a StackOp hash # @raise [RuntimeError] if func_name is not a known EC builtin - def self.dispatch_ec_builtin(func_name, emit) + # Emitters the size flags cannot reach take no options argument. Passing + # one anyway would be a silent no-op today and a latent divergence the day + # someone gives them a body -- so the split is explicit. + EC_FLAG_AWARE = %w[ecAdd ecMul ecMulGen ecNegate ecOnCurve].freeze + + def self.dispatch_ec_builtin(func_name, emit, opts = nil) fn = EC_DISPATCH[func_name] raise "unknown EC builtin: #{func_name}" if fn.nil? - fn.call(emit) + + if EC_FLAG_AWARE.include?(func_name) + fn.call(emit, opts) + else + fn.call(emit) + end end end end diff --git a/compilers/ruby/lib/runar_compiler/codegen/p256_p384.rb b/compilers/ruby/lib/runar_compiler/codegen/p256_p384.rb index cb6828521..938de776c 100644 --- a/compilers/ruby/lib/runar_compiler/codegen/p256_p384.rb +++ b/compilers/ruby/lib/runar_compiler/codegen/p256_p384.rb @@ -16,6 +16,8 @@ # Direct port of compilers/go/codegen/p256_p384.go require_relative "ec" +require_relative "comb" +require_relative "cost_model" module RunarCompiler module Codegen @@ -138,10 +140,30 @@ def self.emit_reverse48(e) # ================================================================= def self.c_push_field_p(t, name, c) - t.push_big_int(name, c.field_p) + t.push_const(EC::POOL_FIELD_P, c.field_p, name) + end + + # `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. + def self.c_field_mod_short(t, a_name, result_name, c) + t.to_top(a_name) + c_push_field_p(t, "_fmods_p", c) + t.raw_block([a_name, "_fmods_p"], result_name, + ->(e) { e.call(make_stack_op(op: "opcode", code: "OP_MOD")) }) + t.set_domain(result_name, EC::DOM_REDUCED) + end + + # Does the cheap `a - b + p` subtraction pay? Only when p is pooled. + def self.c_cheap_sub_pays(t, c) + cost = t.const_cost(EC::POOL_FIELD_P, c.field_p) + 2 * cost + 2 < cost + 8 end def self.c_field_mod(t, a_name, result_name, c) + if t.sinking && EC.non_negative?(t.domain_of(a_name)) + c_field_mod_short(t, a_name, result_name, c) + return + end t.to_top(a_name) c_push_field_p(t, "_fmod_p", c) fn = ->(e) { @@ -155,30 +177,58 @@ def self.c_field_mod(t, a_name, result_name, c) e.call(make_stack_op(op: "opcode", code: "OP_MOD")) } t.raw_block([a_name, "_fmod_p"], result_name, fn) + t.set_domain(result_name, EC::DOM_REDUCED) end def self.c_field_add(t, a_name, b_name, result_name, c) + # Read the operand facts before raw_block consumes their slots. + sum_non_neg = EC.non_negative?(t.domain_of(a_name)) && EC.non_negative?(t.domain_of(b_name)) t.to_top(a_name) t.to_top(b_name) t.raw_block([a_name, b_name], "_fadd_sum", ->(e) { e.call(make_stack_op(op: "opcode", code: "OP_ADD")) }) + t.set_domain("_fadd_sum", EC::DOM_NON_NEGATIVE) if sum_non_neg c_field_mod(t, "_fadd_sum", result_name, c) end def self.c_field_sub(t, a_name, b_name, result_name, c) t.to_top(a_name) t.to_top(b_name) + # 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. + cheap = t.sinking && + EC.non_negative?(t.domain_of(a_name)) && + t.domain_of(b_name) == EC::DOM_REDUCED && + c_cheap_sub_pays(t, c) + t.raw_block([a_name, b_name], "_fsub_diff", ->(e) { e.call(make_stack_op(op: "opcode", code: "OP_SUB")) }) + + if cheap + c_push_field_p(t, "_fsub_p", c) + t.raw_block(["_fsub_diff", "_fsub_p"], "_fsub_shift", + ->(e) { e.call(make_stack_op(op: "opcode", code: "OP_ADD")) }) + t.set_domain("_fsub_shift", EC::DOM_NON_NEGATIVE) + c_field_mod_short(t, "_fsub_shift", result_name, c) + return + end c_field_mod(t, "_fsub_diff", result_name, c) end - def self.c_field_mul(t, a_name, b_name, result_name, c) + def self.c_field_mul(t, a_name, b_name, result_name, c, product_non_negative = false) + # product_non_negative lets c_field_sqr assert the sign independently of + # the operand: a*a >= 0 for any a whatsoever. + non_neg = product_non_negative || + (EC.non_negative?(t.domain_of(a_name)) && EC.non_negative?(t.domain_of(b_name))) t.to_top(a_name) t.to_top(b_name) t.raw_block([a_name, b_name], "_fmul_prod", ->(e) { e.call(make_stack_op(op: "opcode", code: "OP_MUL")) }) + t.set_domain("_fmul_prod", EC::DOM_NON_NEGATIVE) if non_neg c_field_mod(t, "_fmul_prod", result_name, c) end def self.c_field_mul_const(t, a_name, cv, result_name, c) + # Every call site passes a small positive cv, so the product keeps a's sign. + non_neg = cv.positive? && EC.non_negative?(t.domain_of(a_name)) t.to_top(a_name) t.raw_block([a_name], "_fmc_prod", ->(e) { if cv == 2 @@ -188,12 +238,13 @@ def self.c_field_mul_const(t, a_name, cv, result_name, c) e.call(make_stack_op(op: "opcode", code: "OP_MUL")) end }) + t.set_domain("_fmc_prod", EC::DOM_NON_NEGATIVE) if non_neg c_field_mod(t, "_fmc_prod", result_name, c) end def self.c_field_sqr(t, a_name, result_name, c) t.copy_to_top(a_name, "_fsqr_copy") - c_field_mul(t, a_name, "_fsqr_copy", result_name, c) + c_field_mul(t, a_name, "_fsqr_copy", result_name, c, true) end # Generic square-and-multiply inversion: a^(p-2) mod p @@ -224,7 +275,7 @@ def self.c_field_inv(t, a_name, result_name, c) # ================================================================= def self.c_push_group_n(t, name, g) - t.push_big_int(name, g.n) + t.push_const(EC::POOL_GROUP_N, g.n, name) end def self.c_group_mod(t, a_name, result_name, g) @@ -312,8 +363,8 @@ def self.c_decompose_point(t, point_name, x_name, y_name, c) e.call(make_stack_op(op: "opcode", code: "OP_SPLIT")) } t.raw_block([point_name], "", split_fn) - t.nm.push("_dp_xb") - t.nm.push("_dp_yb") + t.push_tracked("_dp_xb", EC::DOM_UNKNOWN) + t.push_tracked("_dp_yb", EC::DOM_UNKNOWN) # Convert y_bytes (on top) to num rev_fn = c.reverse_bytes_fn @@ -324,6 +375,10 @@ def self.c_decompose_point(t, point_name, x_name, y_name, c) e.call(make_stack_op(op: "opcode", code: "OP_BIN2NUM")) } t.raw_block(["_dp_yb"], y_name, convert_y) + # A 0x00 sign byte is appended before BIN2NUM, so the coordinate decodes + # UNSIGNED: >= 0, but it may be up to 2^(8*coordBytes) - 1 and therefore + # >= p. That gap is exactly what the subtraction precondition turns on. + t.set_domain(y_name, EC::DOM_NON_NEGATIVE) # Convert x_bytes to num t.to_top("_dp_xb") @@ -334,6 +389,7 @@ def self.c_decompose_point(t, point_name, x_name, y_name, c) e.call(make_stack_op(op: "opcode", code: "OP_BIN2NUM")) } t.raw_block(["_dp_xb"], x_name, convert_x) + t.set_domain(x_name, EC::DOM_NON_NEGATIVE) t.swap end @@ -632,7 +688,12 @@ def self.c_jacobian_to_affine(t, rx_name, ry_name, c) # ================================================================= def self.c_build_jacobian_add_affine_inline(e, t, c) - c_jacobian_add_affine_body(EC::ECTracker.new(t.nm.dup, e), false, c) + # The inner tracker inherits the stack state AND the lattice facts: + # the operands' proved domains are what decide which reduction shape the + # body emits, so dropping them here would silently fall back everywhere. + c_jacobian_add_affine_body( + EC::ECTracker.new(t.nm.dup, e, t.options, t.dm.dup), false, c + ) end # The mixed-add itself, emitting through a tracker the caller owns. @@ -763,7 +824,7 @@ def self.c_select_coord(t, add_name, dbl_name, cond_name, result_name, c) # # Stack layout: [..., ax, ay, _k, jx, jy, jz] -- same in and out. def self.c_build_jacobian_add_or_double_inline(e, t, c) - it = EC::ECTracker.new(t.nm.dup, e) + it = EC::ECTracker.new(t.nm.dup, e, t.options, t.dm.dup) # Keep the pre-add accumulator: it is what must be DOUBLED in the # exceptional case, and the add below consumes jx/jy/jz. @@ -824,8 +885,10 @@ def self.c_build_jacobian_add_or_double_inline(e, t, c) # Scalar multiplication (generic for both P-256 and P-384) # ================================================================= - def self.c_emit_mul(emit, c, g) - t = EC::ECTracker.new(["_pt", "_k"], emit) + def self.c_emit_mul(emit, c, g, opts = nil) + t = EC::ECTracker.new(["_pt", "_k"], emit, opts) + t.pool_constant(EC::POOL_FIELD_P, c.field_p) + t.pool_constant(EC::POOL_GROUP_N, g.n) c_decompose_point(t, "_pt", "ax", "ay", c) # k' = k + 3n @@ -868,7 +931,7 @@ def self.c_emit_mul(emit, c, g) t.raw_block(["_shifted", "_two"], "_bit", ->(e) { e.call(make_stack_op(op: "opcode", code: "OP_MOD")) }) t.to_top("_bit") - t.nm.pop # _bit consumed by IF + t.pop_tracked # _bit consumed by IF add_ops = [] add_emit = ->(op) { add_ops.push(op) } @@ -893,6 +956,8 @@ def self.c_emit_mul(emit, c, g) t.drop c_compose_point(t, "_rx", "_ry", "_result", c) + t.release_constant(EC::POOL_GROUP_N) + t.release_constant(EC::POOL_FIELD_P) end # ================================================================= @@ -961,8 +1026,8 @@ def self.c_decompress_pub_key(t, pk_name, qx_name, qy_name, c, curve_b, sqrt_exp e.call(make_stack_op(op: "push", value: big_int_push(1))) e.call(make_stack_op(op: "opcode", code: "OP_SPLIT")) }) - t.nm.push("_dk_prefix") - t.nm.push("_dk_xbytes") + t.push_tracked("_dk_prefix", EC::DOM_UNKNOWN) + t.push_tracked("_dk_xbytes", EC::DOM_UNKNOWN) # 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 @@ -1051,7 +1116,7 @@ def self.c_decompress_pub_key(t, pk_name, qx_name, qy_name, c, curve_b, sqrt_exp # Use OP_IF to select: if match, use y_cand; else use neg_y t.to_top("_dk_match") - t.nm.pop # condition consumed by IF + t.pop_tracked # condition consumed by IF then_ops = [make_stack_op(op: "drop")] else_ops = [make_stack_op(op: "nip")] @@ -1059,7 +1124,7 @@ def self.c_decompress_pub_key(t, pk_name, qx_name, qy_name, c, curve_b, sqrt_exp # Remove one item from tracker and rename the surviving item neg_idx = t.nm.rindex("_dk_neg_y") - t.nm.delete_at(neg_idx) if neg_idx + t.remove_slot_at(neg_idx) if neg_idx yc_idx = t.nm.rindex("_dk_y_cand") t.nm[yc_idx] = qy_name if yc_idx @@ -1134,8 +1199,8 @@ def self.c_emit_length_gate(t, name, want, flag_name) e.call(make_stack_op(op: "opcode", code: "OP_SPLIT")) e.call(make_stack_op(op: "drop")) }) - t.nm.push(flag_name) - t.nm.push(name) + t.push_tracked(flag_name, EC::DOM_UNKNOWN) + t.push_tracked(name, EC::DOM_UNKNOWN) end # SEC1 4.1.4 step 1 / FIPS 186-5 6.4.2: verify 1 <= r <= n-1 and @@ -1207,8 +1272,165 @@ def self.c_emit_sig_range_gate(t, g) }) end - def self.c_emit_verify_ecdsa(emit, c, g, curve_b, sqrt_exp, gx, gy) - t = EC::ECTracker.new(["_msg", "_sig", "_pk"], emit) + # ================================================================= + # Fixed-base comb (the base is a compile-time constant) + # ================================================================= + + # k*G by a Lim-Lee comb, for a base known at compile time. + # + # The binary ladder runs one doubling and one conditional add per scalar + # BIT. A comb splits the scalar into w blocks of d bits and runs one + # doubling and one conditional add per COLUMN, so the round count falls + # from w*d to d at the price of a 2^w - 1 entry table -- which costs + # nothing to build here, because G is a constant. Measured optimum is w=3: + # the selection logic grows as 2^w and overtakes the saving by w=5. + # + # SOUNDNESS. The cheap incomplete mixed add cannot represent a pre-add + # accumulator equal to the addend, its negation, or the point at infinity. + # c_build_jacobian_add_or_double_inline's comment justifies using it + # everywhere but the last step of the BINARY ladder by an interval + # argument over c_i mod n, and insists that argument be re-derived by + # anything changing the offset or the iteration count. A comb changes + # both, so it is re-derived -- as executable interval arithmetic in + # comb_safe_rounds, evaluated here. Rounds it cannot prove get the + # complete add-or-double form instead; nothing is assumed. For P-256 at + # w=3 it proves 81 of 86 rounds. + # + # The other half of that argument is that the accumulator never starts at + # infinity, which needs the first digit non-zero. comb_geometry searches + # for the scalar offset that guarantees it rather than reusing the + # ladder's hardcoded +3n -- right for P-256 at w=3 and WRONG for P-384. + # + # Stack in: [_k]. Stack out: [_result]. + # + # @return [Boolean] false when no geometry exists for w + def self.c_emit_comb_mul_gen(emit, c, g, curve, w, opts = nil) + params = Comb.comb_geometry(w, curve) + return false if params.nil? + + d = params.d + table = Comb.comb_table(w, d, curve) + safe = Comb.comb_safe_rounds(params, curve) + entries = (1 << w) - 1 + + t = EC::ECTracker.new(["_k"], emit, opts) + t.pool_constant(EC::POOL_FIELD_P, c.field_p) + t.pool_constant(EC::POOL_GROUP_N, g.n) + + # k' = (k mod n) + m*n. The reduce is what confines k to [0, n-1] and so + # what makes the interval argument apply at all. + t.to_top("_k") + c_emit_scalar_reduce(t, "_k", "_kr", g) + t.rename("_k") + (0...params.offset_multiple).each do |i| + off = "_off#{i}" + t.push_const(EC::POOL_GROUP_N, g.n, off) + t.raw_block(["_k", off], "_k", ->(e) { e.call(make_stack_op(op: "opcode", code: "OP_ADD")) }) + end + t.set_domain("_k", EC::DOM_NON_NEGATIVE) + + # Table, resident for the whole comb: picking an entry costs 2-3 bytes + # against a 34-byte literal push, and every round reads all of them. + (1..entries).each do |j| + pt = table[j] + t.push_big_int("_Tx#{j}", pt.x) + t.push_big_int("_Ty#{j}", pt.y) + t.set_domain("_Tx#{j}", EC::DOM_REDUCED) + t.set_domain("_Ty#{j}", EC::DOM_REDUCED) + end + + # Round d-1 initialises the accumulator. The first digit is non-zero by + # construction (comb_geometry), so this is a real point, never infinity. + EC.comb_emit_select(t, d - 1, w, d) + t.to_top("_flag") + t.drop + t.to_top("ax") + t.rename("jx") + t.to_top("ay") + t.rename("jy") + t.push_int("jz", 1) + t.set_domain("jz", EC::DOM_REDUCED) + + (d - 2).downto(0) do |i| + c_jacobian_double(t, c) + EC.comb_emit_select(t, i, w, d) + + # c_jacobian_add_affine_body documents its layout as + # [..., ax, ay, jx, jy, jz] and replaces the accumulator IN PLACE at + # the top. The selection leaves ax/ay above jz, so restore the + # contract before the branch -- otherwise the add arm would reorder + # the stack and the empty else arm would not, leaving the two arms + # with different layouts at OP_ENDIF. + t.to_top("_flag") + t.to_alt + t.to_top("jx") + t.to_top("jy") + t.to_top("jz") + t.from_alt("_flag") + + t.pop_tracked # consumed by OP_IF + add_ops = [] + add_emit = ->(o) { add_ops.push(o) } + if safe[i] + c_build_jacobian_add_affine_inline(add_emit, t, c) + else + c_build_jacobian_add_or_double_inline(add_emit, t, c) + end + emit.call(make_stack_op(op: "if", then: add_ops, else_ops: [])) + + # The addend was selected fresh for this round; the add only copied it. + t.to_top("ay") + t.drop + t.to_top("ax") + t.drop + end + + c_jacobian_to_affine(t, "_rx", "_ry", c) + + entries.downto(1) do |j| + t.to_top("_Ty#{j}") + t.drop + t.to_top("_Tx#{j}") + t.drop + end + t.to_top("_k") + t.drop + + c_compose_point(t, "_rx", "_ry", "_result", c) + t.release_constant(EC::POOL_GROUP_N) + t.release_constant(EC::POOL_FIELD_P) + true + end + + # Emit the cheapest comb over the candidate window widths. + # + # Each candidate is rendered in full and scored with the same byte-cost + # model the emitter is measured by, and the smallest wins. + # + # @return [Array, nil] + def self.c_emit_comb_best(c, g, curve, opts = nil) + best = nil + [2, 3, 4].each do |w| + ops = [] + next unless c_emit_comb_mul_gen(->(o) { ops.push(o) }, c, g, curve, w, opts) + + if best.nil? || + CostModel.estimate_script_bytes(ops) < CostModel.estimate_script_bytes(best) + best = ops + end + end + best + end + + def self.c_emit_verify_ecdsa(emit, c, g, curve_b, sqrt_exp, gx, gy, comb_curve = nil, opts = nil) + t = EC::ECTracker.new(["_msg", "_sig", "_pk"], emit, opts) + # The verifier does hundreds of reductions OUTSIDE the two ladders -- + # decompression's sqrt ladder, c_group_inv, c_affine_add, the final + # c_group_mod. Each ladder pools separately: c_emit_mul runs on its own + # tracker that deliberately cannot see this stack, so it cannot reach + # this slot. + t.pool_constant(EC::POOL_FIELD_P, c.field_p) + t.pool_constant(EC::POOL_GROUP_N, g.n) # Step 0: length gate. `_sig` and `_pk` are bare ByteString in the # builtin table and the type checker imposes no width, so both arrive @@ -1241,8 +1463,8 @@ def self.c_emit_verify_ecdsa(emit, c, g, curve_b, sqrt_exp, gx, gy) e.call(make_stack_op(op: "push", value: big_int_push(c.coord_bytes))) e.call(make_stack_op(op: "opcode", code: "OP_SPLIT")) }) - t.nm.push("_r_bytes") - t.nm.push("_s_bytes") + t.push_tracked("_r_bytes", EC::DOM_UNKNOWN) + t.push_tracked("_s_bytes", EC::DOM_UNKNOWN) rev_fn = c.reverse_bytes_fn @@ -1301,7 +1523,14 @@ def self.c_emit_verify_ecdsa(emit, c, g, curve_b, sqrt_exp, gx, gy) # Step 7: R = u1*G + u2*Q point_bytes = c.coord_bytes * 2 g_point = bigint_to_n_bytes(gx, c.coord_bytes) + bigint_to_n_bytes(gy, c.coord_bytes) - t.push_bytes("_G", g_point) + + # u1*G. G is a compile-time constant, so this half can use a fixed-base + # comb -- one doubling and one add per COLUMN instead of per bit. u2*Q + # below cannot: Q arrives in the witness. + comb_ops = nil + comb_ops = c_emit_comb_best(c, g, comb_curve, opts) if opts && opts.fixed_base_comb && comb_curve + + t.push_bytes("_G", g_point) if comb_ops.nil? t.to_top("_u1") # Stash items on altstack. @@ -1317,14 +1546,19 @@ def self.c_emit_verify_ecdsa(emit, c, g, curve_b, sqrt_exp, gx, gy) t.to_top("_qx") t.to_alt - # Remove _G and _u1 from tracker before c_emit_mul - t.nm.pop # _u1 - t.nm.pop # _G + # The multiply creates its own ECTracker and cannot see items below its + # operands. Remove them from ours. + t.pop_tracked # _u1 + t.pop_tracked if comb_ops.nil? # _G - c_emit_mul(emit, c, g) + if comb_ops + comb_ops.each { |o| emit.call(o) } + else + c_emit_mul(emit, c, g, opts) + end # After mul, one result point is on the stack - t.nm.push("_R1_point") + t.push_tracked("_R1_point", EC::DOM_UNKNOWN) # Pop qx/qy/u2 from altstack (LIFO order) t.from_alt("_qx") @@ -1341,10 +1575,10 @@ def self.c_emit_verify_ecdsa(emit, c, g, curve_b, sqrt_exp, gx, gy) t.to_top("_u2") # Remove from tracker, emit mul, push result - t.nm.pop # _u2 - t.nm.pop # _Q_point - c_emit_mul(emit, c, g) - t.nm.push("_R2_point") + t.pop_tracked # _u2 + t.pop_tracked # _Q_point + c_emit_mul(emit, c, g, opts) + t.push_tracked("_R2_point", EC::DOM_UNKNOWN) # Restore R1 point t.from_alt("_R1_point") @@ -1389,41 +1623,55 @@ def self.c_emit_verify_ecdsa(emit, c, g, curve_b, sqrt_exp, gx, gy) t.raw_block(["_input_ok", "_sig_ok"], "_result", ->(e) { e.call(make_stack_op(op: "opcode", code: "OP_BOOLAND")) }) + t.release_constant(EC::POOL_GROUP_N) + t.release_constant(EC::POOL_FIELD_P) end # ================================================================= # P-256 public API # ================================================================= - def self.emit_p256_add(emit) - t = EC::ECTracker.new(["_pa", "_pb"], emit) + def self.emit_p256_add(emit, opts = nil) + t = EC::ECTracker.new(["_pa", "_pb"], emit, opts) + t.pool_constant(EC::POOL_FIELD_P, P256_CURVE.field_p) c_decompose_point(t, "_pa", "px", "py", P256_CURVE) c_decompose_point(t, "_pb", "qx", "qy", P256_CURVE) c_affine_add(t, P256_CURVE) c_compose_point(t, "rx", "ry", "_result", P256_CURVE) + t.release_constant(EC::POOL_FIELD_P) end - def self.emit_p256_mul(emit) - c_emit_mul(emit, P256_CURVE, P256_GROUP) + def self.emit_p256_mul(emit, opts = nil) + c_emit_mul(emit, P256_CURVE, P256_GROUP, opts) end - def self.emit_p256_mul_gen(emit) + def self.emit_p256_mul_gen(emit, opts = nil) + if opts && opts.fixed_base_comb + ops = c_emit_comb_best(P256_CURVE, P256_GROUP, Comb::P256_COMB_CURVE, opts) + if ops + ops.each { |o| emit.call(o) } + return + end + end g_point = bigint_to_n_bytes(P256_GX, 32) + bigint_to_n_bytes(P256_GY, 32) emit.call(make_stack_op(op: "push", value: make_push_value(kind: "bytes", bytes_val: g_point))) emit.call(make_stack_op(op: "swap")) - emit_p256_mul(emit) + emit_p256_mul(emit, opts) end - def self.emit_p256_negate(emit) - t = EC::ECTracker.new(["_pt"], emit) + def self.emit_p256_negate(emit, opts = nil) + t = EC::ECTracker.new(["_pt"], emit, opts) + t.pool_constant(EC::POOL_FIELD_P, P256_CURVE.field_p) c_decompose_point(t, "_pt", "_nx", "_ny", P256_CURVE) c_push_field_p(t, "_fp", P256_CURVE) c_field_sub(t, "_fp", "_ny", "_neg_y", P256_CURVE) c_compose_point(t, "_nx", "_neg_y", "_result", P256_CURVE) + t.release_constant(EC::POOL_FIELD_P) end - def self.emit_p256_on_curve(emit) - t = EC::ECTracker.new(["_pt"], emit) + def self.emit_p256_on_curve(emit, opts = nil) + t = EC::ECTracker.new(["_pt"], emit, opts) + t.pool_constant(EC::POOL_FIELD_P, P256_CURVE.field_p) c_decompose_point(t, "_pt", "_x", "_y", P256_CURVE) c_emit_canonicity_guard(t, "_x", "_y", P256_CURVE) @@ -1446,6 +1694,7 @@ def self.emit_p256_on_curve(emit) t.to_top("_canon") t.to_top("_curve_eq") t.raw_block(["_canon", "_curve_eq"], "_result", ->(e) { e.call(make_stack_op(op: "opcode", code: "OP_BOOLAND")) }) + t.release_constant(EC::POOL_FIELD_P) end def self.emit_p256_encode_compressed(emit) @@ -1469,43 +1718,55 @@ def self.emit_p256_encode_compressed(emit) emit.call(make_stack_op(op: "opcode", code: "OP_CAT")) end - def self.emit_verify_ecdsa_p256(emit) - c_emit_verify_ecdsa(emit, P256_CURVE, P256_GROUP, P256_B, P256_SQRT_EXP, P256_GX, P256_GY) + def self.emit_verify_ecdsa_p256(emit, opts = nil) + c_emit_verify_ecdsa(emit, P256_CURVE, P256_GROUP, P256_B, P256_SQRT_EXP, P256_GX, P256_GY, Comb::P256_COMB_CURVE, opts) end # ================================================================= # P-384 public API # ================================================================= - def self.emit_p384_add(emit) - t = EC::ECTracker.new(["_pa", "_pb"], emit) + def self.emit_p384_add(emit, opts = nil) + t = EC::ECTracker.new(["_pa", "_pb"], emit, opts) + t.pool_constant(EC::POOL_FIELD_P, P384_CURVE.field_p) c_decompose_point(t, "_pa", "px", "py", P384_CURVE) c_decompose_point(t, "_pb", "qx", "qy", P384_CURVE) c_affine_add(t, P384_CURVE) c_compose_point(t, "rx", "ry", "_result", P384_CURVE) + t.release_constant(EC::POOL_FIELD_P) end - def self.emit_p384_mul(emit) - c_emit_mul(emit, P384_CURVE, P384_GROUP) + def self.emit_p384_mul(emit, opts = nil) + c_emit_mul(emit, P384_CURVE, P384_GROUP, opts) end - def self.emit_p384_mul_gen(emit) + def self.emit_p384_mul_gen(emit, opts = nil) + if opts && opts.fixed_base_comb + ops = c_emit_comb_best(P384_CURVE, P384_GROUP, Comb::P384_COMB_CURVE, opts) + if ops + ops.each { |o| emit.call(o) } + return + end + end g_point = bigint_to_n_bytes(P384_GX, 48) + bigint_to_n_bytes(P384_GY, 48) emit.call(make_stack_op(op: "push", value: make_push_value(kind: "bytes", bytes_val: g_point))) emit.call(make_stack_op(op: "swap")) - emit_p384_mul(emit) + emit_p384_mul(emit, opts) end - def self.emit_p384_negate(emit) - t = EC::ECTracker.new(["_pt"], emit) + def self.emit_p384_negate(emit, opts = nil) + t = EC::ECTracker.new(["_pt"], emit, opts) + t.pool_constant(EC::POOL_FIELD_P, P384_CURVE.field_p) c_decompose_point(t, "_pt", "_nx", "_ny", P384_CURVE) c_push_field_p(t, "_fp", P384_CURVE) c_field_sub(t, "_fp", "_ny", "_neg_y", P384_CURVE) c_compose_point(t, "_nx", "_neg_y", "_result", P384_CURVE) + t.release_constant(EC::POOL_FIELD_P) end - def self.emit_p384_on_curve(emit) - t = EC::ECTracker.new(["_pt"], emit) + def self.emit_p384_on_curve(emit, opts = nil) + t = EC::ECTracker.new(["_pt"], emit, opts) + t.pool_constant(EC::POOL_FIELD_P, P384_CURVE.field_p) c_decompose_point(t, "_pt", "_x", "_y", P384_CURVE) c_emit_canonicity_guard(t, "_x", "_y", P384_CURVE) @@ -1528,6 +1789,7 @@ def self.emit_p384_on_curve(emit) t.to_top("_canon") t.to_top("_curve_eq") t.raw_block(["_canon", "_curve_eq"], "_result", ->(e) { e.call(make_stack_op(op: "opcode", code: "OP_BOOLAND")) }) + t.release_constant(EC::POOL_FIELD_P) end def self.emit_p384_encode_compressed(emit) @@ -1551,8 +1813,8 @@ def self.emit_p384_encode_compressed(emit) emit.call(make_stack_op(op: "opcode", code: "OP_CAT")) end - def self.emit_verify_ecdsa_p384(emit) - c_emit_verify_ecdsa(emit, P384_CURVE, P384_GROUP, P384_B, P384_SQRT_EXP, P384_GX, P384_GY) + def self.emit_verify_ecdsa_p384(emit, opts = nil) + c_emit_verify_ecdsa(emit, P384_CURVE, P384_GROUP, P384_B, P384_SQRT_EXP, P384_GX, P384_GY, Comb::P384_COMB_CURVE, opts) end # ================================================================= @@ -1589,17 +1851,24 @@ def self.verify_ecdsa_builtin?(name) "p384EncodeCompressed" => method(:emit_p384_encode_compressed), }.freeze - def self.dispatch_nist_ec_builtin(func_name, emit) + def self.dispatch_nist_ec_builtin(func_name, emit, opts = nil) fn = NIST_EC_DISPATCH[func_name] raise "unknown NIST EC builtin: #{func_name}" if fn.nil? - fn.call(emit) + + # The encode-compressed emitters are pure byte shuffling with no field + # arithmetic, so the flags cannot reach them and they take no options. + if func_name.end_with?("EncodeCompressed") + fn.call(emit) + else + fn.call(emit, opts) + end end - def self.dispatch_verify_ecdsa(func_name, emit) + def self.dispatch_verify_ecdsa(func_name, emit, opts = nil) if func_name == "verifyECDSA_P256" - emit_verify_ecdsa_p256(emit) + emit_verify_ecdsa_p256(emit, opts) else - emit_verify_ecdsa_p384(emit) + emit_verify_ecdsa_p384(emit, opts) end end end diff --git a/compilers/ruby/lib/runar_compiler/codegen/stack.rb b/compilers/ruby/lib/runar_compiler/codegen/stack.rb index c01fe358e..49ad43669 100644 --- a/compilers/ruby/lib/runar_compiler/codegen/stack.rb +++ b/compilers/ruby/lib/runar_compiler/codegen/stack.rb @@ -663,7 +663,7 @@ def self.variable_length_state_type?(t) class LoweringContext attr_accessor :sm, :ops, :max_depth, :properties, :private_methods, :local_bindings, :outer_protected_refs, :inside_branch, - :current_source_loc + :current_source_loc, :ec_codegen # OP_PUSH_TX on-chain signature derivation (BUG-100 fix). # @@ -751,6 +751,12 @@ def initialize(params, properties) @outer_protected_refs = nil @inside_branch = false @current_source_loc = nil + # EXPERIMENTAL EC size options (constant pool, sign lattice / reduction + # sinking, fixed-base comb), handed down to the EC and NIST curve + # emitters. nil -- not an all-false instance -- when nothing is enabled, + # so those emitters take their untouched default path and the emitted + # bytes are provably identical to the shipping ones. + @ec_codegen = nil # #130 (stack layer): a method param whose name collides with a MUTABLE # property gets a duplicate stackMap slot once deserialize_state pushes @@ -2967,7 +2973,7 @@ def _lower_ec_builtin(binding_name, func_name, args, binding_index, last_uses) args.length.times { @sm.pop } emit_fn = ->(op) { emit_op(op) } - EC.dispatch_ec_builtin(func_name, emit_fn) + EC.dispatch_ec_builtin(func_name, emit_fn, @ec_codegen) @sm.push(binding_name) _track_depth @@ -2982,7 +2988,7 @@ def _lower_nist_ec_builtin(binding_name, func_name, args, binding_index, last_us args.length.times { @sm.pop } emit_fn = ->(op) { emit_op(op) } - NISTEC.dispatch_nist_ec_builtin(func_name, emit_fn) + NISTEC.dispatch_nist_ec_builtin(func_name, emit_fn, @ec_codegen) @sm.push(binding_name) _track_depth @@ -3003,7 +3009,7 @@ def _lower_verify_ecdsa(binding_name, func_name, args, binding_index, last_uses) @sm.pop # msg emit_fn = ->(op) { emit_op(op) } - NISTEC.dispatch_verify_ecdsa(func_name, emit_fn) + NISTEC.dispatch_verify_ecdsa(func_name, emit_fn, @ec_codegen) @sm.push(binding_name) _track_depth @@ -4200,8 +4206,8 @@ def _lower_verify_rabin_sig(binding_name, args, binding_index, last_uses) # # @param program [IR::ANFProgram] the ANF program # @return [Array] list of stack method hashes - def self.lower_to_stack(program) - _lower_to_stack_inner(program) + def self.lower_to_stack(program, ec_codegen = nil) + _lower_to_stack_inner(program, ec_codegen) rescue RuntimeError raise rescue ::RunarCompiler::IR::UnknownANFKindError @@ -4213,7 +4219,7 @@ def self.lower_to_stack(program) end # @api private - def self._lower_to_stack_inner(program) + def self._lower_to_stack_inner(program, ec_codegen = nil) # Build map of private methods for inlining private_methods = {} program.methods.each do |m| @@ -4226,7 +4232,8 @@ def self._lower_to_stack_inner(program) next if method.name == "constructor" next if !method.is_public && method.name != "constructor" - sm = _lower_method_with_private_methods(method, program.properties, private_methods) + sm = _lower_method_with_private_methods(method, program.properties, private_methods, + ec_codegen) methods << sm end @@ -4235,7 +4242,7 @@ def self._lower_to_stack_inner(program) private_class_method :_lower_to_stack_inner # @api private - def self._lower_method_with_private_methods(method, properties, private_methods) + def self._lower_method_with_private_methods(method, properties, private_methods, ec_codegen = nil) param_names = method.params.map(&:name) # _codePart is needed for continuation builders (add_output/add_raw_output) @@ -4255,6 +4262,7 @@ def self._lower_method_with_private_methods(method, properties, private_methods) end ctx = LoweringContext.new(param_names, properties) + ctx.ec_codegen = ec_codegen ctx.private_methods = private_methods # Pass terminalAssert=true for public methods ctx.lower_bindings(method.body, method.is_public) diff --git a/compilers/ruby/lib/runar_compiler/compiler.rb b/compilers/ruby/lib/runar_compiler/compiler.rb index da7b02f34..962fc1ba3 100644 --- a/compilers/ruby/lib/runar_compiler/compiler.rb +++ b/compilers/ruby/lib/runar_compiler/compiler.rb @@ -289,9 +289,9 @@ def self._eliminate_dead_code(program) private_class_method :_eliminate_dead_code # Stack lowering: ANF -> Stack IR. - def self._lower_to_stack(program) + def self._lower_to_stack(program, ec_codegen = nil) require_relative "codegen/stack" - Codegen.lower_to_stack(program) + Codegen.lower_to_stack(program, ec_codegen) end private_class_method :_lower_to_stack @@ -481,7 +481,27 @@ def self.compile_from_ir_bytes(data, disable_constant_folding: false) # @param program [IR::ANFProgram] the ANF program # @param disable_constant_folding [Boolean] skip constant folding pass # @return [Artifact] - def self.compile_from_program(program, disable_constant_folding: false) + # Options handed to the EC / NIST codegen modules. + # + # Returns nil -- not an all-false instance -- when nothing is enabled, so + # those emitters take their untouched default path and the emitted bytes are + # provably identical to the shipping ones. + # + # Cross-tier byte parity for the flags THEMSELVES is gated by + # conformance/ec-flag-parity/expected.json. + def self._ec_codegen_options(pool, sinking, comb) + return nil unless pool || sinking || comb + + require "runar_compiler/codegen/ec" + Codegen::EC::EcCodegenOptions.new( + constant_pool: pool, reduction_sinking: sinking, fixed_base_comb: comb + ) + end + private_class_method :_ec_codegen_options + + def self.compile_from_program(program, disable_constant_folding: false, + ec_constant_pool: false, ec_reduction_sinking: false, + ec_fixed_base_comb: false) # Pass 4.25: Constant folding (on by default) program = _fold_constants(program) unless disable_constant_folding @@ -490,7 +510,10 @@ def self.compile_from_program(program, disable_constant_folding: false) program = _optimize_ec(program) # Pass 5: Stack lowering - stack_methods = _lower_to_stack(program) + stack_methods = _lower_to_stack( + program, + _ec_codegen_options(ec_constant_pool, ec_reduction_sinking, ec_fixed_base_comb) + ) # Peephole optimization -- runs on Stack IR before emission. stack_methods.each do |sm| @@ -523,7 +546,9 @@ def self.compile_from_program(program, disable_constant_folding: false) # @param disable_constant_folding [Boolean] skip constant folding pass # @param constructor_args [Hash, nil] constructor argument overrides # @return [Artifact] - def self.compile_from_source(source_path, disable_constant_folding: false, constructor_args: nil) + def self.compile_from_source(source_path, disable_constant_folding: false, constructor_args: nil, + ec_constant_pool: false, ec_reduction_sinking: false, + ec_fixed_base_comb: false) source = _read_file(source_path) # Pass 1: Parse @@ -561,7 +586,13 @@ def self.compile_from_source(source_path, disable_constant_folding: false, const _apply_constructor_args(program, constructor_args) # Feed into existing compilation pipeline (passes 4.25-6) - compile_from_program(program, disable_constant_folding: disable_constant_folding) + compile_from_program( + program, + disable_constant_folding: disable_constant_folding, + ec_constant_pool: ec_constant_pool, + ec_reduction_sinking: ec_reduction_sinking, + ec_fixed_base_comb: ec_fixed_base_comb + ) end # Run passes 1-4 on a source file and return the ANF program. diff --git a/compilers/ruby/test/codegen/test_ec_flag_parity.rb b/compilers/ruby/test/codegen/test_ec_flag_parity.rb new file mode 100644 index 000000000..727ff16ea --- /dev/null +++ b/compilers/ruby/test/codegen/test_ec_flag_parity.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require_relative 'codegen_helper' +require 'json' +require 'digest' +require 'runar_compiler/codegen/emit' +require 'runar_compiler/codegen/ec' +require 'runar_compiler/codegen/p256_p384' + +# Cross-tier parity for the EXPERIMENTAL EC size flags. +# +# The flags default off, so the ordinary conformance suite -- which compiles +# with defaults -- cannot see them at all. Seven tiers could each ship a +# DIFFERENT --ec-constant-pool and the suite would stay green. +# +# That matters because the flags are not cosmetic: they change which reduction +# form is emitted and which addition formula each ladder round uses. A tier that +# ports the constant pool but not the sign lattice's REDUCED precondition +# produces a script that is smaller, passes its own tests, and is wrong on +# ecAdd((0,1), (2^256-1,1)). Byte-identical output against a single reference is +# the only cheap check that catches that. +# +# conformance/ec-flag-parity/expected.json is derived from the TypeScript +# reference compiler and re-derived by its own vitest, so it cannot go stale. +class TestEcFlagParity < Minitest::Test + C = RunarCompiler::Codegen + + FIXTURE_PATH = File.expand_path( + '../../../../conformance/ec-flag-parity/expected.json', __dir__ + ) + + FIELD_MAP = { + 'constantPool' => :constant_pool, + 'reductionSinking' => :reduction_sinking, + 'fixedBaseComb' => :fixed_base_comb + }.freeze + + # Adapt an emitter the flags cannot reach to the options-taking shape. These + # are deliberately included: a tier that accidentally made ecModReduce or + # ecPointX flag-sensitive would be diverging just as badly as one that ignored + # a flag. + IGNORE_OPTS = ->(f) { ->(e, _o = nil) { f.call(e) } } + + def emitters + { + 'EcAdd' => C::EC.method(:emit_ec_add), + 'EcMul' => C::EC.method(:emit_ec_mul), + 'EcMulGen' => C::EC.method(:emit_ec_mul_gen), + 'EcNegate' => C::EC.method(:emit_ec_negate), + 'EcOnCurve' => C::EC.method(:emit_ec_on_curve), + 'EcModReduce' => IGNORE_OPTS.call(C::EC.method(:emit_ec_mod_reduce)), + 'EcEncodeCompressed' => IGNORE_OPTS.call(C::EC.method(:emit_ec_encode_compressed)), + 'EcMakePoint' => IGNORE_OPTS.call(C::EC.method(:emit_ec_make_point)), + 'EcPointX' => IGNORE_OPTS.call(C::EC.method(:emit_ec_point_x)), + 'EcPointY' => IGNORE_OPTS.call(C::EC.method(:emit_ec_point_y)), + 'P256Add' => C::NISTEC.method(:emit_p256_add), + 'P256Mul' => C::NISTEC.method(:emit_p256_mul), + 'P256MulGen' => C::NISTEC.method(:emit_p256_mul_gen), + 'P256Negate' => C::NISTEC.method(:emit_p256_negate), + 'P256OnCurve' => C::NISTEC.method(:emit_p256_on_curve), + 'P256EncodeCompressed' => IGNORE_OPTS.call(C::NISTEC.method(:emit_p256_encode_compressed)), + 'VerifyECDSA_P256' => C::NISTEC.method(:emit_verify_ecdsa_p256), + 'P384Add' => C::NISTEC.method(:emit_p384_add), + 'P384Mul' => C::NISTEC.method(:emit_p384_mul), + 'P384MulGen' => C::NISTEC.method(:emit_p384_mul_gen), + 'P384Negate' => C::NISTEC.method(:emit_p384_negate), + 'P384OnCurve' => C::NISTEC.method(:emit_p384_on_curve), + 'P384EncodeCompressed' => IGNORE_OPTS.call(C::NISTEC.method(:emit_p384_encode_compressed)), + 'VerifyECDSA_P384' => C::NISTEC.method(:emit_verify_ecdsa_p384) + } + end + + def fixture + @fixture ||= JSON.parse(File.read(FIXTURE_PATH)) + end + + def emit_and_hash(fn, opts) + ops = [] + fn.call(->(o) { ops << o }, opts) + res = C.emit_method({ name: 't', ops: ops }) + raw = [res[:script_hex]].pack('H*') + [raw.bytesize, Digest::SHA256.hexdigest(raw)] + end + + def test_ec_flag_parity_against_typescript_reference + emitters.each do |name, fn| + want = fixture['emitters'][name] + refute_nil want, "#{name}: no entry in the parity fixture" + fixture['variants'].each do |variant, spec| + opts = if spec.empty? + nil + else + C::EC::EcCodegenOptions.new(**spec.to_h { |k, v| [FIELD_MAP[k], v] }) + end + got = emit_and_hash(fn, opts) + expect = [want[variant]['bytes'], want[variant]['sha256']] + assert_equal expect, got, + "#{name} under #{variant}: Ruby and the TypeScript reference disagree" + end + end + end + + # nil options must reproduce the shipping output. This is what keeps the + # existing goldens, the size baseline and every cross-tier hex comparison from + # moving while the flags are experimental. + def test_ec_flags_default_off_is_byte_identical + emitters.each do |name, fn| + none = emit_and_hash(fn, nil) + off = emit_and_hash(fn, C::EC::EcCodegenOptions.new) + assert_equal none, off, "#{name}: nil and all-false options disagree" + assert_equal fixture['emitters'][name]['off']['sha256'], none[1], + "#{name}: default output moved" + end + end +end diff --git a/compilers/rust/src/codegen/comb.rs b/compilers/rust/src/codegen/comb.rs new file mode 100644 index 000000000..3f56a1b91 --- /dev/null +++ b/compilers/rust/src/codegen/comb.rs @@ -0,0 +1,324 @@ +//! Fixed-base comb: compile-time table, and the soundness check that decides +//! where the cheap incomplete addition may be used. +//! +//! Port of `packages/runar-compiler/src/passes/comb.ts`. The binary ladders in +//! `ec.rs` / `p256_p384.rs` use the cheap mixed add at every step but the last, +//! justified by an interval argument over `c_i mod n`. That comment is emphatic +//! that the argument must be RE-DERIVED, not assumed, by anything which changes +//! the offset, the iteration count, or the reduce — and a comb changes all +//! three. `comb_safe_rounds` below is that re-derivation, written as executable +//! interval arithmetic rather than prose, so a round only gets the cheap add +//! when the exception is proved unreachable. Rounds it cannot prove fall back to +//! the complete add-or-double form. +//! +//! Nothing here emits Script. It is pure arithmetic over `BigInt`, run once per +//! compilation, and unit-tested against published curve vectors. + +use num_bigint::BigInt; +use num_traits::{One, Signed, Zero}; +use std::sync::LazyLock; + +/// An affine point. `None` is the point at infinity. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CombPoint { + pub x: BigInt, + pub y: BigInt, +} + +/// A short-Weierstrass curve, for the compile-time table. +#[derive(Debug, Clone)] +pub struct CombCurve { + /// Field prime. + pub p: BigInt, + /// Curve coefficient a: -3 on the NIST curves, 0 on secp256k1. + pub a: BigInt, + /// Curve coefficient b. + pub b: BigInt, + /// Group order. + pub n: BigInt, + /// Base point. + pub g: CombPoint, +} + +/// Comb geometry for one window width, chosen so the top digit is never zero. +/// +/// The binary ladder hardcodes `k + 3n`, which puts the scalar's top bit at a +/// fixed position and so keeps the accumulator off the point at infinity. A comb +/// needs the same guarantee, but its first round reads bit `w*d - 1`, so the +/// offset has to be chosen against `w*d` rather than assumed. `offset_multiple` +/// is the smallest `m` for which every `k + m*n` has bit `w*d - 1` set: +/// +/// ```text +/// m*n >= 2^(w*d - 1) and (m+1)*n - 1 < 2^(w*d) +/// ``` +/// +/// `m*n ≡ 0 (mod n)` so the result is unchanged. For P-256 at w=3 the search +/// returns m=3, d=86 — i.e. exactly the `+3n` the binary ladder already uses. +/// For P-384 at w=3 it returns m=5, d=129; assuming `+3n` there would have left +/// the top digit free to be zero. +#[derive(Debug, Clone)] +pub struct CombParams { + pub w: usize, + /// Rounds, and the block width. Digit `i` reads bits `i, i+d, ..., i+(w-1)d`. + pub d: usize, + pub offset_multiple: BigInt, + /// Inclusive scalar domain after the offset. + pub lo: BigInt, + pub hi: BigInt, +} + +fn hex_big(s: &str) -> BigInt { + BigInt::parse_bytes(s.as_bytes(), 16).expect("comb: bad hex constant") +} + +/// P-256, P-384 and secp256k1 — the three curves the comb is wired for. +/// +/// secp256k1 is NOT built from the NIST template: it is `y² = x³ + 7`, so +/// `a = 0`. Getting `a` wrong here does not produce an obviously broken table — +/// it produces a table of points on a DIFFERENT curve, which that other curve's +/// on-curve check would happily accept. Hence the published 2G vectors pinned in +/// `comb_tests.rs`. +pub static P256_COMB_CURVE: LazyLock = LazyLock::new(|| CombCurve { + p: hex_big("ffffffff00000001000000000000000000000000ffffffffffffffffffffffff"), + a: BigInt::from(-3), + b: hex_big("5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b"), + n: hex_big("ffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551"), + g: CombPoint { + x: hex_big("6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296"), + y: hex_big("4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5"), + }, +}); + +pub static P384_COMB_CURVE: LazyLock = LazyLock::new(|| CombCurve { + p: hex_big("fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffeffffffff0000000000000000ffffffff"), + a: BigInt::from(-3), + b: hex_big("b3312fa7e23ee7e4988e056be3f82d19181d9c6efe8141120314088f5013875ac656398d8a2ed19d2a85c8edd3ec2aef"), + n: hex_big("ffffffffffffffffffffffffffffffffffffffffffffffffc7634d81f4372ddf581a0db248b0a77aecec196accc52973"), + g: CombPoint { + x: hex_big("aa87ca22be8b05378eb1c71ef320ad746e1d3b628ba79b9859f741e082542a385502f25dbf55296c3a545e3872760ab7"), + y: hex_big("3617de4a96262c6f5d9e98bf9292dc29f8f41dbd289a147ce9da3113b5f0b8c00a60b1ce1d7e819d7a431d7c90ea0e5f"), + }, +}); + +pub static SECP256K1_COMB_CURVE: LazyLock = LazyLock::new(|| CombCurve { + p: hex_big("fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f"), + a: BigInt::zero(), + b: BigInt::from(7), + n: hex_big("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"), + g: CombPoint { + x: hex_big("79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"), + y: hex_big("483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8"), + }, +}); + +/// Geometry for window width `w`, or `None` if no offset in the search range +/// puts a guaranteed set bit at the top of the first digit. Returning `None` +/// rather than guessing keeps the caller from silently combing a scalar whose +/// leading digit can vanish. +pub fn comb_geometry(w: usize, c: &CombCurve) -> Option { + let base = (c.n.bits() as usize).div_ceil(w); + for d in base..=base + 2 { + let bits = (w * d) as u64; + let top = BigInt::one() << (bits - 1); + let cap = BigInt::one() << bits; + for m in 1i64..=16 { + let mm = BigInt::from(m); + let lo = &mm * &c.n; + let hi = (&mm + BigInt::one()) * &c.n - BigInt::one(); + if lo >= top && hi < cap { + return Some(CombParams { w, d, offset_multiple: mm, lo, hi }); + } + } + } + None +} + +// --------------------------------------------------------------------------- +// Affine arithmetic (compile time only) +// --------------------------------------------------------------------------- + +fn comb_mod(v: &BigInt, m: &BigInt) -> BigInt { + let r = v % m; + if r.is_negative() { r + m } else { r } +} + +/// Modular inverse by extended Euclid. `v` is never 0 on the paths below. +fn inv(v: &BigInt, m: &BigInt) -> BigInt { + let (mut old_r, mut r) = (comb_mod(v, m), m.clone()); + let (mut old_s, mut s) = (BigInt::one(), BigInt::zero()); + while !r.is_zero() { + let q = &old_r / &r; + let nr = &old_r - &q * &r; + old_r = r; + r = nr; + let ns = &old_s - &q * &s; + old_s = s; + s = ns; + } + comb_mod(&old_s, m) +} + +/// Affine addition. `None` is the point at infinity. +pub fn comb_affine_add( + p: Option<&CombPoint>, + q: Option<&CombPoint>, + c: &CombCurve, +) -> Option { + let (p, q) = match (p, q) { + (None, _) => return q.cloned(), + (_, None) => return p.cloned(), + (Some(p), Some(q)) => (p, q), + }; + if p.x == q.x { + if comb_mod(&(&p.y + &q.y), &c.p).is_zero() { + return None; // P == -Q + } + // Tangent. + let num = comb_mod(&(BigInt::from(3) * &p.x * &p.x + &c.a), &c.p); + let den = inv(&comb_mod(&(&p.y * 2), &c.p), &c.p); + let lam = comb_mod(&(num * den), &c.p); + let x = comb_mod(&(&lam * &lam - &p.x * 2), &c.p); + let y = comb_mod(&(&lam * (&p.x - &x) - &p.y), &c.p); + return Some(CombPoint { x, y }); + } + let den = inv(&comb_mod(&(&q.x - &p.x), &c.p), &c.p); + let lam = comb_mod(&(comb_mod(&(&q.y - &p.y), &c.p) * den), &c.p); + let x = comb_mod(&(&lam * &lam - &p.x - &q.x), &c.p); + let y = comb_mod(&(&lam * (&p.x - &x) - &p.y), &c.p); + Some(CombPoint { x, y }) +} + +/// Compile-time double-and-add. `None` is the point at infinity. +pub fn comb_scalar_mul(k: &BigInt, p: &CombPoint, c: &CombCurve) -> Option { + let mut r: Option = None; + let mut base = Some(p.clone()); + let mut e = comb_mod(k, &c.n); + while e.is_positive() { + if e.bit(0) { + r = comb_affine_add(r.as_ref(), base.as_ref(), c); + } + base = comb_affine_add(base.as_ref(), base.as_ref(), c); + e >>= 1; + } + r +} + +// --------------------------------------------------------------------------- +// Comb table +// --------------------------------------------------------------------------- + +/// The multiple of G that table entry `j` represents. +/// +/// Comb round `i` consumes bits `{i, i+d, i+2d, ...}` of the scalar — one from +/// each block — so entry `j` stands for the sum of `2^(t*d)` over the set bits +/// `t` of `j`. +pub fn comb_value(j: usize, d: usize) -> BigInt { + let mut v = BigInt::zero(); + let mut t = 0usize; + while (j >> t) != 0 { + if (j >> t) & 1 == 1 { + v += BigInt::one() << (t * d) as u64; + } + t += 1; + } + v +} + +/// `T[j] = comb_value(j)·G`. Index 0 is the point at infinity and is never added. +pub fn comb_table(w: usize, d: usize, c: &CombCurve) -> Vec> { + (0..(1usize << w)) + .map(|j| if j == 0 { None } else { comb_scalar_mul(&comb_value(j, d), &c.g, c) }) + .collect() +} + +// --------------------------------------------------------------------------- +// Soundness: where may the cheap incomplete addition be used? +// --------------------------------------------------------------------------- + +/// Bounds on the comb accumulator's multiplier before round `i`'s doubling. +/// +/// After processing rounds `d-1 .. i`, the accumulator is `c_i·G` with +/// +/// ```text +/// c_i = Σ_m 2^(m·d) · floor(K_m / 2^i) +/// ``` +/// +/// where `K_m` is the m-th `d`-bit block of the expanded scalar. Each floor +/// discards less than one unit of its block, so +/// +/// ```text +/// k/2^i - Σ_m 2^(m·d) < c_i <= k/2^i +/// ``` +/// +/// and with `k` confined to `[lo, hi]` that gives a contiguous interval. The +/// slack term is bounded by `2^(w·d)/(2^d - 1)`, far below `n`, which is why the +/// interval stays narrower than the group order for all but the last few rounds +/// — exactly the property the binary ladder's argument relies on. +fn accumulator_interval(i: usize, params: &CombParams) -> (BigInt, BigInt) { + let mut slack = BigInt::zero(); + for m in 0..params.w { + slack += BigInt::one() << (m * params.d) as u64; + } + let hi = ¶ms.hi >> i as u64; + let lo = (¶ms.lo >> i as u64) - slack; + (if lo.is_negative() { BigInt::zero() } else { lo }, hi) +} + +/// Does `[lo, hi]` contain an integer congruent to `target` modulo `n`? +fn interval_hits_residue(lo: &BigInt, hi: &BigInt, target: &BigInt, n: &BigInt) -> bool { + if hi < lo { + return false; + } + if hi - lo + BigInt::one() >= *n { + return true; // wraps a full residue class + } + let t = comb_mod(target, n); + // Smallest value >= lo that is congruent to t (mod n). + let first = lo + comb_mod(&(t - lo), n); + first <= *hi +} + +/// Per-round verdict: may round `i` use the cheap incomplete mixed add? +/// +/// The exception the cheap formula cannot represent is a pre-add accumulator +/// equal to the addend, its negation, or the point at infinity. After round +/// `i`'s doubling the accumulator is `2·c_{i+1}·G`, and the addend is +/// `comb_value(j)·G` for whichever digit `j` the scalar selects — so the round +/// is safe exactly when, for every `j`, +/// +/// ```text +/// 2·c_{i+1} ≢ 0, +comb_value(j), -comb_value(j) (mod n) +/// ``` +/// +/// over the whole interval of `c_{i+1}`. Both `G` and every table entry are +/// compile-time constants and the curves have cofactor 1, so `ord(G) = n` and +/// this is decidable here. Anything the checker cannot prove gets the complete +/// add-or-double form instead; `true` is never assumed. +/// +/// Index `d-1` is `false` by construction: that round initialises the +/// accumulator from the table and performs no addition at all. +pub fn comb_safe_rounds(params: &CombParams, c: &CombCurve) -> Vec { + let values: Vec = (1..(1usize << params.w)) + .map(|j| comb_value(j, params.d)) + .collect(); + + let mut safe = vec![false; params.d]; + for i in 0..params.d { + if i == params.d - 1 { + continue; + } + let (lo, hi) = accumulator_interval(i + 1, params); + let d_lo = lo << 1u32; + let d_hi = hi << 1u32; + let mut ok = !interval_hits_residue(&d_lo, &d_hi, &BigInt::zero(), &c.n); + for v in &values { + if !ok { + break; + } + ok = !interval_hits_residue(&d_lo, &d_hi, v, &c.n) + && !interval_hits_residue(&d_lo, &d_hi, &(-v), &c.n); + } + safe[i] = ok; + } + safe +} diff --git a/compilers/rust/src/codegen/cost_model.rs b/compilers/rust/src/codegen/cost_model.rs new file mode 100644 index 000000000..5b431e621 --- /dev/null +++ b/compilers/rust/src/codegen/cost_model.rs @@ -0,0 +1,94 @@ +//! Script-byte cost model for Stack IR. +//! +//! Port of `packages/runar-compiler/src/metrics/cost-model.ts`. Optimizer passes +//! need to compare two candidate lowerings by the metric that actually matters — +//! serialized locking-script bytes — before either one is emitted. `OP_DUP` and +//! a 33-byte constant push are one instruction each and 1 vs 34 bytes; an +//! instruction count cannot tell them apart. +//! +//! This is deliberately NOT an approximation: every push routes through the same +//! encoders `emit.rs` uses, so +//! +//! ```text +//! estimate_script_bytes(ops) == emit_method(..).script_hex.len() / 2 +//! ``` +//! +//! holds exactly. `cost_model_tests.rs` asserts that over every crypto emitter. + +use num_bigint::BigInt; + +use super::emit::{encode_push_int, encode_push_data}; +use super::opcodes::opcode_byte; +use super::stack::{PushValue, StackOp}; + +/// Serialized byte cost of a single push value. +/// +/// Mirrors `encode_push_value` in `emit.rs`: booleans are the 1-byte OP_TRUE / +/// OP_FALSE, integers go through the small-int opcodes where possible, and byte +/// slices are MINIMALDATA-aware before falling back to a length-prefixed push. +pub fn size_of_push_value(value: &PushValue) -> usize { + match value { + PushValue::Bool(_) => 1, + PushValue::Int(n) => encode_push_int(n).0.len() / 2, + PushValue::Bytes(b) => encode_push_data(b).len(), + } +} + +/// `size_of_push_value` for a bare integer, which is what the constant pool and +/// the comb width search compare against. +pub fn size_of_push_int(n: &BigInt) -> usize { + encode_push_int(n).0.len() / 2 +} + +/// Serialized byte cost of one Stack IR operation, including nested `if` arms. +/// +/// Note on `Pick` / `Roll`: they cost ONE byte here. The depth operand is a +/// separate `Push` op that the tracker emits immediately before, so charging the +/// depth here would double-count it. +/// +/// Panics on an unknown opcode mnemonic rather than costing it zero — a typo in +/// a codegen module should surface loudly, not as a cost model that quietly +/// under-reports. +pub fn size_of_stack_op(op: &StackOp) -> usize { + match op { + StackOp::Push(v) => size_of_push_value(v), + + StackOp::Dup + | StackOp::Swap + | StackOp::Roll { .. } + | StackOp::Pick { .. } + | StackOp::Drop + | StackOp::Nip + | StackOp::Over + | StackOp::Rot + | StackOp::Tuck => 1, + + StackOp::Opcode(code) => { + if opcode_byte(code).is_none() { + panic!("cost-model: unknown opcode '{}'", code); + } + 1 + } + + // OP_IF + then + [OP_ELSE + else] + OP_ENDIF. The emitter writes + // OP_ELSE only for a NON-EMPTY else arm. + StackOp::If { then_ops, else_ops } => { + let mut total = 2; + total += estimate_script_bytes(then_ops); + if !else_ops.is_empty() { + total += 1 + estimate_script_bytes(else_ops); + } + total + } + + // Both emit a single 0x00 byte that the SDK rewrites later. + StackOp::Placeholder { .. } | StackOp::PushCodeSepIndex => 1, + + StackOp::RawBytes { bytes, .. } => bytes.len(), + } +} + +/// Serialized byte cost of a Stack IR sequence. +pub fn estimate_script_bytes(ops: &[StackOp]) -> usize { + ops.iter().map(size_of_stack_op).sum() +} diff --git a/compilers/rust/src/codegen/ec.rs b/compilers/rust/src/codegen/ec.rs index 9042028b6..6d466b54f 100644 --- a/compilers/rust/src/codegen/ec.rs +++ b/compilers/rust/src/codegen/ec.rs @@ -7,7 +7,11 @@ //! Internal arithmetic uses Jacobian coordinates for scalar multiplication. use num_bigint::BigInt; +use std::sync::LazyLock; +use num_traits::ToPrimitive; use super::stack::{PushValue, StackOp}; +use super::cost_model::{estimate_script_bytes, size_of_push_int}; +use super::comb::{comb_geometry, comb_safe_rounds, comb_table, SECP256K1_COMB_CURVE}; // =========================================================================== // Constants @@ -57,25 +61,192 @@ fn collect_ops(f: impl FnOnce(&mut dyn FnMut(StackOp))) -> Vec { // ECTracker — named stack state tracker (mirrors SLHTracker) // =========================================================================== -struct ECTracker<'a> { - nm: Vec, - e: &'a mut dyn FnMut(StackOp), +/// Codegen options shared by every EC / NIST-curve emitter. +/// +/// Off by default: with `None` (or an all-false struct) each emitter is +/// byte-identical to what the seven tiers ship today, so no golden, size +/// baseline, or cross-tier parity gate can move. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct EcCodegenOptions { + /// Park large repeated constants (the field prime, the group order) in a + /// stack slot and copy them with `OP_PICK` instead of re-pushing the + /// literal. + /// + /// `field_mod` pushes the 256-bit prime at every modular reduction — 34 + /// bytes a time, 20,025 times in `p256-wallet` (71 % of that fixture). A + /// pick from a slot a dozen deep costs 2. + pub constant_pool: bool, + + /// 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. + /// + /// Which reductions qualify is decided by the sign lattice below — never + /// assumed. Only useful alongside `constant_pool`: the cheap subtraction + /// references the prime twice, so without a pooled slot it does not pay (and + /// the emitters compare the two costs, so it is never taken when it does + /// not). + pub reduction_sinking: bool, + + /// Use a fixed-base comb instead of the binary ladder wherever the base + /// point is a compile-time constant (`ecMulGen`, `p256MulGen`, + /// `p384MulGen`, and the `u1·G` half of ECDSA verification). + /// + /// The window width is not fixed here: the emitter renders each candidate + /// and keeps whichever the byte-cost model scores smallest. + pub fixed_base_comb: bool, +} + +/// 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 `raw_block` 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))`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)] +pub enum Dom { + /// Nothing known. May be negative. + #[default] + Unknown, + /// Provably >= 0. May be >= p. + NonNegative, + /// Provably in [0, p). + Reduced, +} + +impl Dom { + /// True when this proves the value is >= 0. + pub(crate) fn is_non_negative(self) -> bool { + self >= Dom::NonNegative + } +} + +/// Stack slot names reserved for pooled constants. +pub const POOL_FIELD_P: &str = "_pool$p"; +pub const POOL_GROUP_N: &str = "_pool$n"; + +pub(crate) struct ECTracker<'a> { + pub(crate) nm: Vec, + /// 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. + pub(crate) dm: Vec, + /// Lattice facts for values parked on the alt stack, bottom -> top. + alt_dm: Vec, + pub(crate) e: &'a mut dyn FnMut(StackOp), + /// True when this tracker may serve constants from a pooled slot. + pub(crate) pooling: bool, + /// True when this tracker may emit sunk reductions. + pub(crate) sinking: bool, + /// True when a compile-time-known base may use a fixed-base comb. + pub(crate) comb: bool, } #[allow(dead_code)] impl<'a> ECTracker<'a> { - fn new(init: &[&str], emit: &'a mut dyn FnMut(StackOp)) -> Self { + pub(crate) fn new(init: &[&str], emit: &'a mut dyn FnMut(StackOp)) -> Self { + Self::with_opts(init, emit, None, None) + } + + /// Create a tracker carrying codegen options and, optionally, initial + /// lattice facts for the pre-existing slots. + pub(crate) fn with_opts( + init: &[&str], + emit: &'a mut dyn FnMut(StackOp), + opts: Option<&EcCodegenOptions>, + init_domains: Option<&[Dom]>, + ) -> Self { + let nm: Vec = init.iter().map(|s| s.to_string()).collect(); + let dm: Vec = match init_domains { + Some(d) => d.to_vec(), + None => vec![Dom::Unknown; nm.len()], + }; + let o = opts.copied().unwrap_or_default(); ECTracker { - nm: init.iter().map(|s| s.to_string()).collect(), + nm, + dm, + alt_dm: Vec::new(), e: emit, + pooling: o.constant_pool, + sinking: o.reduction_sinking, + comb: o.fixed_base_comb, + } + } + + /// The options this tracker was built with, for handing to a nested tracker. + pub(crate) fn options(&self) -> EcCodegenOptions { + EcCodegenOptions { + constant_pool: self.pooling, + reduction_sinking: self.sinking, + fixed_base_comb: self.comb, + } + } + + // -- sign lattice -------------------------------------------------------- + + /// What is known about the named value. `Unknown` when the name is absent. + pub(crate) fn domain_of(&self, name: &str) -> 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. + assert_eq!( + self.dm.len(), + self.nm.len(), + "ECTracker: lattice desynchronised. Every nm mutation must go through \ + a tracker method or push_tracked/pop_tracked." + ); + for i in (0..self.nm.len()).rev() { + if self.nm[i] == name { + return self.dm[i]; + } } + Dom::Unknown } - fn depth(&self) -> usize { + /// Record a fact about the named value's slot. + pub(crate) fn set_domain(&mut self, name: &str, d: Dom) { + for i in (0..self.nm.len()).rev() { + if self.nm[i] == name { + self.dm[i] = d; + return; + } + } + } + + /// Push a slot the caller tracks itself (used where raw opcodes create items). + pub(crate) fn push_tracked(&mut self, name: &str, d: Dom) { + self.nm.push(name.to_string()); + self.dm.push(d); + } + + /// Pop a slot the caller tracks itself. Mirror of `push_tracked`. + pub(crate) fn pop_tracked(&mut self) -> Option { + self.dm.pop(); + self.nm.pop() + } + + /// Remove the slot at an absolute (bottom-relative) index. + pub(crate) fn remove_slot_at(&mut self, index: usize) -> (String, Dom) { + (self.nm.remove(index), self.dm.remove(index)) + } + + pub(crate) fn depth(&self) -> usize { self.nm.len() } - fn find_depth(&self, name: &str) -> usize { + pub(crate) fn find_depth(&self, name: &str) -> usize { for i in (0..self.nm.len()).rev() { if self.nm[i] == name { return self.nm.len() - 1 - i; @@ -84,63 +255,78 @@ impl<'a> ECTracker<'a> { panic!("ECTracker: '{}' not on stack {:?}", name, self.nm); } - fn push_bytes(&mut self, n: &str, v: Vec) { + pub(crate) fn push_bytes(&mut self, n: &str, v: Vec) { (self.e)(StackOp::Push(PushValue::Bytes(v))); - self.nm.push(n.to_string()); + // A byte blob is not a number until BIN2NUM decides how to read it. + self.push_tracked(n, Dom::Unknown); } - fn push_int(&mut self, n: &str, v: i128) { + pub(crate) fn push_int(&mut self, n: &str, v: i128) { (self.e)(StackOp::Push(PushValue::Int(BigInt::from(v)))); - self.nm.push(n.to_string()); + self.push_tracked(n, if v >= 0 { Dom::NonNegative } else { Dom::Unknown }); } - fn dup(&mut self, n: &str) { + /// Push an arbitrary-precision integer. + /// + /// The EC constants exceed `i128`, and the tier used to push them as + /// pre-encoded script-number BYTE blobs. Encoded hex is identical either + /// way, but a `Bytes` push is invisible to the peephole's constant folding + /// and to the lattice, so a repeated constant could neither be folded nor + /// proved non-negative. Pushing them as `Int` restores both. + pub(crate) fn push_big(&mut self, n: &str, v: &BigInt) { + (self.e)(StackOp::Push(PushValue::Int(v.clone()))); + let d = if v.sign() == num_bigint::Sign::Minus { Dom::Unknown } else { Dom::NonNegative }; + self.push_tracked(n, d); + } + + pub(crate) fn dup(&mut self, n: &str) { (self.e)(StackOp::Dup); - self.nm.push(n.to_string()); + let d = self.dm.last().copied().unwrap_or_default(); + self.push_tracked(n, d); } - fn drop(&mut self) { + pub(crate) fn drop(&mut self) { (self.e)(StackOp::Drop); - if !self.nm.is_empty() { - self.nm.pop(); - } + self.pop_tracked(); } - fn nip(&mut self) { + pub(crate) fn nip(&mut self) { (self.e)(StackOp::Nip); let len = self.nm.len(); if len >= 2 { - self.nm.remove(len - 2); + self.remove_slot_at(len - 2); } } - fn over(&mut self, n: &str) { + pub(crate) fn over(&mut self, n: &str) { (self.e)(StackOp::Over); - self.nm.push(n.to_string()); + let d = if self.dm.len() >= 2 { self.dm[self.dm.len() - 2] } else { Dom::Unknown }; + self.push_tracked(n, d); } - fn swap(&mut self) { + pub(crate) fn swap(&mut self) { (self.e)(StackOp::Swap); let len = self.nm.len(); if len >= 2 { self.nm.swap(len - 1, len - 2); + self.dm.swap(len - 1, len - 2); } } - fn rot(&mut self) { + pub(crate) fn rot(&mut self) { (self.e)(StackOp::Rot); let len = self.nm.len(); if len >= 3 { - let r = self.nm.remove(len - 3); - self.nm.push(r); + let (r, rd) = self.remove_slot_at(len - 3); + self.push_tracked(&r, rd); } } - fn op(&mut self, code: &str) { + pub(crate) fn op(&mut self, code: &str) { (self.e)(StackOp::Opcode(code.into())); } - fn roll(&mut self, d: usize) { + pub(crate) fn roll(&mut self, d: usize) { if d == 0 { return; } @@ -153,15 +339,15 @@ impl<'a> ECTracker<'a> { return; } (self.e)(StackOp::Push(PushValue::Int(BigInt::from(d as i128)))); - self.nm.push(String::new()); + self.push_tracked("", Dom::NonNegative); (self.e)(StackOp::Opcode("OP_ROLL".into())); - self.nm.pop(); // pop the push + self.pop_tracked(); // the depth literal let idx = self.nm.len() - 1 - d; - let r = self.nm.remove(idx); - self.nm.push(r); + let (r, rd) = self.remove_slot_at(idx); + self.push_tracked(&r, rd); } - fn pick(&mut self, d: usize, n: &str) { + pub(crate) fn pick(&mut self, d: usize, n: &str) { if d == 0 { self.dup(n); return; @@ -171,60 +357,126 @@ impl<'a> ECTracker<'a> { return; } (self.e)(StackOp::Push(PushValue::Int(BigInt::from(d as i128)))); - self.nm.push(String::new()); + self.push_tracked("", Dom::NonNegative); (self.e)(StackOp::Opcode("OP_PICK".into())); - self.nm.pop(); // pop the push - self.nm.push(n.to_string()); + self.pop_tracked(); // the depth literal + // Once the depth literal is gone the copied slot sits at depth d. + let src = if self.dm.len() > d { self.dm[self.dm.len() - 1 - d] } else { Dom::Unknown }; + self.push_tracked(n, src); } - fn to_top(&mut self, name: &str) { + pub(crate) fn to_top(&mut self, name: &str) { let d = self.find_depth(name); self.roll(d); } - fn copy_to_top(&mut self, name: &str, n: &str) { + pub(crate) fn copy_to_top(&mut self, name: &str, n: &str) { let d = self.find_depth(name); self.pick(d, n); } - fn to_alt(&mut self) { + // -- constant pool ------------------------------------------------------- + // + // A pooled constant is an ordinary tracked slot; nothing about the stack + // model changes. `push_const` just chooses, per call site and by emitted + // bytes, between copying that slot and re-pushing the literal. Nested + // trackers built from `nm.clone()` inherit the slot for free, so pooled + // constants work unchanged inside an `OP_IF` arm. + + pub(crate) fn has_slot(&self, slot: &str) -> bool { + self.nm.iter().any(|n| n == slot) + } + + /// Park `value` in `slot` for the lifetime of this emitter. No-op when + /// pooling is off. + pub(crate) fn pool_constant(&mut self, slot: &str, value: &BigInt) { + if !self.pooling || self.has_slot(slot) { + return; + } + self.push_big(slot, value); + } + + /// Remove a pooled slot. No-op when pooling is off or the slot is absent. + pub(crate) fn release_constant(&mut self, slot: &str) { + if !self.pooling || !self.has_slot(slot) { + return; + } + self.to_top(slot); + self.drop(); + } + + /// Emitted bytes a `push_const` of this constant would cost right now. + /// + /// The comparison is exact — `size_of_push_int` is the same encoder the emit + /// pass uses — so pooling can never make a call site bigger. A pick at depth + /// d costs `size_of_push_int(d) + 1`; depths 0 and 1 are OP_DUP / OP_OVER, 1 + /// byte each. + pub(crate) fn const_cost(&self, slot: &str, value: &BigInt) -> usize { + if self.pooling && self.has_slot(slot) { + let d = self.find_depth(slot); + let pick_cost = if d <= 1 { 1 } else { size_of_push_int(&BigInt::from(d)) + 1 }; + if pick_cost < size_of_push_int(value) { + return pick_cost; + } + } + size_of_push_int(value) + } + + /// Materialize `value` on top as `name`, from the pooled `slot` when that is + /// cheaper in emitted bytes than pushing the literal. + pub(crate) fn push_const(&mut self, slot: &str, value: &BigInt, name: &str) { + if self.pooling && self.has_slot(slot) { + let d = self.find_depth(slot); + let pick_cost = if d <= 1 { 1 } else { size_of_push_int(&BigInt::from(d)) + 1 }; + if pick_cost < size_of_push_int(value) { + self.pick(d, name); + return; + } + } + self.push_big(name, value); + } + + pub(crate) fn to_alt(&mut self) { self.op("OP_TOALTSTACK"); if !self.nm.is_empty() { - self.nm.pop(); + let d = self.dm[self.dm.len() - 1]; + self.pop_tracked(); + self.alt_dm.push(d); } } - fn from_alt(&mut self, n: &str) { + pub(crate) fn from_alt(&mut self, n: &str) { self.op("OP_FROMALTSTACK"); - self.nm.push(n.to_string()); + let d = self.alt_dm.pop().unwrap_or_default(); + self.push_tracked(n, d); } - fn rename(&mut self, n: &str) { + pub(crate) fn rename(&mut self, n: &str) { if let Some(last) = self.nm.last_mut() { *last = n.to_string(); } } /// Emit raw opcodes; tracker only records net stack effect. - fn raw_block( + pub(crate) fn raw_block( &mut self, consume: &[&str], produce: Option<&str>, f: impl FnOnce(&mut dyn FnMut(StackOp)), ) { for _ in consume { - if !self.nm.is_empty() { - self.nm.pop(); - } + self.pop_tracked(); } f(self.e); if let Some(p) = produce { - self.nm.push(p.to_string()); + // Opaque opcodes: nothing is known about the result unless the + // caller proves it and records that with `set_domain` afterwards. + self.push_tracked(p, Dom::Unknown); } } /// Emit if/else with tracked stack effect. - fn emit_if( + pub(crate) fn emit_if( &mut self, cond_name: &str, then_fn: impl FnOnce(&mut dyn FnMut(StackOp)), @@ -232,7 +484,7 @@ impl<'a> ECTracker<'a> { result_name: Option<&str>, ) { self.to_top(cond_name); - self.nm.pop(); // condition consumed + self.pop_tracked(); // condition consumed let then_ops = collect_ops(then_fn); let else_ops = collect_ops(else_fn); (self.e)(StackOp::If { @@ -240,7 +492,8 @@ impl<'a> ECTracker<'a> { else_ops, }); if let Some(rn) = result_name { - self.nm.push(rn.to_string()); + // A join over two arms this tracker did not analyse: nothing is known. + self.push_tracked(rn, Dom::Unknown); } } } @@ -262,16 +515,61 @@ const FIELD_P_SCRIPT_NUM: [u8; 33] = [ 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, ]; +/// secp256k1 field prime p, as an integer. +/// +/// The tier used to push this (and the group order) as a pre-encoded +/// script-number BYTE blob, because the value exceeds `i128`. `PushValue::Int` +/// carries a `BigInt`, so the blob was never necessary — and it cost real +/// things: a `Bytes` push is invisible to the peephole's constant folding (which +/// is why the `+3n` chain had to be pre-folded by hand) and to the sign lattice. +pub(crate) static FIELD_P: LazyLock = LazyLock::new(|| { + BigInt::parse_bytes( + b"fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f", 16).unwrap() +}); + +/// secp256k1 curve order n. +pub(crate) static CURVE_N: LazyLock = LazyLock::new(|| { + BigInt::parse_bytes( + b"fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141", 16).unwrap() +}); + /// Push the field prime p onto the stack as a script number. fn push_field_p(t: &mut ECTracker, name: &str) { - // Push p as pre-encoded script number bytes — equivalent to pushInt(FIELD_P) - // in the TS implementation, but using bytes since FIELD_P exceeds i128. - t.push_bytes(name, FIELD_P_SCRIPT_NUM.to_vec()); + t.push_const(POOL_FIELD_P, &FIELD_P, name); +} + +/// `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. +fn field_mod_short(t: &mut ECTracker, a_name: &str, result_name: &str) { + t.to_top(a_name); + push_field_p(t, "_fmods_p"); + t.raw_block(&[a_name, "_fmods_p"], Some(result_name), |e| { + e(StackOp::Opcode("OP_MOD".into())); + }); + t.set_domain(result_name, Dom::Reduced); +} + +/// Does the cheap `a - b + p` subtraction shape pay here? +/// +/// It references the prime TWICE where 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. +fn cheap_sub_pays(t: &ECTracker) -> bool { + let c = t.const_cost(POOL_FIELD_P, &FIELD_P); + 2 * c + 2 < c + 8 } /// fieldMod: reduce TOS mod p, ensure non-negative. /// Expects `a_name` to be on the tracker stack. fn field_mod(t: &mut ECTracker, a_name: &str, result_name: &str) { + if t.sinking && t.domain_of(a_name).is_non_negative() { + field_mod_short(t, a_name, result_name); + return; + } t.to_top(a_name); push_field_p(t, "_fmod_p"); // (a % p + p) % p @@ -285,15 +583,22 @@ fn field_mod(t: &mut ECTracker, a_name: &str, result_name: &str) { e(StackOp::Swap); // (a%p+p) p e(StackOp::Opcode("OP_MOD".into())); // ((a%p+p)%p) }); + t.set_domain(result_name, Dom::Reduced); } /// fieldAdd: (a + b) mod p. fn field_add(t: &mut ECTracker, a_name: &str, b_name: &str, result_name: &str) { + // Read the operand facts BEFORE raw_block consumes their slots. + let sum_non_neg = + t.domain_of(a_name).is_non_negative() && t.domain_of(b_name).is_non_negative(); t.to_top(a_name); t.to_top(b_name); t.raw_block(&[a_name, b_name], Some("_fadd_sum"), |e| { e(StackOp::Opcode("OP_ADD".into())); }); + if sum_non_neg { + t.set_domain("_fadd_sum", Dom::NonNegative); + } field_mod(t, "_fadd_sum", result_name); } @@ -301,24 +606,59 @@ fn field_add(t: &mut ECTracker, a_name: &str, b_name: &str, result_name: &str) { fn field_sub(t: &mut ECTracker, a_name: &str, b_name: &str, result_name: &str) { t.to_top(a_name); t.to_top(b_name); + // 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. + let cheap = t.sinking + && t.domain_of(a_name).is_non_negative() + && t.domain_of(b_name) == Dom::Reduced + && cheap_sub_pays(t); + t.raw_block(&[a_name, b_name], Some("_fsub_diff"), |e| { e(StackOp::Opcode("OP_SUB".into())); }); + + if cheap { + push_field_p(t, "_fsub_p"); + t.raw_block(&["_fsub_diff", "_fsub_p"], Some("_fsub_shift"), |e| { + e(StackOp::Opcode("OP_ADD".into())); + }); + t.set_domain("_fsub_shift", Dom::NonNegative); + field_mod_short(t, "_fsub_shift", result_name); + return; + } field_mod(t, "_fsub_diff", result_name); } /// fieldMul: (a * b) mod p. fn field_mul(t: &mut ECTracker, a_name: &str, b_name: &str, result_name: &str) { + field_mul_signed(t, a_name, b_name, result_name, false); +} + +/// `field_mul` with an explicit assertion about the product's sign, independent +/// of the operands — `field_sqr` uses it, since a*a >= 0 for any a whatsoever. +fn field_mul_signed( + t: &mut ECTracker, a_name: &str, b_name: &str, result_name: &str, + product_non_negative: bool, +) { + let non_neg = product_non_negative + || (t.domain_of(a_name).is_non_negative() && t.domain_of(b_name).is_non_negative()); t.to_top(a_name); t.to_top(b_name); t.raw_block(&[a_name, b_name], Some("_fmul_prod"), |e| { e(StackOp::Opcode("OP_MUL".into())); }); + if non_neg { + t.set_domain("_fmul_prod", Dom::NonNegative); + } field_mod(t, "_fmul_prod", result_name); } /// fieldMulConst: (a * c) mod p where c is a small constant. fn field_mul_const(t: &mut ECTracker, a_name: &str, c: i128, result_name: &str) { + // Every call site passes a small positive c, so the product keeps a's sign. + let non_neg = c > 0 && t.domain_of(a_name).is_non_negative(); t.to_top(a_name); t.raw_block(&[a_name], Some("_fmc_prod"), |e| { if c == 2 { @@ -329,13 +669,16 @@ fn field_mul_const(t: &mut ECTracker, a_name: &str, c: i128, result_name: &str) e(StackOp::Opcode("OP_MUL".into())); } }); + if non_neg { + t.set_domain("_fmc_prod", Dom::NonNegative); + } field_mod(t, "_fmc_prod", result_name); } -/// fieldSqr: (a * a) mod p. +/// fieldSqr: (a * a) mod p. A square is non-negative whatever a's sign is. fn field_sqr(t: &mut ECTracker, a_name: &str, result_name: &str) { t.copy_to_top(a_name, "_fsqr_copy"); - field_mul(t, a_name, "_fsqr_copy", result_name); + field_mul_signed(t, a_name, "_fsqr_copy", result_name, true); } /// fieldInv: a^(p-2) mod p via square-and-multiply. @@ -390,8 +733,8 @@ fn decompose_point(t: &mut ECTracker, point_name: &str, x_name: &str, y_name: &s e(StackOp::Opcode("OP_SPLIT".into())); }); // Manually track the two new items - t.nm.push("_dp_xb".to_string()); - t.nm.push("_dp_yb".to_string()); + t.push_tracked("_dp_xb", Dom::Unknown); + t.push_tracked("_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 @@ -401,6 +744,10 @@ fn decompose_point(t: &mut ECTracker, point_name: &str, x_name: &str, y_name: &s e(StackOp::Opcode("OP_CAT".into())); e(StackOp::Opcode("OP_BIN2NUM".into())); }); + // 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.set_domain(y_name, Dom::NonNegative); // Convert x_bytes to num t.to_top("_dp_xb"); @@ -410,6 +757,7 @@ fn decompose_point(t: &mut ECTracker, point_name: &str, x_name: &str, y_name: &s e(StackOp::Opcode("OP_CAT".into())); e(StackOp::Opcode("OP_BIN2NUM".into())); }); + t.set_domain(x_name, Dom::NonNegative); // Stack: [yName, xName] — swap to standard order [xName, yName] t.swap(); @@ -696,10 +1044,13 @@ fn jacobian_to_affine(t: &mut ECTracker, rx_name: &str, ry_name: &str) { /// Stack layout: [..., ax, ay, _k, jx, jy, jz] /// After: [..., ax, ay, _k, jx', jy', jz'] fn build_jacobian_add_affine_inline(e: &mut dyn FnMut(StackOp), t: &ECTracker) { - // Create inner tracker with cloned stack state + // Create the inner tracker with cloned stack state AND lattice facts: the + // operands' proved domains are what decide which reduction shape the body + // emits, so dropping them here would silently fall back everywhere. let cloned_nm: Vec = t.nm.clone(); let init_strs: Vec<&str> = cloned_nm.iter().map(|s| s.as_str()).collect(); - let mut it = ECTracker::new(&init_strs, e); + let opts = t.options(); + let mut it = ECTracker::with_opts(&init_strs, e, Some(&opts), Some(&t.dm)); jacobian_add_affine_body(&mut it, false); } @@ -851,7 +1202,8 @@ fn select_coord(t: &mut ECTracker, add_name: &str, dbl_name: &str, cond_name: &s fn build_jacobian_add_or_double_inline(e: &mut dyn FnMut(StackOp), t: &ECTracker) { let cloned_nm: Vec = t.nm.clone(); let init_strs: Vec<&str> = cloned_nm.iter().map(|s| s.as_str()).collect(); - let mut it = ECTracker::new(&init_strs, e); + let opts = t.options(); + let mut it = ECTracker::with_opts(&init_strs, e, Some(&opts), Some(&t.dm)); let it = &mut it; // Keep the pre-add accumulator: it is what must be DOUBLED in the @@ -909,12 +1261,14 @@ fn build_jacobian_add_or_double_inline(e: &mut dyn FnMut(StackOp), t: &ECTracker /// ecAdd: add two points. /// Stack in: [point_a, point_b] (b on top) /// Stack out: [result_point] -pub fn emit_ec_add(emit: &mut dyn FnMut(StackOp)) { - let mut t = ECTracker::new(&["_pa", "_pb"], emit); +pub fn emit_ec_add(emit: &mut dyn FnMut(StackOp), opts: Option<&EcCodegenOptions>) { + let mut t = ECTracker::with_opts(&["_pa", "_pb"], emit, opts, None); + t.pool_constant(POOL_FIELD_P, &FIELD_P); decompose_point(&mut t, "_pa", "px", "py"); decompose_point(&mut t, "_pb", "qx", "qy"); affine_add(&mut t); compose_point(&mut t, "rx", "ry", "_result"); + t.release_constant(POOL_FIELD_P); } /// Reduce a scalar to [0, n-1]: ((k mod n) + n) mod n. @@ -930,7 +1284,7 @@ pub fn emit_ec_add(emit: &mut dyn FnMut(StackOp)) { /// costs 1 push + 8 opcodes (42 bytes) against a ~429 KB script, and makes /// k >= n, k < 0 and k = 0 all well defined. fn emit_scalar_reduce(t: &mut ECTracker, k_name: &str, result_name: &str) { - t.push_bytes("_n_red", CURVE_N_SCRIPT_NUM.to_vec()); + t.push_const(POOL_GROUP_N, &CURVE_N, "_n_red"); t.raw_block(&[k_name, "_n_red"], Some(result_name), |e| { e(StackOp::Opcode("OP_2DUP".into())); e(StackOp::Opcode("OP_MOD".into())); @@ -948,8 +1302,10 @@ fn emit_scalar_reduce(t: &mut ECTracker, k_name: &str, result_name: &str) { /// Stack out: [result_point] /// /// Uses 256-iteration double-and-add with Jacobian coordinates. -pub fn emit_ec_mul(emit: &mut dyn FnMut(StackOp)) { - let mut t = ECTracker::new(&["_pt", "_k"], emit); +pub fn emit_ec_mul(emit: &mut dyn FnMut(StackOp), opts: Option<&EcCodegenOptions>) { + let mut t = ECTracker::with_opts(&["_pt", "_k"], emit, opts, None); + t.pool_constant(POOL_FIELD_P, &FIELD_P); + t.pool_constant(POOL_GROUP_N, &CURVE_N); // Decompose to affine base point decompose_point(&mut t, "_pt", "ax", "ay"); @@ -962,8 +1318,20 @@ pub fn emit_ec_mul(emit: &mut dyn FnMut(StackOp)) { // usually an unlock argument — so reduce it first. See `emit_scalar_reduce`. t.to_top("_k"); emit_scalar_reduce(&mut t, "_k", "_kr"); - t.push_bytes("_3n", THREE_CURVE_N_SCRIPT_NUM.to_vec()); - t.raw_block(&["_kr", "_3n"], Some("_k3n"), |e| { + // THREE separate `+n` steps, not a pre-folded `3n`. The peephole's + // fold-chain-add collapses them back to the same `push 3n, ADD`, so the + // shipped bytes are unchanged — but the pre-peephole form now matches the + // reference, and each step can come from the pooled slot. + t.push_const(POOL_GROUP_N, &CURVE_N, "_n"); + t.raw_block(&["_kr", "_n"], Some("_kn"), |e| { + e(StackOp::Opcode("OP_ADD".into())); + }); + t.push_const(POOL_GROUP_N, &CURVE_N, "_n2"); + t.raw_block(&["_kn", "_n2"], Some("_kn2"), |e| { + e(StackOp::Opcode("OP_ADD".into())); + }); + t.push_const(POOL_GROUP_N, &CURVE_N, "_n3"); + t.raw_block(&["_kn2", "_n3"], Some("_kn3"), |e| { e(StackOp::Opcode("OP_ADD".into())); }); t.rename("_k"); @@ -1002,7 +1370,7 @@ pub fn emit_ec_mul(emit: &mut dyn FnMut(StackOp)) { // 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.to_top("_bit"); - t.nm.pop(); // _bit consumed by IF + t.pop_tracked(); // _bit consumed by IF // Only the final step can be handed two equal operands — see // build_jacobian_add_or_double_inline for why, and for what it costs // not to. @@ -1029,37 +1397,331 @@ pub fn emit_ec_mul(emit: &mut dyn FnMut(StackOp)) { // Compose result compose_point(&mut t, "_rx", "_ry", "_result"); + t.release_constant(POOL_GROUP_N); + t.release_constant(POOL_FIELD_P); +} + + +// =========================================================================== +// Fixed-base comb (secp256k1) +// =========================================================================== + +/// Round `i`'s digit and the selected table entry, as `ax`/`ay`/`_flag`. +/// +/// Exactly one equality holds, so `Σ eq_j · T_j` is that entry's coordinate and +/// every term is non-negative and below p — no reduction is needed, and the +/// result is `Reduced` by construction. When the digit is zero every term +/// vanishes and `_flag` is 0, so no add runs. +/// +/// Shared by both comb emitters: the selection is pure scalar bit-twiddling and +/// table indexing, with no curve arithmetic in it at all. +pub(crate) fn comb_emit_select(t: &mut ECTracker, i: usize, w: usize, d: usize) { + let entries = (1usize << w) - 1; + for b in 0..w { + let shift = i + b * d; + let kc = format!("_kc{}", b); + let sh = format!("_sh{}", b); + t.copy_to_top("_k", &kc); + if shift == 0 { + t.rename(&sh); + } else if shift == 1 { + t.raw_block(&[&kc], Some(&sh), |e| { + e(StackOp::Opcode("OP_2DIV".into())); + }); + } else { + let sd = format!("_sd{}", b); + t.push_int(&sd, shift as i128); + t.raw_block(&[&kc, &sd], Some(&sh), |e| { + e(StackOp::Opcode("OP_RSHIFTNUM".into())); + }); + } + let two = format!("_two{}", b); + let bit = format!("_b{}", b); + t.push_int(&two, 2); + t.raw_block(&[&sh, &two], Some(&bit), |e| { + e(StackOp::Opcode("OP_MOD".into())); + }); + t.set_domain(&bit, Dom::Reduced); + } + + t.to_top("_b0"); + t.rename("_idx"); + for b in 1..w { + let bit = format!("_b{}", b); + let wt = format!("_wt{}", b); + let bw = format!("_bw{}", b); + t.to_top(&bit); + t.push_int(&wt, 1i128 << b); + t.raw_block(&[&bit, &wt], Some(&bw), |e| { + e(StackOp::Opcode("OP_MUL".into())); + }); + t.to_top("_idx"); + t.raw_block(&[&bw, "_idx"], Some("_idx"), |e| { + e(StackOp::Opcode("OP_ADD".into())); + }); + } + t.set_domain("_idx", Dom::Reduced); + + for j in 1..=entries { + let ic = format!("_ic{}", j); + let jv = format!("_jv{}", j); + let eq = format!("_eq{}", j); + t.copy_to_top("_idx", &ic); + t.push_int(&jv, j as i128); + t.raw_block(&[&ic, &jv], Some(&eq), |e| { + e(StackOp::Opcode("OP_NUMEQUAL".into())); + }); + t.set_domain(&eq, Dom::Reduced); + } + + for coord in ["x", "y"] { + let acc = if coord == "x" { "ax" } else { "ay" }; + for j in 1..=entries { + let ec = format!("_e{}{}", coord, j); + let tc = format!("_t{}{}", coord, j); + let pr = format!("_pr{}{}", coord, j); + t.copy_to_top(&format!("_eq{}", j), &ec); + t.copy_to_top(&format!("_T{}{}", coord, j), &tc); + t.raw_block(&[&ec, &tc], Some(&pr), |e| { + e(StackOp::Opcode("OP_MUL".into())); + }); + if j == 1 { + t.rename(acc); + } else { + t.to_top(acc); + t.raw_block(&[&pr, acc], Some(acc), |e| { + e(StackOp::Opcode("OP_ADD".into())); + }); + } + } + t.set_domain(acc, Dom::Reduced); + } + + for j in (1..=entries).rev() { + t.to_top(&format!("_eq{}", j)); + t.drop(); + } + + t.to_top("_idx"); + t.raw_block(&["_idx"], Some("_flag"), |e| { + e(StackOp::Opcode("OP_0NOTEQUAL".into())); + }); +} + +/// `k·G` by a Lim-Lee fixed-base comb instead of the 257-round binary ladder. +/// +/// The ladder doubles and conditionally adds once per SCALAR BIT. A comb splits +/// the scalar into `w` blocks of `d` bits and reads one bit from each block per +/// round, so it performs one doubling and one conditional add per COLUMN: the +/// round count falls from `w*d` to `d` at the price of a `2^w - 1` entry table. +/// G is a compile-time constant here, so the table costs nothing to build. +/// +/// This is the secp256k1 twin of `c_emit_comb_mul_gen` in `p256_p384.rs`. The +/// curve arithmetic is NOT shared: secp256k1 has `a = 0`, so `jacobian_double` +/// computes `D = 3X²` where the NIST version computes `3(X-Z²)(X+Z²)`. Only +/// `comb.rs` — the compile-time table and the interval checker — is common, and +/// it takes `a` from the curve record rather than assuming it. +/// +/// SOUNDNESS. The cheap incomplete mixed add cannot represent a pre-add +/// accumulator equal to the addend, its negation, or the point at infinity. +/// `build_jacobian_add_or_double_inline`'s comment justifies using it everywhere +/// but the ladder's LAST step by an interval argument over `c_i mod n`, and +/// insists that argument be re-derived by anything changing the offset or the +/// iteration count. A comb changes both, so it is re-derived: `comb_safe_rounds` +/// evaluates the same argument as executable interval arithmetic over the comb's +/// own geometry, and any round it cannot prove gets the complete add-or-double +/// form instead. Nothing is assumed safe. +/// +/// The other half of that argument is that the accumulator never starts at +/// infinity, which needs the first digit non-zero. `comb_geometry` searches for +/// the scalar offset that guarantees it rather than reusing the ladder's +/// hardcoded `+3n` — which happens to be right for secp256k1 at w=3 and is wrong +/// for P-384. +/// +/// Stack in: [_k]. Stack out: [_result]. Returns false when no geometry exists. +fn emit_comb_mul_gen( + emit: &mut dyn FnMut(StackOp), + w: usize, + opts: Option<&EcCodegenOptions>, +) -> bool { + let curve = &*SECP256K1_COMB_CURVE; + let params = match comb_geometry(w, curve) { + Some(p) => p, + None => return false, + }; + let d = params.d; + let table = comb_table(w, d, curve); + let safe = comb_safe_rounds(¶ms, curve); + let entries = (1usize << w) - 1; + + let mut t = ECTracker::with_opts(&["_k"], emit, opts, None); + t.pool_constant(POOL_FIELD_P, &FIELD_P); + t.pool_constant(POOL_GROUP_N, &CURVE_N); + + // k' = (k mod n) + m*n. The reduce is what confines k to [0, n-1] and so + // what makes the interval argument apply at all; see `emit_scalar_reduce`. + t.to_top("_k"); + emit_scalar_reduce(&mut t, "_k", "_kr"); + t.rename("_k"); + let offset = params.offset_multiple.to_u32().expect("comb offset fits u32"); + for i in 0..offset { + let off = format!("_off{}", i); + t.push_const(POOL_GROUP_N, &CURVE_N, &off); + t.raw_block(&["_k", &off], Some("_k"), |e| { + e(StackOp::Opcode("OP_ADD".into())); + }); + } + t.set_domain("_k", Dom::NonNegative); + + // Table, resident for the whole comb: picking an entry costs 2-3 bytes + // against a 34-byte literal push, and every round reads all of them. + for j in 1..=entries { + let pt = table[j].as_ref().expect("comb table entry is never infinity"); + t.push_big(&format!("_Tx{}", j), &pt.x); + t.push_big(&format!("_Ty{}", j), &pt.y); + t.set_domain(&format!("_Tx{}", j), Dom::Reduced); + t.set_domain(&format!("_Ty{}", j), Dom::Reduced); + } + + // Round d-1 initialises the accumulator. The first digit is non-zero by + // construction (`comb_geometry`), so this is a real point, never infinity. + comb_emit_select(&mut t, d - 1, w, d); + t.to_top("_flag"); + t.drop(); + t.to_top("ax"); + t.rename("jx"); + t.to_top("ay"); + t.rename("jy"); + t.push_int("jz", 1); + t.set_domain("jz", Dom::Reduced); + + for i in (0..=(d - 2)).rev() { + jacobian_double(&mut t); + comb_emit_select(&mut t, i, w, d); + + // `jacobian_add_affine_body` documents its layout as + // [..., ax, ay, jx, jy, jz] and replaces the accumulator IN PLACE at the + // top. The selection leaves ax/ay above jz, so restore the contract + // before the branch — otherwise the add arm would reorder the stack and + // the empty else arm would not, leaving the two arms with different + // layouts at OP_ENDIF. + t.to_top("_flag"); + t.to_alt(); + t.to_top("jx"); + t.to_top("jy"); + t.to_top("jz"); + t.from_alt("_flag"); + + t.pop_tracked(); // consumed by OP_IF + let safe_i = safe[i]; + let add_ops = collect_ops(|add_emit| { + if safe_i { + build_jacobian_add_affine_inline(add_emit, &t); + } else { + build_jacobian_add_or_double_inline(add_emit, &t); + } + }); + (t.e)(StackOp::If { then_ops: add_ops, else_ops: vec![] }); + + // The addend was selected fresh for this round; the add only copied it. + t.to_top("ay"); + t.drop(); + t.to_top("ax"); + t.drop(); + } + + jacobian_to_affine(&mut t, "_rx", "_ry"); + + for j in (1..=entries).rev() { + t.to_top(&format!("_Ty{}", j)); + t.drop(); + t.to_top(&format!("_Tx{}", j)); + t.drop(); + } + t.to_top("_k"); + t.drop(); + + compose_point(&mut t, "_rx", "_ry", "_result"); + t.release_constant(POOL_GROUP_N); + t.release_constant(POOL_FIELD_P); + true +} + +/// Emit the cheapest comb over the candidate window widths. +/// +/// Each candidate is rendered in full and scored with the same byte-cost model +/// the emitter is measured by, and the smallest wins — the window width is not +/// hardcoded. w=1 is the binary ladder and is excluded; beyond w=4 the `2^w` +/// selection logic outgrows the saving. +/// +/// `None` when no candidate could be built, so the caller falls back to the +/// ladder rather than emitting nothing. +fn emit_comb_best(opts: Option<&EcCodegenOptions>) -> Option> { + let mut best: Option> = None; + for w in [2usize, 3, 4] { + let mut ops: Vec = Vec::new(); + let built = { + let mut sink = |op: StackOp| ops.push(op); + emit_comb_mul_gen(&mut sink, w, opts) + }; + if !built { + continue; + } + let better = match &best { + None => true, + Some(b) => estimate_script_bytes(&ops) < estimate_script_bytes(b), + }; + if better { + best = Some(ops); + } + } + best } /// ecMulGen: scalar multiplication G * k. /// Stack in: [scalar] /// Stack out: [result_point] -pub fn emit_ec_mul_gen(emit: &mut dyn FnMut(StackOp)) { +pub fn emit_ec_mul_gen(emit: &mut dyn FnMut(StackOp), opts: Option<&EcCodegenOptions>) { + // G is a compile-time constant, so this is the one secp256k1 call site where + // a fixed-base comb applies. `emit_ec_mul` cannot use it: its base arrives at + // run time. + if opts.map(|o| o.fixed_base_comb).unwrap_or(false) { + if let Some(ops) = emit_comb_best(opts) { + for op in ops { + emit(op); + } + return; + } + } + // Push generator point as 64-byte blob, then delegate to ecMul let mut g_point = Vec::with_capacity(64); g_point.extend_from_slice(&GEN_X_BYTES); g_point.extend_from_slice(&GEN_Y_BYTES); emit(StackOp::Push(PushValue::Bytes(g_point))); emit(StackOp::Swap); // [point, scalar] - emit_ec_mul(emit); + emit_ec_mul(emit, opts); } /// ecNegate: negate a point (x, p - y). /// Stack in: [point] /// Stack out: [negated_point] -pub fn emit_ec_negate(emit: &mut dyn FnMut(StackOp)) { - let mut t = ECTracker::new(&["_pt"], emit); +pub fn emit_ec_negate(emit: &mut dyn FnMut(StackOp), opts: Option<&EcCodegenOptions>) { + let mut t = ECTracker::with_opts(&["_pt"], emit, opts, None); + t.pool_constant(POOL_FIELD_P, &FIELD_P); decompose_point(&mut t, "_pt", "_nx", "_ny"); push_field_p(&mut t, "_fp"); field_sub(&mut t, "_fp", "_ny", "_neg_y"); compose_point(&mut t, "_nx", "_neg_y", "_result"); + t.release_constant(POOL_FIELD_P); } /// ecOnCurve: check if point is on secp256k1 (y^2 = x^3 + 7 mod p). /// Stack in: [point] /// Stack out: [boolean] -pub fn emit_ec_on_curve(emit: &mut dyn FnMut(StackOp)) { - let mut t = ECTracker::new(&["_pt"], emit); +pub fn emit_ec_on_curve(emit: &mut dyn FnMut(StackOp), opts: Option<&EcCodegenOptions>) { + let mut t = ECTracker::with_opts(&["_pt"], emit, opts, None); + t.pool_constant(POOL_FIELD_P, &FIELD_P); decompose_point(&mut t, "_pt", "_x", "_y"); // GAP-301: coordinate canonicity. `decompose_point` BIN2NUMs each coordinate @@ -1107,6 +1769,7 @@ pub fn emit_ec_on_curve(emit: &mut dyn FnMut(StackOp)) { t.raw_block(&["_canon", "_curve_eq"], Some("_result"), |e| { e(StackOp::Opcode("OP_BOOLAND".into())); }); + t.release_constant(POOL_FIELD_P); } /// ecModReduce: ((value % mod) + mod) % mod diff --git a/compilers/rust/src/codegen/mod.rs b/compilers/rust/src/codegen/mod.rs index e6876c12d..2cb2b156b 100644 --- a/compilers/rust/src/codegen/mod.rs +++ b/compilers/rust/src/codegen/mod.rs @@ -8,6 +8,8 @@ pub mod babybear; pub mod blake3; pub mod bn254; +pub mod comb; +pub mod cost_model; pub mod ec; pub mod emit; pub mod p256_p384; diff --git a/compilers/rust/src/codegen/p256_p384.rs b/compilers/rust/src/codegen/p256_p384.rs index e7318b7bf..456b92b11 100644 --- a/compilers/rust/src/codegen/p256_p384.rs +++ b/compilers/rust/src/codegen/p256_p384.rs @@ -152,165 +152,49 @@ fn collect_ops(f: impl FnOnce(&mut dyn FnMut(StackOp))) -> Vec { ops } +// The tracker is SHARED with ec.rs, not copied. +// +// It used to be duplicated here "since it's private there". That was tolerable +// while it was 200 lines of pure stack bookkeeping; it stopped being tolerable +// once it carried a sign lattice whose transfer functions decide which reduction +// shape gets emitted. Two independently-maintained copies of that is two chances +// to prove `Reduced` where only `NonNegative` holds — and the resulting script +// is smaller, passes every local test, and is wrong. +use super::ec::{comb_emit_select, Dom, ECTracker, EcCodegenOptions, POOL_FIELD_P, POOL_GROUP_N}; +use super::comb::{comb_geometry, comb_safe_rounds, comb_table, CombCurve, P256_COMB_CURVE, P384_COMB_CURVE}; +use super::cost_model::estimate_script_bytes; +use num_traits::ToPrimitive; + // =========================================================================== -// ECTracker (same as in ec.rs — duplicated since it's private there) +// Generic curve field arithmetic (parameterized by prime) // =========================================================================== -struct ECTracker<'a> { - nm: Vec, - e: &'a mut dyn FnMut(StackOp), +fn c_push_field_p(t: &mut ECTracker, name: &str, c: &NistCurveParams) { + t.push_const(POOL_FIELD_P, &c.field_p, name); } -#[allow(dead_code)] -impl<'a> ECTracker<'a> { - fn new(init: &[&str], emit: &'a mut dyn FnMut(StackOp)) -> Self { - ECTracker { - nm: init.iter().map(|s| s.to_string()).collect(), - e: emit, - } - } - - fn depth(&self) -> usize { self.nm.len() } - - fn find_depth(&self, name: &str) -> usize { - for i in (0..self.nm.len()).rev() { - if self.nm[i] == name { - return self.nm.len() - 1 - i; - } - } - panic!("ECTracker: '{}' not on stack {:?}", name, self.nm); - } - - fn push_bytes(&mut self, n: &str, v: Vec) { - (self.e)(StackOp::Push(PushValue::Bytes(v))); - self.nm.push(n.to_string()); - } - - fn push_int(&mut self, n: &str, v: i128) { - (self.e)(StackOp::Push(PushValue::Int(BigInt::from(v)))); - self.nm.push(n.to_string()); - } - - fn push_big_int(&mut self, n: &str, v: &BigInt) { - let script_num = bigint_to_script_num(v); - (self.e)(StackOp::Push(PushValue::Bytes(script_num))); - self.nm.push(n.to_string()); - } - - fn dup(&mut self, n: &str) { - (self.e)(StackOp::Dup); - self.nm.push(n.to_string()); - } - - fn drop(&mut self) { - (self.e)(StackOp::Drop); - if !self.nm.is_empty() { self.nm.pop(); } - } - - fn nip(&mut self) { - (self.e)(StackOp::Nip); - let len = self.nm.len(); - if len >= 2 { self.nm.remove(len - 2); } - } - - fn over(&mut self, n: &str) { - (self.e)(StackOp::Over); - self.nm.push(n.to_string()); - } - - fn swap(&mut self) { - (self.e)(StackOp::Swap); - let len = self.nm.len(); - if len >= 2 { self.nm.swap(len - 1, len - 2); } - } - - fn rot(&mut self) { - (self.e)(StackOp::Rot); - let len = self.nm.len(); - if len >= 3 { - let r = self.nm.remove(len - 3); - self.nm.push(r); - } - } - - fn op(&mut self, code: &str) { - (self.e)(StackOp::Opcode(code.into())); - } - - fn roll(&mut self, d: usize) { - if d == 0 { return; } - if d == 1 { self.swap(); return; } - if d == 2 { self.rot(); return; } - (self.e)(StackOp::Push(PushValue::Int(BigInt::from(d as i128)))); - self.nm.push(String::new()); - (self.e)(StackOp::Opcode("OP_ROLL".into())); - self.nm.pop(); - let idx = self.nm.len() - 1 - d; - let r = self.nm.remove(idx); - self.nm.push(r); - } - - fn pick(&mut self, d: usize, n: &str) { - if d == 0 { self.dup(n); return; } - if d == 1 { self.over(n); return; } - (self.e)(StackOp::Push(PushValue::Int(BigInt::from(d as i128)))); - self.nm.push(String::new()); - (self.e)(StackOp::Opcode("OP_PICK".into())); - self.nm.pop(); - self.nm.push(n.to_string()); - } - - fn to_top(&mut self, name: &str) { - let d = self.find_depth(name); - self.roll(d); - } - - fn copy_to_top(&mut self, name: &str, n: &str) { - let d = self.find_depth(name); - self.pick(d, n); - } - - fn to_alt(&mut self) { - self.op("OP_TOALTSTACK"); - if !self.nm.is_empty() { self.nm.pop(); } - } - - fn from_alt(&mut self, n: &str) { - self.op("OP_FROMALTSTACK"); - self.nm.push(n.to_string()); - } - - fn rename(&mut self, n: &str) { - if let Some(last) = self.nm.last_mut() { - *last = n.to_string(); - } - } - - fn raw_block( - &mut self, - consume: &[&str], - produce: Option<&str>, - f: impl FnOnce(&mut dyn FnMut(StackOp)), - ) { - for _ in consume { - if !self.nm.is_empty() { self.nm.pop(); } - } - f(self.e); - if let Some(p) = produce { - self.nm.push(p.to_string()); - } - } +/// `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. +fn c_field_mod_short(t: &mut ECTracker, a_name: &str, result_name: &str, c: &NistCurveParams) { + t.to_top(a_name); + c_push_field_p(t, "_fmods_p", c); + t.raw_block(&[a_name, "_fmods_p"], Some(result_name), |e| { + e(StackOp::Opcode("OP_MOD".into())); + }); + t.set_domain(result_name, Dom::Reduced); } -// =========================================================================== -// Generic curve field arithmetic (parameterized by prime) -// =========================================================================== - -fn c_push_field_p(t: &mut ECTracker, name: &str, c: &NistCurveParams) { - t.push_big_int(name, &*c.field_p); +/// Does the cheap `a - b + p` subtraction pay? Only when p is pooled. +fn c_cheap_sub_pays(t: &ECTracker, c: &NistCurveParams) -> bool { + let cost = t.const_cost(POOL_FIELD_P, &c.field_p); + 2 * cost + 2 < cost + 8 } fn c_field_mod(t: &mut ECTracker, a_name: &str, result_name: &str, c: &NistCurveParams) { + if t.sinking && t.domain_of(a_name).is_non_negative() { + c_field_mod_short(t, a_name, result_name, c); + return; + } t.to_top(a_name); c_push_field_p(t, "_fmod_p", c); t.raw_block(&[a_name, "_fmod_p"], Some(result_name), |e| { @@ -323,36 +207,77 @@ fn c_field_mod(t: &mut ECTracker, a_name: &str, result_name: &str, c: &NistCurve e(StackOp::Swap); e(StackOp::Opcode("OP_MOD".into())); }); + t.set_domain(result_name, Dom::Reduced); } fn c_field_add(t: &mut ECTracker, a_name: &str, b_name: &str, result_name: &str, c: &NistCurveParams) { + // Read the operand facts before raw_block consumes their slots. + let sum_non_neg = + t.domain_of(a_name).is_non_negative() && t.domain_of(b_name).is_non_negative(); t.to_top(a_name); t.to_top(b_name); t.raw_block(&[a_name, b_name], Some("_fadd_sum"), |e| { e(StackOp::Opcode("OP_ADD".into())); }); + if sum_non_neg { + t.set_domain("_fadd_sum", Dom::NonNegative); + } c_field_mod(t, "_fadd_sum", result_name, c); } fn c_field_sub(t: &mut ECTracker, a_name: &str, b_name: &str, result_name: &str, c: &NistCurveParams) { t.to_top(a_name); t.to_top(b_name); + // 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. + let cheap = t.sinking + && t.domain_of(a_name).is_non_negative() + && t.domain_of(b_name) == Dom::Reduced + && c_cheap_sub_pays(t, c); + t.raw_block(&[a_name, b_name], Some("_fsub_diff"), |e| { e(StackOp::Opcode("OP_SUB".into())); }); + + if cheap { + c_push_field_p(t, "_fsub_p", c); + t.raw_block(&["_fsub_diff", "_fsub_p"], Some("_fsub_shift"), |e| { + e(StackOp::Opcode("OP_ADD".into())); + }); + t.set_domain("_fsub_shift", Dom::NonNegative); + c_field_mod_short(t, "_fsub_shift", result_name, c); + return; + } c_field_mod(t, "_fsub_diff", result_name, c); } fn c_field_mul(t: &mut ECTracker, a_name: &str, b_name: &str, result_name: &str, c: &NistCurveParams) { + c_field_mul_signed(t, a_name, b_name, result_name, c, false); +} + +/// `c_field_mul` with an explicit assertion about the product's sign, +/// independent of the operands: a*a >= 0 for any a whatsoever. +fn c_field_mul_signed( + t: &mut ECTracker, a_name: &str, b_name: &str, result_name: &str, c: &NistCurveParams, + product_non_negative: bool, +) { + let non_neg = product_non_negative + || (t.domain_of(a_name).is_non_negative() && t.domain_of(b_name).is_non_negative()); t.to_top(a_name); t.to_top(b_name); t.raw_block(&[a_name, b_name], Some("_fmul_prod"), |e| { e(StackOp::Opcode("OP_MUL".into())); }); + if non_neg { + t.set_domain("_fmul_prod", Dom::NonNegative); + } c_field_mod(t, "_fmul_prod", result_name, c); } fn c_field_mul_const(t: &mut ECTracker, a_name: &str, cv: i128, result_name: &str, c: &NistCurveParams) { + // Every call site passes a small positive cv, so the product keeps a's sign. + let non_neg = cv > 0 && t.domain_of(a_name).is_non_negative(); t.to_top(a_name); t.raw_block(&[a_name], Some("_fmc_prod"), |e| { if cv == 2 { @@ -362,12 +287,15 @@ fn c_field_mul_const(t: &mut ECTracker, a_name: &str, cv: i128, result_name: &st e(StackOp::Opcode("OP_MUL".into())); } }); + if non_neg { + t.set_domain("_fmc_prod", Dom::NonNegative); + } c_field_mod(t, "_fmc_prod", result_name, c); } fn c_field_sqr(t: &mut ECTracker, a_name: &str, result_name: &str, c: &NistCurveParams) { t.copy_to_top(a_name, "_fsqr_copy"); - c_field_mul(t, a_name, "_fsqr_copy", result_name, c); + c_field_mul_signed(t, a_name, "_fsqr_copy", result_name, c, true); } /// c_field_inv computes a^(p-2) mod p via generic square-and-multiply. @@ -399,7 +327,7 @@ fn c_field_inv(t: &mut ECTracker, a_name: &str, result_name: &str, c: &NistCurve // =========================================================================== fn c_push_group_n(t: &mut ECTracker, name: &str, g: &NistGroupParams) { - t.push_big_int(name, &*g.n); + t.push_const(POOL_GROUP_N, &g.n, name); } fn c_group_mod(t: &mut ECTracker, a_name: &str, result_name: &str, g: &NistGroupParams) { @@ -488,8 +416,8 @@ fn c_decompose_point(t: &mut ECTracker, point_name: &str, x_name: &str, y_name: e(StackOp::Push(PushValue::Int(BigInt::from(c.coord_bytes as i128)))); e(StackOp::Opcode("OP_SPLIT".into())); }); - t.nm.push("_dp_xb".to_string()); - t.nm.push("_dp_yb".to_string()); + t.push_tracked("_dp_xb", Dom::Unknown); + t.push_tracked("_dp_yb", Dom::Unknown); // Convert y_bytes (on top) to num t.raw_block(&["_dp_yb"], Some(y_name), |e| { @@ -498,6 +426,10 @@ fn c_decompose_point(t: &mut ECTracker, point_name: &str, x_name: &str, y_name: e(StackOp::Opcode("OP_CAT".into())); e(StackOp::Opcode("OP_BIN2NUM".into())); }); + // A 0x00 sign byte is appended before BIN2NUM, so the coordinate decodes + // UNSIGNED: >= 0, but it may be up to 2^(8*coord_bytes) - 1 and therefore + // >= p. That gap is exactly what the subtraction precondition turns on. + t.set_domain(y_name, Dom::NonNegative); // Convert x_bytes to num t.to_top("_dp_xb"); @@ -507,6 +439,7 @@ fn c_decompose_point(t: &mut ECTracker, point_name: &str, x_name: &str, y_name: e(StackOp::Opcode("OP_CAT".into())); e(StackOp::Opcode("OP_BIN2NUM".into())); }); + t.set_domain(x_name, Dom::NonNegative); // Swap to standard order [xName, yName] t.swap(); @@ -795,7 +728,8 @@ fn c_jacobian_to_affine(t: &mut ECTracker, rx_name: &str, ry_name: &str, c: &Nis fn c_build_jacobian_add_affine_inline(e: &mut dyn FnMut(StackOp), t: &ECTracker, c: &NistCurveParams) { let cloned_nm: Vec = t.nm.clone(); let init_strs: Vec<&str> = cloned_nm.iter().map(|s| s.as_str()).collect(); - let mut it = ECTracker::new(&init_strs, e); + let opts = t.options(); + let mut it = ECTracker::with_opts(&init_strs, e, Some(&opts), Some(&t.dm)); c_jacobian_add_affine_body(&mut it, false, c); } @@ -944,7 +878,8 @@ fn c_build_jacobian_add_or_double_inline( ) { let cloned_nm: Vec = t.nm.clone(); let init_strs: Vec<&str> = cloned_nm.iter().map(|s| s.as_str()).collect(); - let mut it = ECTracker::new(&init_strs, e); + let opts = t.options(); + let mut it = ECTracker::with_opts(&init_strs, e, Some(&opts), Some(&t.dm)); let it = &mut it; // Keep the pre-add accumulator: it is what must be DOUBLED in the @@ -999,19 +934,40 @@ fn c_build_jacobian_add_or_double_inline( // Scalar multiplication (generic for both P-256 and P-384) // =========================================================================== -fn c_emit_mul(emit: &mut dyn FnMut(StackOp), c: &NistCurveParams, g: &NistGroupParams) { - let mut t = ECTracker::new(&["_pt", "_k"], emit); +fn c_emit_mul( + emit: &mut dyn FnMut(StackOp), + c: &NistCurveParams, + g: &NistGroupParams, + opts: Option<&EcCodegenOptions>, +) { + let mut t = ECTracker::with_opts(&["_pt", "_k"], emit, opts, None); + t.pool_constant(POOL_FIELD_P, &c.field_p); + t.pool_constant(POOL_GROUP_N, &g.n); c_decompose_point(&mut t, "_pt", "ax", "ay", c); - // k' = k + 3n (pre-compute 3n to match Go peephole optimizer output) + // k' = k + 3n, as THREE separate `+n` steps. + // + // This tier used to push a pre-folded `3n` "to match Go peephole optimizer + // output". Emitting the three steps is what the reference does, and the + // peephole's fold-chain-add collapses them back to the same `push 3n, ADD` + // — so the shipped bytes are unchanged while the pre-peephole form now + // matches the reference exactly, which is what the cross-tier flag-parity + // fixture compares. // // The "k ∈ [1, n-1]" precondition is one the caller cannot enforce — the // scalar is usually an unlock argument — so reduce it first. t.to_top("_k"); c_emit_scalar_reduce(&mut t, "_k", "_kr", g); - let three_n = &**g.n * 3; - t.push_big_int("_3n", &three_n); - t.raw_block(&["_kr", "_3n"], Some("_kn3"), |e| { + t.push_big("_n", &g.n); + t.raw_block(&["_kr", "_n"], Some("_kn"), |e| { + e(StackOp::Opcode("OP_ADD".into())); + }); + t.push_big("_n2", &g.n); + t.raw_block(&["_kn", "_n2"], Some("_kn2"), |e| { + e(StackOp::Opcode("OP_ADD".into())); + }); + t.push_big("_n3", &g.n); + t.raw_block(&["_kn2", "_n3"], Some("_kn3"), |e| { e(StackOp::Opcode("OP_ADD".into())); }); t.rename("_k"); @@ -1051,7 +1007,7 @@ fn c_emit_mul(emit: &mut dyn FnMut(StackOp), c: &NistCurveParams, g: &NistGroupP // Conditional add t.to_top("_bit"); - t.nm.pop(); // _bit consumed by IF + t.pop_tracked(); // _bit consumed by IF // Only the final step can be handed two equal operands — see // c_build_jacobian_add_or_double_inline for why, and for what it costs // not to. @@ -1076,6 +1032,180 @@ fn c_emit_mul(emit: &mut dyn FnMut(StackOp), c: &NistCurveParams, g: &NistGroupP t.to_top("_k"); t.drop(); c_compose_point(&mut t, "_rx", "_ry", "_result", c); + t.release_constant(POOL_GROUP_N); + t.release_constant(POOL_FIELD_P); +} + + +// =========================================================================== +// Fixed-base comb (the base is a compile-time constant) +// =========================================================================== + +/// `k·G` by a Lim-Lee comb, for a base known at compile time. +/// +/// The binary ladder runs one doubling and one conditional add per scalar BIT. A +/// comb splits the scalar into `w` blocks of `d` bits and runs one doubling and +/// one conditional add per COLUMN, so the round count falls from `w*d` to `d` at +/// the price of a `2^w - 1` entry table — which costs nothing to build here, +/// because `G` is a constant. Measured optimum is w=3: the selection logic grows +/// as `2^w` and overtakes the saving by w=5. +/// +/// SOUNDNESS. The cheap incomplete mixed add cannot represent a pre-add +/// accumulator equal to the addend, its negation, or the point at infinity. +/// `c_build_jacobian_add_or_double_inline`'s comment justifies using it +/// everywhere but the last step of the BINARY ladder by an interval argument +/// over `c_i mod n`, and insists that argument be re-derived by anything +/// changing the offset or the iteration count. A comb changes both, so it is +/// re-derived — as executable interval arithmetic in `comb_safe_rounds`, +/// evaluated here. Rounds it cannot prove get the complete add-or-double form +/// instead; nothing is assumed. For P-256 at w=3 it proves 81 of 86 rounds. +/// +/// The other half of that argument is that the accumulator never starts at +/// infinity, which needs the first digit non-zero. `comb_geometry` searches for +/// the scalar offset that guarantees it rather than reusing the ladder's +/// hardcoded `+3n` — which happens to be right for P-256 at w=3 and WRONG for +/// P-384. +/// +/// Stack in: [_k]. Stack out: [_result]. Returns false when no geometry exists. +fn c_emit_comb_mul_gen( + emit: &mut dyn FnMut(StackOp), + c: &NistCurveParams, + g: &NistGroupParams, + curve: &CombCurve, + w: usize, + opts: Option<&EcCodegenOptions>, +) -> bool { + let params = match comb_geometry(w, curve) { + Some(p) => p, + None => return false, + }; + let d = params.d; + let table = comb_table(w, d, curve); + let safe = comb_safe_rounds(¶ms, curve); + let entries = (1usize << w) - 1; + + let mut t = ECTracker::with_opts(&["_k"], emit, opts, None); + t.pool_constant(POOL_FIELD_P, &c.field_p); + t.pool_constant(POOL_GROUP_N, &g.n); + + // k' = (k mod n) + m*n. The reduce is what confines k to [0, n-1] and so + // what makes the interval argument apply at all; see `c_emit_scalar_reduce`. + t.to_top("_k"); + c_emit_scalar_reduce(&mut t, "_k", "_kr", g); + t.rename("_k"); + let offset = params.offset_multiple.to_u32().expect("comb offset fits u32"); + for i in 0..offset { + let off = format!("_off{}", i); + t.push_const(POOL_GROUP_N, &g.n, &off); + t.raw_block(&["_k", &off], Some("_k"), |e| { + e(StackOp::Opcode("OP_ADD".into())); + }); + } + t.set_domain("_k", Dom::NonNegative); + + // Table, resident for the whole comb: picking an entry costs 2-3 bytes + // against a 34-byte literal push, and every round reads all of them. + for j in 1..=entries { + let pt = table[j].as_ref().expect("comb table entry is never infinity"); + t.push_big(&format!("_Tx{}", j), &pt.x); + t.push_big(&format!("_Ty{}", j), &pt.y); + t.set_domain(&format!("_Tx{}", j), Dom::Reduced); + t.set_domain(&format!("_Ty{}", j), Dom::Reduced); + } + + // Round d-1 initialises the accumulator. The first digit is non-zero by + // construction (`comb_geometry`), so this is a real point, never infinity. + comb_emit_select(&mut t, d - 1, w, d); + t.to_top("_flag"); + t.drop(); + t.to_top("ax"); + t.rename("jx"); + t.to_top("ay"); + t.rename("jy"); + t.push_int("jz", 1); + t.set_domain("jz", Dom::Reduced); + + for i in (0..=(d - 2)).rev() { + c_jacobian_double(&mut t, c); + comb_emit_select(&mut t, i, w, d); + + // `c_jacobian_add_affine_body` documents its layout as + // [..., ax, ay, jx, jy, jz] and replaces the accumulator IN PLACE at the + // top. The selection leaves ax/ay above jz, so restore the contract + // before the branch — otherwise the add arm would reorder the stack and + // the empty else arm would not, leaving the two arms with different + // layouts at OP_ENDIF. + t.to_top("_flag"); + t.to_alt(); + t.to_top("jx"); + t.to_top("jy"); + t.to_top("jz"); + t.from_alt("_flag"); + + t.pop_tracked(); // consumed by OP_IF + let safe_i = safe[i]; + let add_ops = collect_ops(|add_emit| { + if safe_i { + c_build_jacobian_add_affine_inline(add_emit, &t, c); + } else { + c_build_jacobian_add_or_double_inline(add_emit, &t, c); + } + }); + (t.e)(StackOp::If { then_ops: add_ops, else_ops: vec![] }); + + // The addend was selected fresh for this round; the add only copied it. + t.to_top("ay"); + t.drop(); + t.to_top("ax"); + t.drop(); + } + + c_jacobian_to_affine(&mut t, "_rx", "_ry", c); + + for j in (1..=entries).rev() { + t.to_top(&format!("_Ty{}", j)); + t.drop(); + t.to_top(&format!("_Tx{}", j)); + t.drop(); + } + t.to_top("_k"); + t.drop(); + + c_compose_point(&mut t, "_rx", "_ry", "_result", c); + t.release_constant(POOL_GROUP_N); + t.release_constant(POOL_FIELD_P); + true +} + +/// Emit the cheapest comb over the candidate window widths. +/// +/// Each candidate is rendered in full and scored with the same byte-cost model +/// the emitter is measured by, and the smallest wins. +fn c_emit_comb_best( + c: &NistCurveParams, + g: &NistGroupParams, + curve: &CombCurve, + opts: Option<&EcCodegenOptions>, +) -> Option> { + let mut best: Option> = None; + for w in [2usize, 3, 4] { + let mut ops: Vec = Vec::new(); + let built = { + let mut sink = |op: StackOp| ops.push(op); + c_emit_comb_mul_gen(&mut sink, c, g, curve, w, opts) + }; + if !built { + continue; + } + let better = match &best { + None => true, + Some(b) => estimate_script_bytes(&ops) < estimate_script_bytes(b), + }; + if better { + best = Some(ops); + } + } + best } // =========================================================================== @@ -1151,8 +1281,8 @@ fn c_decompress_pub_key( e(StackOp::Push(PushValue::Int(BigInt::from(1)))); e(StackOp::Opcode("OP_SPLIT".into())); }); - t.nm.push("_dk_prefix".to_string()); - t.nm.push("_dk_xbytes".to_string()); + t.push_tracked("_dk_prefix", Dom::Unknown); + t.push_tracked("_dk_xbytes", Dom::Unknown); // 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: @@ -1203,7 +1333,7 @@ fn c_decompress_pub_key( t.copy_to_top("_dk_x_save", "_dk_x_for_3"); c_field_mul_const(t, "_dk_x_for_3", 3, "_dk_3x", c); c_field_sub(t, "_dk_x3", "_dk_3x", "_dk_x3m3x", c); - t.push_big_int("_dk_b", curve_b); + t.push_big("_dk_b", curve_b); c_field_add(t, "_dk_x3m3x", "_dk_b", "_dk_y2", c); // y = (y^2)^sqrtExp mod p. c_field_pow CONSUMES its base, so keep a copy of @@ -1240,7 +1370,7 @@ fn c_decompress_pub_key( // Use OP_IF to select: if match, use y_cand (drop neg_y), else use neg_y (drop y_cand) t.to_top("_dk_match"); - t.nm.pop(); // condition consumed by IF + t.pop_tracked(); // condition consumed by IF let then_ops = vec![StackOp::Drop]; // remove neg_y, leaving y_cand let else_ops = vec![StackOp::Nip]; // remove y_cand, leaving neg_y @@ -1248,7 +1378,7 @@ fn c_decompress_pub_key( // Remove one from tracker and rename the surviving item if let Some(neg_idx) = t.nm.iter().rposition(|n| n == "_dk_neg_y") { - t.nm.remove(neg_idx); + t.remove_slot_at(neg_idx); } if let Some(yc_idx) = t.nm.iter().rposition(|n| n == "_dk_y_cand") { t.nm[yc_idx] = qy_name.to_string(); @@ -1322,8 +1452,8 @@ fn c_emit_length_gate(t: &mut ECTracker, name: &str, want: usize, flag_name: &st e(StackOp::Opcode("OP_SPLIT".into())); e(StackOp::Drop); }); - t.nm.push(flag_name.to_string()); - t.nm.push(name.to_string()); + t.push_tracked(flag_name, Dom::Unknown); + t.push_tracked(name, Dom::Unknown); } /// SEC1 §4.1.4 step 1 / FIPS 186-5 §6.4.2: verify 1 <= r <= n-1 and @@ -1398,8 +1528,17 @@ fn c_emit_verify_ecdsa( sqrt_exp: &BigInt, gx: &BigInt, gy: &BigInt, + comb_curve: &CombCurve, + opts: Option<&EcCodegenOptions>, ) { - let mut t = ECTracker::new(&["_msg", "_sig", "_pk"], emit); + let mut t = ECTracker::with_opts(&["_msg", "_sig", "_pk"], emit, opts, None); + // The verifier does hundreds of reductions OUTSIDE the two ladders — + // decompression's sqrt ladder, `c_group_inv`, `c_affine_add`, the final + // `c_group_mod`. Each ladder pools separately: `c_emit_mul` runs on its own + // tracker that deliberately cannot see this stack, so it cannot reach this + // slot. + t.pool_constant(POOL_FIELD_P, &c.field_p); + t.pool_constant(POOL_GROUP_N, &g.n); // Step 0: length gate. `_sig` and `_pk` are bare ByteString in the builtin // table and the type checker imposes no width, so both arrive attacker-sized. @@ -1432,8 +1571,8 @@ fn c_emit_verify_ecdsa( e(StackOp::Push(PushValue::Int(BigInt::from(cb)))); e(StackOp::Opcode("OP_SPLIT".into())); }); - t.nm.push("_r_bytes".to_string()); - t.nm.push("_s_bytes".to_string()); + t.push_tracked("_r_bytes", Dom::Unknown); + t.push_tracked("_s_bytes", Dom::Unknown); // Convert r_bytes to integer let rev_fn = c.reverse_bytes; @@ -1494,7 +1633,18 @@ fn c_emit_verify_ecdsa( g_point_data[..c.coord_bytes].copy_from_slice(&gx_bytes); g_point_data[c.coord_bytes..].copy_from_slice(&gy_bytes); - t.push_bytes("_G", g_point_data); + // u1*G. G is a compile-time constant, so this half can use a fixed-base + // comb — one doubling and one add per COLUMN instead of per bit. u2*Q below + // cannot: Q arrives in the witness. + let comb_ops = if opts.map(|o| o.fixed_base_comb).unwrap_or(false) { + c_emit_comb_best(c, g, comb_curve, opts) + } else { + None + }; + + if comb_ops.is_none() { + t.push_bytes("_G", g_point_data); + } t.to_top("_u1"); // Stash items on altstack. @@ -1510,14 +1660,24 @@ fn c_emit_verify_ecdsa( t.to_top("_qx"); t.to_alt(); - // Remove _G and _u1 from tracker before c_emit_mul - t.nm.pop(); // _u1 - t.nm.pop(); // _G + // The multiply creates its own ECTracker and cannot see items below its + // operands. Remove them from ours. + t.pop_tracked(); // _u1 + if comb_ops.is_none() { + t.pop_tracked(); // _G + } - c_emit_mul(t.e, c, g); + match &comb_ops { + Some(ops) => { + for op in ops { + (t.e)(op.clone()); + } + } + None => c_emit_mul(t.e, c, g, opts), + } // After mul, one result point is on the stack - t.nm.push("_R1_point".to_string()); + t.push_tracked("_R1_point", Dom::Unknown); // Pop qx/qy/u2 from altstack (LIFO order) t.from_alt("_qx"); @@ -1534,10 +1694,10 @@ fn c_emit_verify_ecdsa( t.to_top("_u2"); // Remove from tracker, emit mul, push result - t.nm.pop(); // _u2 - t.nm.pop(); // _Q_point - c_emit_mul(t.e, c, g); - t.nm.push("_R2_point".to_string()); + t.pop_tracked(); // _u2 + t.pop_tracked(); // _Q_point + c_emit_mul(t.e, c, g, opts); + t.push_tracked("_R2_point", Dom::Unknown); // Restore R1 point t.from_alt("_R1_point"); @@ -1582,6 +1742,8 @@ fn c_emit_verify_ecdsa( t.raw_block(&["_input_ok", "_sig_ok"], Some("_result"), |e| { e(StackOp::Opcode("OP_BOOLAND".into())); }); + t.release_constant(POOL_GROUP_N); + t.release_constant(POOL_FIELD_P); } // =========================================================================== @@ -1589,41 +1751,54 @@ fn c_emit_verify_ecdsa( // =========================================================================== /// p256Add: add two P-256 points. -pub fn emit_p256_add(emit: &mut dyn FnMut(StackOp)) { - let mut t = ECTracker::new(&["_pa", "_pb"], emit); +pub fn emit_p256_add(emit: &mut dyn FnMut(StackOp), opts: Option<&EcCodegenOptions>) { + let mut t = ECTracker::with_opts(&["_pa", "_pb"], emit, opts, None); + t.pool_constant(POOL_FIELD_P, &P256_CURVE.field_p); c_decompose_point(&mut t, "_pa", "px", "py", &P256_CURVE); c_decompose_point(&mut t, "_pb", "qx", "qy", &P256_CURVE); c_affine_add(&mut t, &P256_CURVE); c_compose_point(&mut t, "rx", "ry", "_result", &P256_CURVE); + t.release_constant(POOL_FIELD_P); } /// p256Mul: P-256 scalar multiplication. -pub fn emit_p256_mul(emit: &mut dyn FnMut(StackOp)) { - c_emit_mul(emit, &P256_CURVE, &P256_GROUP); +pub fn emit_p256_mul(emit: &mut dyn FnMut(StackOp), opts: Option<&EcCodegenOptions>) { + c_emit_mul(emit, &P256_CURVE, &P256_GROUP, opts); } /// p256MulGen: P-256 generator multiplication. -pub fn emit_p256_mul_gen(emit: &mut dyn FnMut(StackOp)) { +pub fn emit_p256_mul_gen(emit: &mut dyn FnMut(StackOp), opts: Option<&EcCodegenOptions>) { + if opts.map(|o| o.fixed_base_comb).unwrap_or(false) { + if let Some(ops) = c_emit_comb_best(&P256_CURVE, &P256_GROUP, &P256_COMB_CURVE, opts) { + for op in ops { + emit(op); + } + return; + } + } let mut g_point = Vec::with_capacity(64); g_point.extend_from_slice(&bigint_to_n_bytes(&P256_GX, 32)); g_point.extend_from_slice(&bigint_to_n_bytes(&P256_GY, 32)); emit(StackOp::Push(PushValue::Bytes(g_point))); emit(StackOp::Swap); // [point, scalar] - emit_p256_mul(emit); + emit_p256_mul(emit, opts); } /// p256Negate: negate a P-256 point. -pub fn emit_p256_negate(emit: &mut dyn FnMut(StackOp)) { - let mut t = ECTracker::new(&["_pt"], emit); +pub fn emit_p256_negate(emit: &mut dyn FnMut(StackOp), opts: Option<&EcCodegenOptions>) { + let mut t = ECTracker::with_opts(&["_pt"], emit, opts, None); + t.pool_constant(POOL_FIELD_P, &P256_CURVE.field_p); c_decompose_point(&mut t, "_pt", "_nx", "_ny", &P256_CURVE); c_push_field_p(&mut t, "_fp", &P256_CURVE); c_field_sub(&mut t, "_fp", "_ny", "_neg_y", &P256_CURVE); c_compose_point(&mut t, "_nx", "_neg_y", "_result", &P256_CURVE); + t.release_constant(POOL_FIELD_P); } /// p256OnCurve: check if a P-256 point is on the curve (y^2 = x^3 - 3x + b mod p). -pub fn emit_p256_on_curve(emit: &mut dyn FnMut(StackOp)) { - let mut t = ECTracker::new(&["_pt"], emit); +pub fn emit_p256_on_curve(emit: &mut dyn FnMut(StackOp), opts: Option<&EcCodegenOptions>) { + let mut t = ECTracker::with_opts(&["_pt"], emit, opts, None); + t.pool_constant(POOL_FIELD_P, &P256_CURVE.field_p); c_decompose_point(&mut t, "_pt", "_x", "_y", &P256_CURVE); c_emit_canonicity_guard(&mut t, "_x", "_y", &P256_CURVE); @@ -1637,7 +1812,7 @@ pub fn emit_p256_on_curve(emit: &mut dyn FnMut(StackOp)) { c_field_mul(&mut t, "_x2", "_x_copy", "_x3", &P256_CURVE); c_field_mul_const(&mut t, "_x_copy2", 3, "_3x", &P256_CURVE); c_field_sub(&mut t, "_x3", "_3x", "_x3m3x", &P256_CURVE); - t.push_big_int("_b", &P256_B); + t.push_big("_b", &P256_B); c_field_add(&mut t, "_x3m3x", "_b", "_rhs", &P256_CURVE); // Compare @@ -1653,6 +1828,7 @@ pub fn emit_p256_on_curve(emit: &mut dyn FnMut(StackOp)) { t.raw_block(&["_canon", "_curve_eq"], Some("_result"), |e| { e(StackOp::Opcode("OP_BOOLAND".into())); }); + t.release_constant(POOL_FIELD_P); } /// p256EncodeCompressed: encode a P-256 point as 33-byte compressed pubkey. @@ -1683,8 +1859,8 @@ pub fn emit_p256_encode_compressed(emit: &mut dyn FnMut(StackOp)) { } /// verifyECDSA_P256: verify an ECDSA signature on P-256. -pub fn emit_verify_ecdsa_p256(emit: &mut dyn FnMut(StackOp)) { - c_emit_verify_ecdsa(emit, &P256_CURVE, &P256_GROUP, &P256_B, &P256_SQRT_EXP, &P256_GX, &P256_GY); +pub fn emit_verify_ecdsa_p256(emit: &mut dyn FnMut(StackOp), opts: Option<&EcCodegenOptions>) { + c_emit_verify_ecdsa(emit, &P256_CURVE, &P256_GROUP, &P256_B, &P256_SQRT_EXP, &P256_GX, &P256_GY, &P256_COMB_CURVE, opts); } // =========================================================================== @@ -1692,41 +1868,54 @@ pub fn emit_verify_ecdsa_p256(emit: &mut dyn FnMut(StackOp)) { // =========================================================================== /// p384Add: add two P-384 points. -pub fn emit_p384_add(emit: &mut dyn FnMut(StackOp)) { - let mut t = ECTracker::new(&["_pa", "_pb"], emit); +pub fn emit_p384_add(emit: &mut dyn FnMut(StackOp), opts: Option<&EcCodegenOptions>) { + let mut t = ECTracker::with_opts(&["_pa", "_pb"], emit, opts, None); + t.pool_constant(POOL_FIELD_P, &P384_CURVE.field_p); c_decompose_point(&mut t, "_pa", "px", "py", &P384_CURVE); c_decompose_point(&mut t, "_pb", "qx", "qy", &P384_CURVE); c_affine_add(&mut t, &P384_CURVE); c_compose_point(&mut t, "rx", "ry", "_result", &P384_CURVE); + t.release_constant(POOL_FIELD_P); } /// p384Mul: P-384 scalar multiplication. -pub fn emit_p384_mul(emit: &mut dyn FnMut(StackOp)) { - c_emit_mul(emit, &P384_CURVE, &P384_GROUP); +pub fn emit_p384_mul(emit: &mut dyn FnMut(StackOp), opts: Option<&EcCodegenOptions>) { + c_emit_mul(emit, &P384_CURVE, &P384_GROUP, opts); } /// p384MulGen: P-384 generator multiplication. -pub fn emit_p384_mul_gen(emit: &mut dyn FnMut(StackOp)) { +pub fn emit_p384_mul_gen(emit: &mut dyn FnMut(StackOp), opts: Option<&EcCodegenOptions>) { + if opts.map(|o| o.fixed_base_comb).unwrap_or(false) { + if let Some(ops) = c_emit_comb_best(&P384_CURVE, &P384_GROUP, &P384_COMB_CURVE, opts) { + for op in ops { + emit(op); + } + return; + } + } let mut g_point = Vec::with_capacity(96); g_point.extend_from_slice(&bigint_to_n_bytes(&P384_GX, 48)); g_point.extend_from_slice(&bigint_to_n_bytes(&P384_GY, 48)); emit(StackOp::Push(PushValue::Bytes(g_point))); emit(StackOp::Swap); // [point, scalar] - emit_p384_mul(emit); + emit_p384_mul(emit, opts); } /// p384Negate: negate a P-384 point. -pub fn emit_p384_negate(emit: &mut dyn FnMut(StackOp)) { - let mut t = ECTracker::new(&["_pt"], emit); +pub fn emit_p384_negate(emit: &mut dyn FnMut(StackOp), opts: Option<&EcCodegenOptions>) { + let mut t = ECTracker::with_opts(&["_pt"], emit, opts, None); + t.pool_constant(POOL_FIELD_P, &P384_CURVE.field_p); c_decompose_point(&mut t, "_pt", "_nx", "_ny", &P384_CURVE); c_push_field_p(&mut t, "_fp", &P384_CURVE); c_field_sub(&mut t, "_fp", "_ny", "_neg_y", &P384_CURVE); c_compose_point(&mut t, "_nx", "_neg_y", "_result", &P384_CURVE); + t.release_constant(POOL_FIELD_P); } /// p384OnCurve: check if a P-384 point is on the curve. -pub fn emit_p384_on_curve(emit: &mut dyn FnMut(StackOp)) { - let mut t = ECTracker::new(&["_pt"], emit); +pub fn emit_p384_on_curve(emit: &mut dyn FnMut(StackOp), opts: Option<&EcCodegenOptions>) { + let mut t = ECTracker::with_opts(&["_pt"], emit, opts, None); + t.pool_constant(POOL_FIELD_P, &P384_CURVE.field_p); c_decompose_point(&mut t, "_pt", "_x", "_y", &P384_CURVE); c_emit_canonicity_guard(&mut t, "_x", "_y", &P384_CURVE); @@ -1740,7 +1929,7 @@ pub fn emit_p384_on_curve(emit: &mut dyn FnMut(StackOp)) { c_field_mul(&mut t, "_x2", "_x_copy", "_x3", &P384_CURVE); c_field_mul_const(&mut t, "_x_copy2", 3, "_3x", &P384_CURVE); c_field_sub(&mut t, "_x3", "_3x", "_x3m3x", &P384_CURVE); - t.push_big_int("_b", &P384_B); + t.push_big("_b", &P384_B); c_field_add(&mut t, "_x3m3x", "_b", "_rhs", &P384_CURVE); // Compare @@ -1756,6 +1945,7 @@ pub fn emit_p384_on_curve(emit: &mut dyn FnMut(StackOp)) { t.raw_block(&["_canon", "_curve_eq"], Some("_result"), |e| { e(StackOp::Opcode("OP_BOOLAND".into())); }); + t.release_constant(POOL_FIELD_P); } /// p384EncodeCompressed: encode a P-384 point as 49-byte compressed pubkey. @@ -1786,6 +1976,6 @@ pub fn emit_p384_encode_compressed(emit: &mut dyn FnMut(StackOp)) { } /// verifyECDSA_P384: verify an ECDSA signature on P-384. -pub fn emit_verify_ecdsa_p384(emit: &mut dyn FnMut(StackOp)) { - c_emit_verify_ecdsa(emit, &P384_CURVE, &P384_GROUP, &P384_B, &P384_SQRT_EXP, &P384_GX, &P384_GY); +pub fn emit_verify_ecdsa_p384(emit: &mut dyn FnMut(StackOp), opts: Option<&EcCodegenOptions>) { + c_emit_verify_ecdsa(emit, &P384_CURVE, &P384_GROUP, &P384_B, &P384_SQRT_EXP, &P384_GX, &P384_GY, &P384_COMB_CURVE, opts); } diff --git a/compilers/rust/src/codegen/stack.rs b/compilers/rust/src/codegen/stack.rs index 70a174027..ab15d0db5 100644 --- a/compilers/rust/src/codegen/stack.rs +++ b/compilers/rust/src/codegen/stack.rs @@ -627,6 +627,12 @@ struct LoweringContext { /// deserialized property slot (issue #130). Empty for the common /// no-collision case, so all other contracts are byte-identical. renamed_params: HashMap, + /// EXPERIMENTAL EC size options (constant pool, sign lattice / reduction + /// sinking, fixed-base comb), handed down to the EC and NIST curve + /// emitters. `None` — not an all-false struct — when nothing is enabled, so + /// those emitters take their untouched default path and the emitted bytes + /// are provably identical to the shipping ones. + ec_codegen: Option, } impl LoweringContext { @@ -646,6 +652,7 @@ impl LoweringContext { array_lengths: HashMap::new(), array_elements: HashMap::new(), renamed_params: HashMap::new(), + ec_codegen: None, }; // Issue #130 (stack layer): a method param whose name collides with a @@ -4505,14 +4512,16 @@ impl LoweringContext { self.sm.pop(); } + // Snapshot before `emit` takes a mutable borrow of `self`. + let ec_opts = self.ec_codegen; let emit = &mut |op: StackOp| self.ops.push(op); match func_name { - "ecAdd" => super::ec::emit_ec_add(emit), - "ecMul" => super::ec::emit_ec_mul(emit), - "ecMulGen" => super::ec::emit_ec_mul_gen(emit), - "ecNegate" => super::ec::emit_ec_negate(emit), - "ecOnCurve" => super::ec::emit_ec_on_curve(emit), + "ecAdd" => super::ec::emit_ec_add(emit, ec_opts.as_ref()), + "ecMul" => super::ec::emit_ec_mul(emit, ec_opts.as_ref()), + "ecMulGen" => super::ec::emit_ec_mul_gen(emit, ec_opts.as_ref()), + "ecNegate" => super::ec::emit_ec_negate(emit, ec_opts.as_ref()), + "ecOnCurve" => super::ec::emit_ec_on_curve(emit, ec_opts.as_ref()), "ecModReduce" => super::ec::emit_ec_mod_reduce(emit), "ecEncodeCompressed" => super::ec::emit_ec_encode_compressed(emit), "ecMakePoint" => super::ec::emit_ec_make_point(emit), @@ -4546,20 +4555,22 @@ impl LoweringContext { self.sm.pop(); } + // Snapshot before `emit` takes a mutable borrow of `self`. + let ec_opts = self.ec_codegen; let emit = &mut |op: StackOp| self.ops.push(op); match func_name { - "p256Add" => super::p256_p384::emit_p256_add(emit), - "p256Mul" => super::p256_p384::emit_p256_mul(emit), - "p256MulGen" => super::p256_p384::emit_p256_mul_gen(emit), - "p256Negate" => super::p256_p384::emit_p256_negate(emit), - "p256OnCurve" => super::p256_p384::emit_p256_on_curve(emit), + "p256Add" => super::p256_p384::emit_p256_add(emit, ec_opts.as_ref()), + "p256Mul" => super::p256_p384::emit_p256_mul(emit, ec_opts.as_ref()), + "p256MulGen" => super::p256_p384::emit_p256_mul_gen(emit, ec_opts.as_ref()), + "p256Negate" => super::p256_p384::emit_p256_negate(emit, ec_opts.as_ref()), + "p256OnCurve" => super::p256_p384::emit_p256_on_curve(emit, ec_opts.as_ref()), "p256EncodeCompressed" => super::p256_p384::emit_p256_encode_compressed(emit), - "p384Add" => super::p256_p384::emit_p384_add(emit), - "p384Mul" => super::p256_p384::emit_p384_mul(emit), - "p384MulGen" => super::p256_p384::emit_p384_mul_gen(emit), - "p384Negate" => super::p256_p384::emit_p384_negate(emit), - "p384OnCurve" => super::p256_p384::emit_p384_on_curve(emit), + "p384Add" => super::p256_p384::emit_p384_add(emit, ec_opts.as_ref()), + "p384Mul" => super::p256_p384::emit_p384_mul(emit, ec_opts.as_ref()), + "p384MulGen" => super::p256_p384::emit_p384_mul_gen(emit, ec_opts.as_ref()), + "p384Negate" => super::p256_p384::emit_p384_negate(emit, ec_opts.as_ref()), + "p384OnCurve" => super::p256_p384::emit_p384_on_curve(emit, ec_opts.as_ref()), "p384EncodeCompressed" => super::p256_p384::emit_p384_encode_compressed(emit), _ => panic!("unknown NIST EC builtin: {}", func_name), } @@ -4594,12 +4605,14 @@ impl LoweringContext { self.sm.pop(); // sig self.sm.pop(); // msg + // Snapshot before `emit` takes a mutable borrow of `self`. + let ec_opts = self.ec_codegen; let emit = &mut |op: StackOp| self.ops.push(op); if func_name == "verifyECDSA_P256" { - super::p256_p384::emit_verify_ecdsa_p256(emit); + super::p256_p384::emit_verify_ecdsa_p256(emit, ec_opts.as_ref()); } else { - super::p256_p384::emit_verify_ecdsa_p384(emit); + super::p256_p384::emit_verify_ecdsa_p384(emit, ec_opts.as_ref()); } self.sm.push(binding_name); @@ -5215,15 +5228,29 @@ impl LoweringContext { /// Private methods are inlined at call sites rather than compiled separately. /// The constructor is skipped since it's not emitted to Bitcoin Script. pub fn lower_to_stack(program: &ANFProgram) -> Result, String> { + lower_to_stack_with_ec(program, None) +} + +/// `lower_to_stack` with the EXPERIMENTAL EC script-size options. +/// +/// `None` keeps every EC emitter byte-identical to the shipping output; see +/// `EcCodegenOptions` and docs/experiments/script-size-optimizer-results.md. +pub fn lower_to_stack_with_ec( + program: &ANFProgram, + ec_codegen: Option, +) -> Result, String> { // Convert any panic (stack underflow, unknown operator, type mismatch, or a // deliberate refusal) into an error return instead of crashing the process // — and without the default panic hook printing a crash report first. See // `crate::refusal`. - crate::refusal::catch_refusal("stack lowering", || lower_to_stack_inner(program)) + crate::refusal::catch_refusal("stack lowering", || lower_to_stack_inner(program, ec_codegen)) .and_then(|inner| inner) } -fn lower_to_stack_inner(program: &ANFProgram) -> Result, String> { +fn lower_to_stack_inner( + program: &ANFProgram, + ec_codegen: Option, +) -> Result, String> { // Build map of private methods for inlining let mut private_methods: HashMap = HashMap::new(); for method in &program.methods { @@ -5239,7 +5266,8 @@ fn lower_to_stack_inner(program: &ANFProgram) -> Result, String if method.name == "constructor" || (!method.is_public && method.name != "constructor") { continue; } - let sm = lower_method_with_private_methods(method, &program.properties, &private_methods)?; + let sm = lower_method_with_private_methods( + method, &program.properties, &private_methods, ec_codegen)?; methods.push(sm); } @@ -5384,6 +5412,7 @@ fn lower_method_with_private_methods( method: &ANFMethod, properties: &[ANFProperty], private_methods: &HashMap, + ec_codegen: Option, ) -> Result { let mut param_names: Vec = method.params.iter().map(|p| p.name.clone()).collect(); @@ -5411,6 +5440,7 @@ fn lower_method_with_private_methods( let mut ctx = LoweringContext::new(¶m_names, properties); ctx.private_methods = private_methods.clone(); + ctx.ec_codegen = ec_codegen; // Pass terminal_assert=true for public methods so the last assert leaves // its value on the stack (Bitcoin Script requires a truthy top-of-stack). ctx.lower_bindings(&method.body, method.is_public); diff --git a/compilers/rust/src/lib.rs b/compilers/rust/src/lib.rs index b52cad167..3a524e085 100644 --- a/compilers/rust/src/lib.rs +++ b/compilers/rust/src/lib.rs @@ -32,6 +32,23 @@ pub struct CompileOptions { /// Bake property values into the locking script (replaces OP_0 placeholders). /// Keys are property names; values are JSON values (string, number, bool). pub constructor_args: std::collections::HashMap, + /// EXPERIMENTAL EC script-size optimizations. All default off, and with all + /// off every EC emitter is byte-identical to the shipping output — no + /// golden, size baseline, or cross-tier hex comparison moves. + /// + /// Cross-tier byte parity for the flags THEMSELVES is gated by + /// `conformance/ec-flag-parity/expected.json`, replayed in + /// `tests/ec_flag_parity_tests.rs`. + pub ec_constant_pool: bool, + /// Needs `ec_constant_pool`: the cheap subtraction shape references the + /// field prime twice, so without a pooled slot it does not pay. The + /// emitters compare the two costs, so enabling it alone is safe — just + /// useless. + pub ec_reduction_sinking: bool, + /// Applies only where the base point is a compile-time constant. Runtime-base + /// multiplies keep the binary ladder: the comb's interval soundness argument + /// does not cover an attacker-chosen base. + pub ec_fixed_base_comb: bool, } impl Default for CompileOptions { @@ -42,10 +59,31 @@ impl Default for CompileOptions { validate_only: false, typecheck_only: false, constructor_args: std::collections::HashMap::new(), + ec_constant_pool: false, + ec_reduction_sinking: false, + ec_fixed_base_comb: false, } } } +impl CompileOptions { + /// Options handed to the EC / NIST codegen modules. + /// + /// `None` — not an all-false struct — when nothing is enabled, so those + /// emitters take their untouched default path and the emitted bytes are + /// provably identical to the shipping ones. + fn ec_codegen(&self) -> Option { + if !self.ec_constant_pool && !self.ec_reduction_sinking && !self.ec_fixed_base_comb { + return None; + } + Some(codegen::ec::EcCodegenOptions { + constant_pool: self.ec_constant_pool, + reduction_sinking: self.ec_reduction_sinking, + fixed_base_comb: self.ec_fixed_base_comb, + }) + } +} + /// Validate `constructor_args` shape/keys, bake them into the ANF, then verify /// no referenced readonly property is left unbaked. Returns a list of error /// messages (empty = OK). Mirrors the TypeScript `validateConstructorArgsShape` @@ -340,7 +378,13 @@ pub fn compile_from_source_str_with_options( // Passes 5-6: Backend (stack lowering + emit) // Constant folding already ran above; skip it in compile_from_program. - let backend_opts = CompileOptions { disable_constant_folding: true, ..Default::default() }; + // + // `..opts.clone()`, NOT `..Default::default()`: the backend options must + // carry every field the caller set. With Default here, `--ec-constant-pool` + // (and any future backend flag) reached the frontend and was silently + // dropped before stack lowering — the compile succeeded and produced the + // unoptimized script. + let backend_opts = CompileOptions { disable_constant_folding: true, ..opts.clone() }; compile_from_program_with_options(&anf_program, &backend_opts) } @@ -468,7 +512,8 @@ pub fn compile_from_program_with_options(program: &ir::ANFProgram, opts: &Compil let optimized = frontend::anf_optimize::optimize_ec(program); // Pass 5: Stack lowering - let mut stack_methods = lower_to_stack(&optimized)?; + let mut stack_methods = + codegen::stack::lower_to_stack_with_ec(&optimized, opts.ec_codegen())?; // Peephole optimization — runs on Stack IR before emission. Uses the // source-loc-preserving variant so the artifact's sourceMap survives @@ -637,8 +682,9 @@ pub fn compile_from_source_str_with_result( } // Pass 5: Stack lowering (catch panics) + let ec_codegen = opts.ec_codegen(); let stack_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - lower_to_stack(&anf_program) + codegen::stack::lower_to_stack_with_ec(&anf_program, ec_codegen) })); let mut stack_methods = match stack_result { diff --git a/compilers/rust/src/main.rs b/compilers/rust/src/main.rs index 3487329c9..90dcc813e 100644 --- a/compilers/rust/src/main.rs +++ b/compilers/rust/src/main.rs @@ -80,6 +80,18 @@ struct Args { #[arg(long)] disable_constant_folding: bool, + /// EXPERIMENTAL: pool repeated EC curve constants (changes emitted bytes) + #[arg(long)] + ec_constant_pool: bool, + + /// EXPERIMENTAL: drop provably-dead sign fix-ups from EC modular reductions + #[arg(long)] + ec_reduction_sinking: bool, + + /// EXPERIMENTAL: comb multiplication where the base point is a compile-time constant + #[arg(long)] + ec_fixed_base_comb: bool, + /// After a successful compile, write artifact.sourceMap JSON to this path. #[arg(long)] emit_source_map: Option, @@ -107,6 +119,9 @@ fn main() { let opts = runar_compiler_rust::CompileOptions { disable_constant_folding: args.disable_constant_folding, + ec_constant_pool: args.ec_constant_pool, + ec_reduction_sinking: args.ec_reduction_sinking, + ec_fixed_base_comb: args.ec_fixed_base_comb, ..Default::default() }; diff --git a/compilers/rust/tests/crypto_codegen_tests.rs b/compilers/rust/tests/crypto_codegen_tests.rs index a30a2cedd..6388b851f 100644 --- a/compilers/rust/tests/crypto_codegen_tests.rs +++ b/compilers/rust/tests/crypto_codegen_tests.rs @@ -88,31 +88,31 @@ fn test_emit_blake3_compress_deterministic() { #[test] fn test_emit_p256_add_nontrivial() { - let ops = collect(|s| emit_p256_add(s)); + let ops = collect(|s| emit_p256_add(s, None)); assert!(ops.len() > 10, "p256_add should emit a substantial program, got {}", ops.len()); } #[test] fn test_emit_p256_mul_nontrivial() { - let ops = collect(|s| emit_p256_mul(s)); + let ops = collect(|s| emit_p256_mul(s, None)); assert!(ops.len() > 100, "p256_mul should emit a large program, got {}", ops.len()); } #[test] fn test_emit_p256_mul_gen_nontrivial() { - let ops = collect(|s| emit_p256_mul_gen(s)); + let ops = collect(|s| emit_p256_mul_gen(s, None)); assert!(!ops.is_empty(), "p256_mul_gen should not be empty"); } #[test] fn test_emit_p256_negate_nontrivial() { - let ops = collect(|s| emit_p256_negate(s)); + let ops = collect(|s| emit_p256_negate(s, None)); assert!(!ops.is_empty(), "p256_negate should not be empty"); } #[test] fn test_emit_p256_on_curve_nontrivial() { - let ops = collect(|s| emit_p256_on_curve(s)); + let ops = collect(|s| emit_p256_on_curve(s, None)); assert!(!ops.is_empty(), "p256_on_curve should not be empty"); } @@ -124,7 +124,7 @@ fn test_emit_p256_encode_compressed_nontrivial() { #[test] fn test_emit_verify_ecdsa_p256_nontrivial() { - let ops = collect(|s| emit_verify_ecdsa_p256(s)); + let ops = collect(|s| emit_verify_ecdsa_p256(s, None)); assert!( ops.len() > 100, "verify_ecdsa_p256 should emit a substantial program, got {}", @@ -138,31 +138,31 @@ fn test_emit_verify_ecdsa_p256_nontrivial() { #[test] fn test_emit_p384_add_nontrivial() { - let ops = collect(|s| emit_p384_add(s)); + let ops = collect(|s| emit_p384_add(s, None)); assert!(ops.len() > 10, "p384_add should emit a substantial program, got {}", ops.len()); } #[test] fn test_emit_p384_mul_nontrivial() { - let ops = collect(|s| emit_p384_mul(s)); + let ops = collect(|s| emit_p384_mul(s, None)); assert!(ops.len() > 100, "p384_mul should emit a large program, got {}", ops.len()); } #[test] fn test_emit_p384_mul_gen_nontrivial() { - let ops = collect(|s| emit_p384_mul_gen(s)); + let ops = collect(|s| emit_p384_mul_gen(s, None)); assert!(!ops.is_empty(), "p384_mul_gen should not be empty"); } #[test] fn test_emit_p384_negate_nontrivial() { - let ops = collect(|s| emit_p384_negate(s)); + let ops = collect(|s| emit_p384_negate(s, None)); assert!(!ops.is_empty(), "p384_negate should not be empty"); } #[test] fn test_emit_p384_on_curve_nontrivial() { - let ops = collect(|s| emit_p384_on_curve(s)); + let ops = collect(|s| emit_p384_on_curve(s, None)); assert!(!ops.is_empty(), "p384_on_curve should not be empty"); } @@ -174,7 +174,7 @@ fn test_emit_p384_encode_compressed_nontrivial() { #[test] fn test_emit_verify_ecdsa_p384_nontrivial() { - let ops = collect(|s| emit_verify_ecdsa_p384(s)); + let ops = collect(|s| emit_verify_ecdsa_p384(s, None)); assert!( ops.len() > 100, "verify_ecdsa_p384 should emit a substantial program, got {}", @@ -334,7 +334,7 @@ fn test_blake3_hash_op_count_golden() { #[test] fn test_p256_add_op_count_golden() { - let ops = collect(|s| emit_p256_add(s)); + let ops = collect(|s| emit_p256_add(s, None)); // 6642 -> 6663 (+21 ops / +21 bytes) — the same delta ecAdd and p384Add // take, since all three share the affine-add structure: a second // OP_NUMEQUAL on y, the OP_BOOLAND folding it into `cond`, OP_SUB/OP_NOT @@ -345,29 +345,43 @@ fn test_p256_add_op_count_golden() { #[test] fn test_p256_mul_op_count_golden() { - let ops = collect(|s| emit_p256_mul(s)); + let ops = collect(|s| emit_p256_mul(s, None)); // Rust emits 4 fewer raw StackOps than Python/Java peers; same pattern // as ecMul (see ec_codegen_tests.rs module comment). Final hex is // byte-identical (enforced by the conformance harness). - assert_eq!(count_op_tree(&ops), 140032, "p256_mul op count drift"); + // +4 ops against the previous golden: `c_emit_mul` now emits `k + 3n` as + // three `push n; OP_ADD` steps instead of one pre-folded `push 3n; OP_ADD`. + // The peephole's fold-chain-add collapses them back, so the SCRIPT BYTES are + // unchanged (the conformance hex goldens are untouched) — and the raw op + // tree now agrees with the Go tier, which always emitted the three steps. + // The pre-folded form was this tier's private shortcut, and it is what made + // the cross-tier flag-parity comparison impossible to run here. + assert_eq!(count_op_tree(&ops), 140036, "p256_mul op count drift"); } #[test] fn test_p256_mul_gen_op_count_golden() { - let ops = collect(|s| emit_p256_mul_gen(s)); + let ops = collect(|s| emit_p256_mul_gen(s, None)); // See p256_mul_op_count_golden comment. - assert_eq!(count_op_tree(&ops), 140034, "p256_mul_gen op count drift"); + // +4 ops against the previous golden: `c_emit_mul` now emits `k + 3n` as + // three `push n; OP_ADD` steps instead of one pre-folded `push 3n; OP_ADD`. + // The peephole's fold-chain-add collapses them back, so the SCRIPT BYTES are + // unchanged (the conformance hex goldens are untouched) — and the raw op + // tree now agrees with the Go tier, which always emitted the three steps. + // The pre-folded form was this tier's private shortcut, and it is what made + // the cross-tier flag-parity comparison impossible to run here. + assert_eq!(count_op_tree(&ops), 140038, "p256_mul_gen op count drift"); } #[test] fn test_p256_negate_op_count_golden() { - let ops = collect(|s| emit_p256_negate(s)); + let ops = collect(|s| emit_p256_negate(s, None)); assert_eq!(count_op_tree(&ops), 945, "p256_negate op count drift"); } #[test] fn test_p256_on_curve_op_count_golden() { - let ops = collect(|s| emit_p256_on_curve(s)); + let ops = collect(|s| emit_p256_on_curve(s, None)); assert_eq!(count_op_tree(&ops), 559, "p256_on_curve op count drift"); } @@ -379,7 +393,7 @@ fn test_p256_encode_compressed_op_count_golden() { #[test] fn test_verify_ecdsa_p256_op_count_golden() { - let ops = collect(|s| emit_verify_ecdsa_p256(s)); + let ops = collect(|s| emit_verify_ecdsa_p256(s, None)); // Rust emits 8 fewer raw StackOps than Python/Java peers (a verify // computes two mul/mul_gen invocations × the 4-op divergence). // @@ -419,14 +433,21 @@ fn test_verify_ecdsa_p256_op_count_golden() { // it pays 306 bytes rather than 225 purely on wider constants — +49 in the // length gates (49/96-byte pads) and +32 in the range gate (two 50-byte // pushes of n). Both totals match the TS reference. - assert_eq!(count_op_tree(&ops), 297323, "verify_ecdsa_p256 op count drift"); + // +4 ops against the previous golden: `c_emit_mul` now emits `k + 3n` as + // three `push n; OP_ADD` steps instead of one pre-folded `push 3n; OP_ADD`. + // The peephole's fold-chain-add collapses them back, so the SCRIPT BYTES are + // unchanged (the conformance hex goldens are untouched) — and the raw op + // tree now agrees with the Go tier, which always emitted the three steps. + // The pre-folded form was this tier's private shortcut, and it is what made + // the cross-tier flag-parity comparison impossible to run here. + assert_eq!(count_op_tree(&ops), 297331, "verify_ecdsa_p256 op count drift"); } // -- P-384 ----------------------------------------------------------------- #[test] fn test_p384_add_op_count_golden() { - let ops = collect(|s| emit_p384_add(s)); + let ops = collect(|s| emit_p384_add(s, None)); // 11448 -> 11469 (+21 ops / +21 bytes): same affine-add P == -Q -> O mask // as ecAdd / p256Add, see test_p256_add_op_count_golden. assert_eq!(count_op_tree(&ops), 11469, "p384_add op count drift"); @@ -434,20 +455,34 @@ fn test_p384_add_op_count_golden() { #[test] fn test_p384_mul_op_count_golden() { - let ops = collect(|s| emit_p384_mul(s)); + let ops = collect(|s| emit_p384_mul(s, None)); // See ec_codegen_tests.rs module comment for the 4-op divergence pattern. - assert_eq!(count_op_tree(&ops), 211174, "p384_mul op count drift"); + // +4 ops against the previous golden: `c_emit_mul` now emits `k + 3n` as + // three `push n; OP_ADD` steps instead of one pre-folded `push 3n; OP_ADD`. + // The peephole's fold-chain-add collapses them back, so the SCRIPT BYTES are + // unchanged (the conformance hex goldens are untouched) — and the raw op + // tree now agrees with the Go tier, which always emitted the three steps. + // The pre-folded form was this tier's private shortcut, and it is what made + // the cross-tier flag-parity comparison impossible to run here. + assert_eq!(count_op_tree(&ops), 211178, "p384_mul op count drift"); } #[test] fn test_p384_mul_gen_op_count_golden() { - let ops = collect(|s| emit_p384_mul_gen(s)); + let ops = collect(|s| emit_p384_mul_gen(s, None)); // See p384_mul_op_count_golden comment. - assert_eq!(count_op_tree(&ops), 211176, "p384_mul_gen op count drift"); + // +4 ops against the previous golden: `c_emit_mul` now emits `k + 3n` as + // three `push n; OP_ADD` steps instead of one pre-folded `push 3n; OP_ADD`. + // The peephole's fold-chain-add collapses them back, so the SCRIPT BYTES are + // unchanged (the conformance hex goldens are untouched) — and the raw op + // tree now agrees with the Go tier, which always emitted the three steps. + // The pre-folded form was this tier's private shortcut, and it is what made + // the cross-tier flag-parity comparison impossible to run here. + assert_eq!(count_op_tree(&ops), 211180, "p384_mul_gen op count drift"); } #[test] fn test_p384_negate_op_count_golden() { - let ops = collect(|s| emit_p384_negate(s)); + let ops = collect(|s| emit_p384_negate(s, None)); assert_eq!(count_op_tree(&ops), 1393, "p384_negate op count drift"); } diff --git a/compilers/rust/tests/ec_codegen_tests.rs b/compilers/rust/tests/ec_codegen_tests.rs index 7763d4744..3bdf15452 100644 --- a/compilers/rust/tests/ec_codegen_tests.rs +++ b/compilers/rust/tests/ec_codegen_tests.rs @@ -56,31 +56,31 @@ fn test_emit_reverse_32_nontrivial() { #[test] fn test_emit_ec_add_nontrivial() { - let ops = collect(|s| emit_ec_add(s)); + let ops = collect(|s| emit_ec_add(s, None)); assert!(ops.len() > 10, "ec_add should emit a substantial program, got {}", ops.len()); } #[test] fn test_emit_ec_mul_nontrivial() { - let ops = collect(|s| emit_ec_mul(s)); + let ops = collect(|s| emit_ec_mul(s, None)); assert!(ops.len() > 100, "ec_mul should emit a large program, got {}", ops.len()); } #[test] fn test_emit_ec_mul_gen_nontrivial() { - let ops = collect(|s| emit_ec_mul_gen(s)); + let ops = collect(|s| emit_ec_mul_gen(s, None)); assert!(!ops.is_empty(), "ec_mul_gen should not be empty"); } #[test] fn test_emit_ec_negate_nontrivial() { - let ops = collect(|s| emit_ec_negate(s)); + let ops = collect(|s| emit_ec_negate(s, None)); assert!(!ops.is_empty(), "ec_negate should not be empty"); } #[test] fn test_emit_ec_on_curve_nontrivial() { - let ops = collect(|s| emit_ec_on_curve(s)); + let ops = collect(|s| emit_ec_on_curve(s, None)); assert!(!ops.is_empty(), "ec_on_curve should not be empty"); } @@ -124,15 +124,15 @@ fn sig(ops: &[StackOp]) -> String { #[test] fn test_emit_ec_add_deterministic() { - let a = collect(|s| emit_ec_add(s)); - let b = collect(|s| emit_ec_add(s)); + let a = collect(|s| emit_ec_add(s, None)); + let b = collect(|s| emit_ec_add(s, None)); assert_eq!(sig(&a), sig(&b), "emit_ec_add should be deterministic"); } #[test] fn test_emit_ec_mul_deterministic() { - let a = collect(|s| emit_ec_mul(s)); - let b = collect(|s| emit_ec_mul(s)); + let a = collect(|s| emit_ec_mul(s, None)); + let b = collect(|s| emit_ec_mul(s, None)); assert_eq!(sig(&a), sig(&b), "emit_ec_mul should be deterministic"); } @@ -165,7 +165,7 @@ fn test_emit_reverse_32_deterministic() { #[test] fn test_ec_add_op_count_golden() { - let ops = collect(|s| emit_ec_add(s)); + let ops = collect(|s| emit_ec_add(s, None)); // 8202 -> 8223 (+21 ops / +21 bytes) over the pre-P==-Q-fix shape: the // second OP_NUMEQUAL on y, the OP_BOOLAND that folds it into `cond`, the // OP_SUB/OP_NOT that build `notinf`, the two OP_MULs that mask rx/ry, and @@ -176,7 +176,7 @@ fn test_ec_add_op_count_golden() { #[test] fn test_ec_mul_op_count_golden() { - let ops = collect(|s| emit_ec_mul(s)); + let ops = collect(|s| emit_ec_mul(s, None)); // Rust emits 4 fewer raw StackOps than the Python/TS/Java peer; see the // module-level comment above. Final hex is byte-identical. assert_eq!(count_op_tree(&ops), 130511, "ecMul op count drift"); @@ -184,7 +184,7 @@ fn test_ec_mul_op_count_golden() { #[test] fn test_ec_mul_gen_op_count_golden() { - let ops = collect(|s| emit_ec_mul_gen(s)); + let ops = collect(|s| emit_ec_mul_gen(s, None)); // Rust emits 4 fewer raw StackOps than the Python/TS/Java peer; see the // module-level comment above. Final hex is byte-identical. assert_eq!(count_op_tree(&ops), 130513, "ecMulGen op count drift"); @@ -192,13 +192,13 @@ fn test_ec_mul_gen_op_count_golden() { #[test] fn test_ec_negate_op_count_golden() { - let ops = collect(|s| emit_ec_negate(s)); + let ops = collect(|s| emit_ec_negate(s, None)); assert_eq!(count_op_tree(&ops), 945, "ecNegate op count drift"); } #[test] fn test_ec_on_curve_op_count_golden() { - let ops = collect(|s| emit_ec_on_curve(s)); + let ops = collect(|s| emit_ec_on_curve(s, None)); assert_eq!(count_op_tree(&ops), 533, "ecOnCurve op count drift"); } diff --git a/compilers/rust/tests/ec_flag_parity_tests.rs b/compilers/rust/tests/ec_flag_parity_tests.rs new file mode 100644 index 000000000..a746dbd3b --- /dev/null +++ b/compilers/rust/tests/ec_flag_parity_tests.rs @@ -0,0 +1,154 @@ +//! Cross-tier parity for the EXPERIMENTAL EC size flags. +//! +//! The flags default off, so the ordinary conformance suite — which compiles +//! with defaults — cannot see them at all. Seven tiers could each ship a +//! DIFFERENT `--ec-constant-pool` and the suite would stay green. +//! +//! That matters because the flags are not cosmetic: they change which reduction +//! form is emitted and which addition formula each ladder round uses. A tier +//! that ports the constant pool but not the sign lattice's `Reduced` +//! precondition produces a script that is smaller, passes its own tests, and is +//! wrong on `ecAdd((0,1), (2^256-1,1))`. Byte-identical output against a single +//! reference is the only cheap check that catches that. +//! +//! `conformance/ec-flag-parity/expected.json` is derived from the TypeScript +//! reference compiler and re-derived by its own vitest, so it cannot go stale. + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use runar_compiler_rust::codegen::ec::{ + emit_ec_add, emit_ec_encode_compressed, emit_ec_make_point, emit_ec_mod_reduce, emit_ec_mul, + emit_ec_mul_gen, emit_ec_negate, emit_ec_on_curve, emit_ec_point_x, emit_ec_point_y, + EcCodegenOptions, +}; +use runar_compiler_rust::codegen::emit::emit_method; +use runar_compiler_rust::codegen::p256_p384::{ + emit_p256_add, emit_p256_encode_compressed, emit_p256_mul, emit_p256_mul_gen, + emit_p256_negate, emit_p256_on_curve, emit_p384_add, emit_p384_encode_compressed, + emit_p384_mul, emit_p384_mul_gen, emit_p384_negate, emit_p384_on_curve, + emit_verify_ecdsa_p256, emit_verify_ecdsa_p384, +}; +use runar_compiler_rust::codegen::stack::{StackMethod, StackOp}; +use sha2::{Digest, Sha256}; + +type Emitter = fn(&mut dyn FnMut(StackOp), Option<&EcCodegenOptions>); + +/// Adapt an emitter the flags cannot reach to the options-taking shape. +/// +/// These are deliberately included: a tier that accidentally made +/// `ecModReduce` or `ecPointX` flag-sensitive would be diverging just as badly +/// as one that ignored a flag. +macro_rules! ignore_opts { + ($f:path) => { + (|e: &mut dyn FnMut(StackOp), _: Option<&EcCodegenOptions>| $f(e)) as Emitter + }; +} + +fn emitters() -> BTreeMap<&'static str, Emitter> { + let mut m: BTreeMap<&'static str, Emitter> = BTreeMap::new(); + m.insert("EcAdd", emit_ec_add as Emitter); + m.insert("EcMul", emit_ec_mul as Emitter); + m.insert("EcMulGen", emit_ec_mul_gen as Emitter); + m.insert("EcNegate", emit_ec_negate as Emitter); + m.insert("EcOnCurve", emit_ec_on_curve as Emitter); + m.insert("EcModReduce", ignore_opts!(emit_ec_mod_reduce)); + m.insert("EcEncodeCompressed", ignore_opts!(emit_ec_encode_compressed)); + m.insert("EcMakePoint", ignore_opts!(emit_ec_make_point)); + m.insert("EcPointX", ignore_opts!(emit_ec_point_x)); + m.insert("EcPointY", ignore_opts!(emit_ec_point_y)); + + m.insert("P256Add", emit_p256_add as Emitter); + m.insert("P256Mul", emit_p256_mul as Emitter); + m.insert("P256MulGen", emit_p256_mul_gen as Emitter); + m.insert("P256Negate", emit_p256_negate as Emitter); + m.insert("P256OnCurve", emit_p256_on_curve as Emitter); + m.insert("P256EncodeCompressed", ignore_opts!(emit_p256_encode_compressed)); + m.insert("VerifyECDSA_P256", emit_verify_ecdsa_p256 as Emitter); + + m.insert("P384Add", emit_p384_add as Emitter); + m.insert("P384Mul", emit_p384_mul as Emitter); + m.insert("P384MulGen", emit_p384_mul_gen as Emitter); + m.insert("P384Negate", emit_p384_negate as Emitter); + m.insert("P384OnCurve", emit_p384_on_curve as Emitter); + m.insert("P384EncodeCompressed", ignore_opts!(emit_p384_encode_compressed)); + m.insert("VerifyECDSA_P384", emit_verify_ecdsa_p384 as Emitter); + m +} + +fn fixture() -> serde_json::Value { + // tests -> compilers/rust -> compilers -> repo root + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../conformance/ec-flag-parity/expected.json"); + let raw = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("read {}: {}", path.display(), e)); + serde_json::from_str(&raw).expect("parse ec-flag-parity/expected.json") +} + +fn emit_and_hash(f: Emitter, opts: Option<&EcCodegenOptions>) -> (usize, String) { + let mut ops: Vec = Vec::new(); + f(&mut |op| ops.push(op), opts); + let method = StackMethod { + name: "t".to_string(), + ops, + max_stack_depth: 0, + uses_code_part: false, + source_locs: Vec::new(), + }; + let res = emit_method(&method).expect("emit_method"); + let raw = hex::decode(&res.script_hex).expect("valid hex"); + let mut h = Sha256::new(); + h.update(&raw); + (raw.len(), hex::encode(h.finalize())) +} + +#[test] +fn ec_flag_parity_against_typescript_reference() { + let f = fixture(); + let variants = f["variants"].as_object().expect("variants object"); + + for (name, emitter) in emitters() { + let want = f["emitters"][name] + .as_object() + .unwrap_or_else(|| panic!("{}: no entry in the parity fixture", name)); + for (variant, spec) in variants { + let expect = want + .get(variant) + .unwrap_or_else(|| panic!("{}/{}: no entry in the parity fixture", name, variant)); + let opts = EcCodegenOptions { + constant_pool: spec["constantPool"].as_bool().unwrap_or(false), + reduction_sinking: spec["reductionSinking"].as_bool().unwrap_or(false), + fixed_base_comb: spec["fixedBaseComb"].as_bool().unwrap_or(false), + }; + let (bytes, hash) = emit_and_hash(emitter, Some(&opts)); + let want_bytes = expect["bytes"].as_u64().unwrap() as usize; + let want_hash = expect["sha256"].as_str().unwrap(); + assert_eq!( + (bytes, hash.as_str()), + (want_bytes, want_hash), + "{} under {}: Rust and the TypeScript reference disagree", + name, + variant + ); + } + } +} + +/// `None` options must be byte-identical to the shipping output. This is what +/// keeps the existing goldens, the size baseline and every cross-tier hex +/// comparison from moving while the flags are experimental. +#[test] +fn ec_flags_default_off_is_byte_identical() { + let f = fixture(); + for (name, emitter) in emitters() { + let (_, none_hash) = emit_and_hash(emitter, None); + let (_, off_hash) = emit_and_hash(emitter, Some(&EcCodegenOptions::default())); + assert_eq!(none_hash, off_hash, "{}: None and all-false disagree", name); + assert_eq!( + none_hash, + f["emitters"][name]["off"]["sha256"].as_str().unwrap(), + "{}: default output moved", + name + ); + } +} diff --git a/compilers/zig/src/compiler_api.zig b/compilers/zig/src/compiler_api.zig index 184737614..4b3875972 100644 --- a/compilers/zig/src/compiler_api.zig +++ b/compilers/zig/src/compiler_api.zig @@ -15,6 +15,7 @@ const anf_lower = @import("passes/anf_lower.zig"); const constant_fold = @import("passes/constant_fold.zig"); const ec_optimizer = @import("passes/ec_optimizer.zig"); const stack_lower = @import("passes/stack_lower.zig"); +const ec_emitters = @import("passes/helpers/ec_emitters.zig"); const peephole = @import("passes/peephole.zig"); const emit = @import("codegen/emit.zig"); const input_limits = @import("frontend/input_limits.zig"); @@ -149,6 +150,22 @@ pub fn compileSourceWithOptions( source: []const u8, file_name: []const u8, disable_constant_folding: bool, +) CompileError!CompileResult { + return compileSourceWithEcOptions(allocator, source, file_name, disable_constant_folding, .{}); +} + +/// As `compileSourceWithOptions`, plus the EXPERIMENTAL EC script-size options. +/// +/// An all-false `ec_opts` keeps every EC emitter byte-identical to the shipping +/// output — no golden, size baseline, or cross-tier hex comparison moves. See +/// `ec_emitters.EcCodegenOptions` and +/// docs/experiments/script-size-optimizer-results.md. +pub fn compileSourceWithEcOptions( + allocator: std.mem.Allocator, + source: []const u8, + file_name: []const u8, + disable_constant_folding: bool, + ec_opts: ec_emitters.EcCodegenOptions, ) CompileError!CompileResult { // Pass 0: DoS-bound size guard. Reject oversized source BEFORE any // tokenizer / arena allocator touches the input. BUG-008 follow-up. @@ -194,7 +211,7 @@ pub fn compileSourceWithOptions( program = ec_optimizer.optimize(work, program) catch return error.ANFLowerFailed; // Pass 5: Stack Lower + Peephole - const stack_program = stack_lower.lower(work, program) catch return error.StackLowerFailed; + const stack_program = stack_lower.lowerOpts(work, program, ec_opts) catch return error.StackLowerFailed; const optimized_methods = peephole.optimize(work, stack_program.methods) catch return error.StackLowerFailed; const optimized_stack_program = types.StackProgram{ .methods = optimized_methods, diff --git a/compilers/zig/src/main.zig b/compilers/zig/src/main.zig index f02bae19a..c10eb7c26 100644 --- a/compilers/zig/src/main.zig +++ b/compilers/zig/src/main.zig @@ -17,6 +17,7 @@ const anf_lower = @import("passes/anf_lower.zig"); const constant_fold = @import("passes/constant_fold.zig"); const ec_optimizer = @import("passes/ec_optimizer.zig"); const stack_lower = @import("passes/stack_lower.zig"); +const ec_emitters = @import("passes/helpers/ec_emitters.zig"); const peephole = @import("passes/peephole.zig"); const emit = @import("codegen/emit.zig"); const input_limits = @import("frontend/input_limits.zig"); @@ -26,6 +27,21 @@ const CompileOptions = struct { emit_ir: bool = false, hex_only: bool = false, disable_constant_folding: bool = false, + /// EXPERIMENTAL EC script-size optimizations. All default off, and with all + /// off every EC emitter is byte-identical to the shipping output — no + /// golden, size baseline, or cross-tier hex comparison moves. + /// + /// Cross-tier byte parity for the flags THEMSELVES is gated by + /// conformance/ec-flag-parity/expected.json. + ec_constant_pool: bool = false, + /// Needs `ec_constant_pool`: the cheap subtraction shape references the + /// field prime twice, so without a pooled slot it does not pay. The emitters + /// compare the two costs, so enabling it alone is safe — just useless. + ec_reduction_sinking: bool = false, + /// Applies only where the base point is a compile-time constant. Runtime-base + /// multiplies keep the binary ladder: the comb's interval soundness argument + /// does not cover an attacker-chosen base. + ec_fixed_base_comb: bool = false, parse_only: bool = false, emit_source_map_path: ?[]const u8 = null, /// `--emit-ir-to `: write the SAME bytes `--emit-ir` would print to @@ -36,6 +52,18 @@ const CompileOptions = struct { emit_ir_to_path: ?[]const u8 = null, }; +/// Options handed to the EC / NIST codegen modules. +/// +/// All-false — the default — makes those emitters take their untouched path, so +/// the emitted bytes are provably identical to the shipping ones. +fn ecCodegenOptions(opts: CompileOptions) ec_emitters.EcCodegenOptions { + return .{ + .constant_pool = opts.ec_constant_pool, + .reduction_sinking = opts.ec_reduction_sinking, + .fixed_base_comb = opts.ec_fixed_base_comb, + }; +} + const ParseOptionsError = error{ UnknownFlag, UnsupportedFlag, @@ -60,6 +88,18 @@ fn parseCompileOptions(args: []const []const u8, allow_disable_constant_folding: opts.disable_constant_folding = true; continue; } + if (std.mem.eql(u8, arg, "--ec-constant-pool")) { + opts.ec_constant_pool = true; + continue; + } + if (std.mem.eql(u8, arg, "--ec-reduction-sinking")) { + opts.ec_reduction_sinking = true; + continue; + } + if (std.mem.eql(u8, arg, "--ec-fixed-base-comb")) { + opts.ec_fixed_base_comb = true; + continue; + } if (std.mem.eql(u8, arg, "--parse-only")) { opts.parse_only = true; continue; @@ -221,6 +261,9 @@ fn printUsage() void { \\ --emit-ir Output canonical ANF IR JSON (stop after pass 4) \\ --hex Output script hex only (no artifact JSON) \\ --disable-constant-folding Skip constant folding pass + \\ --ec-constant-pool EXPERIMENTAL: pool repeated EC curve constants + \\ --ec-reduction-sinking EXPERIMENTAL: drop provably-dead EC sign fix-ups + \\ --ec-fixed-base-comb EXPERIMENTAL: comb mul for compile-time base points \\ \\Formats: .runar.zig, .runar.ts, .runar.sol, .runar.move, .runar.go, .runar.rs, .runar.py, .runar.rb, .runar.java, .json \\ @@ -257,7 +300,7 @@ fn compileFromIR(allocator: std.mem.Allocator, io: std.Io, path: []const u8, opt return; } - const stack_program = try stack_lower.lower(allocator, program); + const stack_program = try stack_lower.lowerOpts(allocator, program, ecCodegenOptions(opts)); defer stack_program.deinit(allocator); const optimized_methods = try peephole.optimize(allocator, stack_program.methods); const optimized_stack_program = types.StackProgram{ @@ -475,7 +518,7 @@ fn compileFromSource(allocator: std.mem.Allocator, io: std.Io, path: []const u8, } // Pass 5: Stack Lower - const stack_program = try stack_lower.lower(work_allocator, program); + const stack_program = try stack_lower.lowerOpts(work_allocator, program, ecCodegenOptions(opts)); defer stack_program.deinit(work_allocator); const optimized_methods = try peephole.optimize(work_allocator, stack_program.methods); const optimized_stack_program = types.StackProgram{ diff --git a/compilers/zig/src/passes/helpers/comb.zig b/compilers/zig/src/passes/helpers/comb.zig new file mode 100644 index 000000000..26a07e9c3 --- /dev/null +++ b/compilers/zig/src/passes/helpers/comb.zig @@ -0,0 +1,326 @@ +//! Fixed-base comb: compile-time table, and the soundness check that decides +//! where the cheap incomplete addition may be used. +//! +//! Port of `packages/runar-compiler/src/passes/comb.ts`. The binary ladders in +//! `ec_emitters.zig` / `nist_ec_emitters.zig` use the cheap mixed add at every +//! step but the last, justified by an interval argument over `c_i mod n`. That +//! comment is emphatic that the argument must be RE-DERIVED, not assumed, by +//! anything which changes the offset, the iteration count, or the reduce — and a +//! comb changes all three. `combSafeRounds` below is that re-derivation, written +//! as executable interval arithmetic rather than prose, so a round only gets the +//! cheap add when the exception is proved unreachable. Rounds it cannot prove +//! fall back to the complete add-or-double form. +//! +//! Nothing here emits Script. It is pure integer arithmetic, run once per +//! compilation, and unit-tested against published curve vectors. +//! +//! Arithmetic uses a fixed-width signed `Big` rather than `std.math.big.int`: +//! the widest value that occurs is a product of two P-384 field elements +//! (768 bits) and the widest bound is `2^(w*d)` with `w*d = 387`, so a single +//! wide type covers every curve with no allocator and no aliasing hazards. + +const std = @import("std"); + +/// Wide enough for a product of two P-384 field elements (768 bits) with slack. +pub const Big = i1024; + +/// An affine point. `null` is the point at infinity. +pub const Point = struct { + x: Big, + y: Big, +}; + +/// A short-Weierstrass curve, for the compile-time table. +pub const Curve = struct { + /// Field prime. + p: Big, + /// Curve coefficient a: -3 on the NIST curves, 0 on secp256k1. + a: Big, + /// Curve coefficient b. + b: Big, + /// Group order. + n: Big, + /// Base point. + g: Point, +}; + +/// Comb geometry for one window width, chosen so the top digit is never zero. +/// +/// The binary ladder hardcodes `k + 3n`, which puts the scalar's top bit at a +/// fixed position and so keeps the accumulator off the point at infinity. A comb +/// needs the same guarantee, but its first round reads bit `w*d - 1`, so the +/// offset has to be chosen against `w*d` rather than assumed. `offset_multiple` +/// is the smallest `m` for which every `k + m*n` has bit `w*d - 1` set: +/// +/// m*n >= 2^(w*d - 1) and (m+1)*n - 1 < 2^(w*d) +/// +/// `m*n == 0 (mod n)` so the result is unchanged. For P-256 at w=3 the search +/// returns m=3, d=86 — i.e. exactly the `+3n` the binary ladder already uses. +/// For P-384 at w=3 it returns m=5, d=129; assuming `+3n` there would have left +/// the top digit free to be zero. +pub const Params = struct { + w: usize, + /// Rounds, and the block width. Digit `i` reads bits `i, i+d, ..., i+(w-1)d`. + d: usize, + offset_multiple: usize, + /// Inclusive scalar domain after the offset. + lo: Big, + hi: Big, +}; + +fn hex(comptime s: []const u8) Big { + var v: Big = 0; + for (s) |ch| { + const digit: Big = switch (ch) { + '0'...'9' => ch - '0', + 'a'...'f' => ch - 'a' + 10, + 'A'...'F' => ch - 'A' + 10, + else => unreachable, + }; + v = v * 16 + digit; + } + return v; +} + +pub const P256_COMB_CURVE = Curve{ + .p = hex("ffffffff00000001000000000000000000000000ffffffffffffffffffffffff"), + .a = -3, + .b = hex("5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b"), + .n = hex("ffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551"), + .g = .{ + .x = hex("6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296"), + .y = hex("4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5"), + }, +}; + +pub const P384_COMB_CURVE = Curve{ + .p = hex("fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffeffffffff0000000000000000ffffffff"), + .a = -3, + .b = hex("b3312fa7e23ee7e4988e056be3f82d19181d9c6efe8141120314088f5013875ac656398d8a2ed19d2a85c8edd3ec2aef"), + .n = hex("ffffffffffffffffffffffffffffffffffffffffffffffffc7634d81f4372ddf581a0db248b0a77aecec196accc52973"), + .g = .{ + .x = hex("aa87ca22be8b05378eb1c71ef320ad746e1d3b628ba79b9859f741e082542a385502f25dbf55296c3a545e3872760ab7"), + .y = hex("3617de4a96262c6f5d9e98bf9292dc29f8f41dbd289a147ce9da3113b5f0b8c00a60b1ce1d7e819d7a431d7c90ea0e5f"), + }, +}; + +/// secp256k1. NOT built from the NIST template: it is `y^2 = x^3 + 7`, so +/// `a = 0`. Getting `a` wrong here does not produce an obviously broken table — +/// it produces a table of points on a DIFFERENT curve, which that other curve's +/// on-curve check would happily accept. Hence the published 2G vectors pinned in +/// the tests. +pub const SECP256K1_COMB_CURVE = Curve{ + .p = hex("fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f"), + .a = 0, + .b = 7, + .n = hex("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"), + .g = .{ + .x = hex("79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"), + .y = hex("483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8"), + }, +}; + +fn bitLength(v: Big) usize { + if (v == 0) return 0; + var n: usize = 0; + var x = v; + while (x != 0) : (x >>= 1) n += 1; + return n; +} + +/// Geometry for window width `w`, or null if no offset in the search range puts +/// a guaranteed set bit at the top of the first digit. Returning null rather +/// than guessing keeps the caller from silently combing a scalar whose leading +/// digit can vanish. +pub fn combGeometry(w: usize, c: Curve) ?Params { + const base = (bitLength(c.n) + w - 1) / w; + var d = base; + while (d <= base + 2) : (d += 1) { + const bits: u10 = @intCast(w * d); + const top: Big = @as(Big, 1) << (bits - 1); + const cap: Big = @as(Big, 1) << bits; + var m: usize = 1; + while (m <= 16) : (m += 1) { + const lo = @as(Big, @intCast(m)) * c.n; + const hi = (@as(Big, @intCast(m)) + 1) * c.n - 1; + if (lo >= top and hi < cap) { + return Params{ .w = w, .d = d, .offset_multiple = m, .lo = lo, .hi = hi }; + } + } + } + return null; +} + +// --------------------------------------------------------------------------- +// Affine arithmetic (compile time only) +// --------------------------------------------------------------------------- + +fn mod(v: Big, m: Big) Big { + return @mod(v, m); +} + +/// Modular inverse by extended Euclid. `v` is never 0 on the paths below. +fn modInverse(v: Big, m: Big) Big { + var old_r: Big = mod(v, m); + var r: Big = m; + var old_s: Big = 1; + var s: Big = 0; + while (r != 0) { + const q = @divTrunc(old_r, r); + const nr = old_r - q * r; + old_r = r; + r = nr; + const ns = old_s - q * s; + old_s = s; + s = ns; + } + return mod(old_s, m); +} + +/// Affine addition. `null` is the point at infinity. +pub fn combAffineAdd(p: ?Point, q: ?Point, c: Curve) ?Point { + const pp = p orelse return q; + const qq = q orelse return p; + + if (pp.x == qq.x) { + if (mod(pp.y + qq.y, c.p) == 0) return null; // P == -Q + // Tangent. + const num = mod(3 * mod(pp.x * pp.x, c.p) + c.a, c.p); + const den = modInverse(mod(2 * pp.y, c.p), c.p); + const lam = mod(mod(num * den, c.p), c.p); + const x = mod(mod(lam * lam, c.p) - 2 * pp.x, c.p); + return Point{ .x = x, .y = mod(mod(lam * mod(pp.x - x, c.p), c.p) - pp.y, c.p) }; + } + const den = modInverse(mod(qq.x - pp.x, c.p), c.p); + const lam = mod(mod(mod(qq.y - pp.y, c.p) * den, c.p), c.p); + const x = mod(mod(lam * lam, c.p) - pp.x - qq.x, c.p); + return Point{ .x = x, .y = mod(mod(lam * mod(pp.x - x, c.p), c.p) - pp.y, c.p) }; +} + +/// Compile-time double-and-add. `null` is the point at infinity. +pub fn combScalarMul(k: Big, p: Point, c: Curve) ?Point { + var r: ?Point = null; + var base: ?Point = p; + var e = mod(k, c.n); + while (e > 0) { + if (@mod(e, 2) == 1) r = combAffineAdd(r, base, c); + base = combAffineAdd(base, base, c); + e >>= 1; + } + return r; +} + +// --------------------------------------------------------------------------- +// Comb table +// --------------------------------------------------------------------------- + +/// The multiple of G that table entry `j` represents. +/// +/// Comb round `i` consumes bits `{i, i+d, i+2d, ...}` of the scalar — one from +/// each block — so entry `j` stands for the sum of `2^(t*d)` over the set bits +/// `t` of `j`. +pub fn combValue(j: usize, d: usize) Big { + var v: Big = 0; + var t: usize = 0; + while ((j >> @intCast(t)) != 0) : (t += 1) { + if (((j >> @intCast(t)) & 1) == 1) { + v += @as(Big, 1) << @intCast(t * d); + } + } + return v; +} + +/// The maximum window width the table buffer below can hold. +pub const MAX_W: usize = 4; + +/// `out[j] = combValue(j)*G`. Index 0 is the point at infinity and is never +/// added, so it is left null. +pub fn combTable(w: usize, d: usize, c: Curve, out: *[1 << MAX_W]?Point) void { + var j: usize = 0; + while (j < (@as(usize, 1) << @intCast(w))) : (j += 1) { + out[j] = if (j == 0) null else combScalarMul(combValue(j, d), c.g, c); + } +} + +// --------------------------------------------------------------------------- +// Soundness: where may the cheap incomplete addition be used? +// --------------------------------------------------------------------------- + +/// Bounds on the comb accumulator's multiplier before round `i`'s doubling. +/// +/// After processing rounds `d-1 .. i`, the accumulator is `c_i*G` with +/// +/// c_i = sum_m 2^(m*d) * floor(K_m / 2^i) +/// +/// where `K_m` is the m-th `d`-bit block of the expanded scalar. Each floor +/// discards less than one unit of its block, so +/// +/// k/2^i - sum_m 2^(m*d) < c_i <= k/2^i +/// +/// and with `k` confined to `[lo, hi]` that gives a contiguous interval. The +/// slack term is bounded by `2^(w*d)/(2^d - 1)`, far below `n`, which is why the +/// interval stays narrower than the group order for all but the last few rounds +/// — exactly the property the binary ladder's argument relies on. +fn accumulatorInterval(i: usize, params: Params) struct { lo: Big, hi: Big } { + var slack: Big = 0; + var m: usize = 0; + while (m < params.w) : (m += 1) slack += @as(Big, 1) << @intCast(m * params.d); + const hi = params.hi >> @intCast(i); + const lo = (params.lo >> @intCast(i)) - slack; + return .{ .lo = if (lo < 0) 0 else lo, .hi = hi }; +} + +/// Does `[lo, hi]` contain an integer congruent to `target` modulo `n`? +fn intervalHitsResidue(lo: Big, hi: Big, target: Big, n: Big) bool { + if (hi < lo) return false; + if (hi - lo + 1 >= n) return true; // wraps a full residue class + const t = mod(target, n); + // Smallest value >= lo that is congruent to t (mod n). + const first = lo + mod(t - lo, n); + return first <= hi; +} + +/// The maximum round count the verdict buffer below can hold (P-384 at w=2). +pub const MAX_D: usize = 200; + +/// Per-round verdict: may round `i` use the cheap incomplete mixed add? +/// +/// The exception the cheap formula cannot represent is a pre-add accumulator +/// equal to the addend, its negation, or the point at infinity. After round +/// `i`'s doubling the accumulator is `2*c_{i+1}*G`, and the addend is +/// `combValue(j)*G` for whichever digit `j` the scalar selects — so the round is +/// safe exactly when, for every `j`, +/// +/// 2*c_{i+1} != 0, +combValue(j), -combValue(j) (mod n) +/// +/// over the whole interval of `c_{i+1}`. Both `G` and every table entry are +/// compile-time constants and the curves have cofactor 1, so `ord(G) = n` and +/// this is decidable here. Anything the checker cannot prove gets the complete +/// add-or-double form instead; `true` is never assumed. +/// +/// Index `d-1` is `false` by construction: that round initialises the +/// accumulator from the table and performs no addition at all. +pub fn combSafeRounds(params: Params, c: Curve, out: *[MAX_D]bool) void { + var values: [1 << MAX_W]Big = undefined; + const count = (@as(usize, 1) << @intCast(params.w)) - 1; + var j: usize = 1; + while (j <= count) : (j += 1) values[j - 1] = combValue(j, params.d); + + var i: usize = 0; + while (i < params.d) : (i += 1) { + if (i == params.d - 1) { + out[i] = false; + continue; + } + const iv = accumulatorInterval(i + 1, params); + const d_lo = 2 * iv.lo; + const d_hi = 2 * iv.hi; + var ok = !intervalHitsResidue(d_lo, d_hi, 0, c.n); + var k: usize = 0; + while (k < count and ok) : (k += 1) { + ok = !intervalHitsResidue(d_lo, d_hi, values[k], c.n) and + !intervalHitsResidue(d_lo, d_hi, -values[k], c.n); + } + out[i] = ok; + } +} diff --git a/compilers/zig/src/passes/helpers/ec_cost_model.zig b/compilers/zig/src/passes/helpers/ec_cost_model.zig new file mode 100644 index 000000000..c743c5cf2 --- /dev/null +++ b/compilers/zig/src/passes/helpers/ec_cost_model.zig @@ -0,0 +1,28 @@ +//! Script-byte cost model for Stack IR. +//! +//! Port of `packages/runar-compiler/src/metrics/cost-model.ts`. Optimizer passes +//! need to compare two candidate lowerings by the metric that actually matters — +//! serialized locking-script bytes — before either one is emitted. `OP_DUP` and +//! a 33-byte constant push are one instruction each and 1 vs 34 bytes; an +//! instruction count cannot tell them apart. +//! +//! The implementation lives in `ec_emitters.zig` because the tracker's constant +//! pool needs it to price a call site before emitting anything, and a separate +//! copy here would be a second implementation free to drift from the one the +//! pool actually consults. This module is the named entry point the other tiers +//! have, re-exporting the single implementation. +//! +//! It is deliberately NOT an approximation: pushes route through the same +//! `opcodes.zig` encoders `emit.zig` uses, so +//! +//! estimateScriptBytes(ops) == emitted hex length / 2 +//! +//! holds exactly. `ec_cost_model_test.zig` asserts that over every EC emitter. + +const ec = @import("ec_emitters.zig"); + +pub const scriptNumberCost = ec.scriptNumberCost; +pub const pushDataCost = ec.pushDataCost; +pub const sizeOfPushValue = ec.sizeOfPushValue; +pub const sizeOfStackOp = ec.sizeOfStackOp; +pub const estimateScriptBytes = ec.estimateScriptBytes; diff --git a/compilers/zig/src/passes/helpers/ec_emitters.zig b/compilers/zig/src/passes/helpers/ec_emitters.zig index 655fedad8..ce2886fb2 100644 --- a/compilers/zig/src/passes/helpers/ec_emitters.zig +++ b/compilers/zig/src/passes/helpers/ec_emitters.zig @@ -1,5 +1,7 @@ const std = @import("std"); const registry = @import("crypto_builtins.zig"); +const opcodes = @import("../../codegen/opcodes.zig"); +const comb = @import("comb.zig"); const Allocator = std.mem.Allocator; @@ -69,6 +71,83 @@ const gen_y_be = [_]u8{ pub const EcEmitterError = anyerror; +/// Codegen options shared by every EC / NIST-curve emitter. +/// +/// Off by default: with an all-false value each emitter is byte-identical to +/// what the seven tiers ship today, so no golden, size baseline, or cross-tier +/// parity gate can move. +pub const EcCodegenOptions = struct { + /// Park large repeated constants (the field prime, the group order) in a + /// stack slot and copy them with `OP_PICK` instead of re-pushing the + /// literal. + /// + /// `fieldMod` pushes the 256-bit prime at every modular reduction — 34 bytes + /// a time, 20,025 times in `p256-wallet` (71 % of that fixture). A pick from + /// a slot a dozen deep costs 2. + constant_pool: bool = false, + + /// 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. + /// + /// Which reductions qualify is decided by the sign lattice below — never + /// assumed. Only useful alongside `constant_pool`: the cheap subtraction + /// references the prime twice, so without a pooled slot it does not pay (and + /// the emitters compare the two costs, so it is never taken when it does + /// not). + reduction_sinking: bool = false, + + /// Use a fixed-base comb instead of the binary ladder wherever the base + /// point is a compile-time constant. The window width is not fixed here: the + /// emitter renders each candidate and keeps whichever the byte-cost model + /// scores smallest. + fixed_base_comb: bool = false, + + pub fn any(self: EcCodegenOptions) bool { + return self.constant_pool or self.reduction_sinking or self.fixed_base_comb; + } +}; + +/// What is known about a tracked value's sign and range. +/// +/// `.reduced` implies `.non_negative`; 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 `.non_negative` 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 `.non_negative`; 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))`. +pub const Dom = enum(u2) { + /// Nothing known. May be negative. + unknown = 0, + /// Provably >= 0. May be >= p. + non_negative = 1, + /// Provably in [0, p). + reduced = 2, + + /// True when this proves the value is >= 0. + pub fn isNonNegative(self: Dom) bool { + return self != .unknown; + } +}; + +/// Stack slot names reserved for pooled constants. +pub const POOL_FIELD_P = "_pool$p"; + +/// Length of the field prime's unsigned script-number encoding. +/// +/// secp256k1's p is 32 big-endian bytes whose most significant byte is 0xff, so +/// the little-endian sign-magnitude form needs a trailing 0x00 sign byte: 33. +/// Used by `cheapSubPays` to price the pooled constant without allocating. +pub const FIELD_P_SCRIPT_NUM_LEN: usize = 33; +pub const POOL_GROUP_N = "_pool$n"; + + pub const EcOpBundle = struct { allocator: Allocator, ops: []StackOp, @@ -84,7 +163,19 @@ pub const EcOpBundle = struct { }; pub fn buildBuiltinOps(allocator: Allocator, builtin: registry.CryptoBuiltin) EcEmitterError!EcOpBundle { - var tracker = try ECTracker.init(allocator, initialNames(builtin)); + return buildBuiltinOpsOpts(allocator, builtin, .{}); +} + +/// `buildBuiltinOps` with the EXPERIMENTAL EC script-size options. +/// +/// An all-false value keeps every emitter byte-identical to the shipping output; +/// see `EcCodegenOptions` and docs/experiments/script-size-optimizer-results.md. +pub fn buildBuiltinOpsOpts( + allocator: Allocator, + builtin: registry.CryptoBuiltin, + opts: EcCodegenOptions, +) EcEmitterError!EcOpBundle { + var tracker = try ECTracker.initOpts(allocator, initialNames(builtin), opts, null); errdefer tracker.deinit(); switch (builtin) { @@ -126,38 +217,248 @@ pub fn deinitOpsRecursive(allocator: Allocator, ops: []StackOp) void { } } -const ECTracker = struct { + +// --------------------------------------------------------------------------- +// Byte-cost helpers for the constant pool +// --------------------------------------------------------------------------- +// +// These route through the SAME encoders `emit.zig` uses, so the pool's +// cheaper-of-two comparison is exact rather than estimated and can never make a +// call site bigger. `ec_cost_model.zig` is the full model and is pinned against +// the real emitter over every EC emitter; these two are the slice of it the +// tracker itself needs, and are the same functions that model calls. + +/// A writer that counts bytes and discards them. +const CostWriter = struct { + n: usize = 0, + + pub const Error = error{}; + + pub fn writeByte(self: *CostWriter, _: u8) Error!void { + self.n += 1; + } + + pub fn writeAll(self: *CostWriter, bytes: []const u8) Error!void { + self.n += bytes.len; + } + + pub fn writeInt(self: *CostWriter, comptime T: type, _: T, _: std.builtin.Endian) Error!void { + self.n += @sizeOf(T); + } +}; + +/// Serialized byte cost of a bare script number. +pub fn scriptNumberCost(n: i64) usize { + var w = CostWriter{}; + opcodes.encodeScriptNumber(&w, n) catch unreachable; + return w.n; +} + +/// Serialized byte cost of pushing `len` bytes of push data. +pub fn pushDataCost(len: usize) usize { + if (len == 0) return 1; + if (len <= 75) return 1 + len; + if (len <= 255) return 2 + len; + if (len <= 65535) return 3 + len; + return 5 + len; +} + +/// Serialized byte cost of a single push value. +pub fn sizeOfPushValue(pv: PushValue) usize { + return switch (pv) { + .bytes => |data| pushDataCost(data.len), + .integer => |n| scriptNumberCost(n), + // OP_TRUE (0x51) / OP_FALSE (0x00). + .boolean => 1, + }; +} + +/// Serialized byte cost of one Stack IR operation, including nested `if` arms. +/// +/// ONE THING DIFFERS FROM THE OTHER TIERS. There the tracker emits a separate +/// depth `push` op immediately before a `roll` / `pick`, and the cost model +/// charges the roll ONE byte so the depth is not double-counted. This tier's +/// `StackOp.roll` / `.pick` carry the depth in the op itself and `emitStackOp` +/// writes the depth push as part of emitting them — so here they cost +/// `scriptNumberCost(depth) + 1`. Same emitted bytes; the cost-model test is +/// what keeps the two spellings honest. +pub fn sizeOfStackOp(op: StackOp) usize { + return switch (op) { + .push => |pv| sizeOfPushValue(pv), + .dup, .swap, .drop, .nip, .over, .rot, .tuck => 1, + .roll => |d| scriptNumberCost(@intCast(d)) + 1, + .pick => |d| scriptNumberCost(@intCast(d)) + 1, + .opcode => 1, + // OP_IF + then + [OP_ELSE + else] + OP_ENDIF. The emitter writes + // OP_ELSE only for a NON-EMPTY else arm. + .@"if" => |if_op| blk: { + var total: usize = 2; + total += estimateScriptBytes(if_op.then); + if (if_op.@"else") |else_ops| { + if (else_ops.len > 0) total += 1 + estimateScriptBytes(else_ops); + } + break :blk total; + }, + }; +} + +/// Serialized byte cost of a Stack IR sequence. +pub fn estimateScriptBytes(ops: []const StackOp) usize { + var total: usize = 0; + for (ops) |op| total += sizeOfStackOp(op); + return total; +} + +/// Named stack-state tracker, shared with `nist_ec_emitters.zig`. +/// +/// It is `pub` for exactly that reason. The NIST emitters kept their own copy +/// until this port; two independently-maintained copies of a sign lattice are +/// two chances to prove `.reduced` where only `.non_negative` holds, and the +/// resulting script is smaller, passes every local test, and is wrong. The +/// curve-specific parts (prime, order, coordinate width) are function +/// parameters over there, not tracker state, so one tracker serves both. +pub const ECTracker = struct { allocator: Allocator, names: std.ArrayListUnmanaged(?[]const u8), + /// Sign-lattice fact per stack SLOT, kept parallel to `names`. + /// + /// 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 `names` below mirrors into `doms` with the + /// same splice, so the two cannot drift. + doms: std.ArrayListUnmanaged(Dom), + /// Lattice facts for values parked on the alt stack, bottom -> top. + alt_doms: std.ArrayListUnmanaged(Dom), ops: std.ArrayListUnmanaged(StackOp), owned_bytes: std.ArrayListUnmanaged([]u8), + /// Heap copies of generated slot names (`_Tx3`, `_eq5`, ...). + /// + /// The comb builds names by formatting into a stack buffer, which would + /// dangle the moment the buffer is reused. `internName` copies into here and + /// the copies live exactly as long as the tracker's name list does — nothing + /// in the emitted ops refers to them. + owned_names: std.ArrayListUnmanaged([]u8), + opts: EcCodegenOptions, fn init(allocator: Allocator, initial_names: []const ?[]const u8) !ECTracker { + return initOpts(allocator, initial_names, .{}, null); + } + + /// Create a tracker carrying codegen options and, optionally, initial + /// lattice facts for the pre-existing slots. + pub fn initOpts( + allocator: Allocator, + initial_names: []const ?[]const u8, + opts: EcCodegenOptions, + initial_doms: ?[]const Dom, + ) !ECTracker { var names: std.ArrayListUnmanaged(?[]const u8) = .empty; errdefer names.deinit(allocator); try names.appendSlice(allocator, initial_names); + var doms: std.ArrayListUnmanaged(Dom) = .empty; + errdefer doms.deinit(allocator); + if (initial_doms) |d| { + try doms.appendSlice(allocator, d); + } else { + try doms.appendNTimes(allocator, .unknown, initial_names.len); + } return .{ .allocator = allocator, .names = names, + .doms = doms, + .alt_doms = .empty, .ops = .empty, .owned_bytes = .empty, + .owned_names = .empty, + .opts = opts, }; } - fn deinit(self: *ECTracker) void { + pub fn deinit(self: *ECTracker) void { deinitOpsRecursive(self.allocator, self.ops.items); self.ops.deinit(self.allocator); self.names.deinit(self.allocator); + self.doms.deinit(self.allocator); + self.alt_doms.deinit(self.allocator); for (self.owned_bytes.items) |bytes| self.allocator.free(bytes); self.owned_bytes.deinit(self.allocator); + for (self.owned_names.items) |name| self.allocator.free(name); + self.owned_names.deinit(self.allocator); + } + + /// Copy a formatted slot name into tracker-owned storage. + pub fn internName(self: *ECTracker, name: []const u8) ![]const u8 { + const copy = try self.allocator.dupe(u8, name); + try self.owned_names.append(self.allocator, copy); + return copy; + } + + // -- sign lattice -------------------------------------------------------- + + /// What is known about the named value. `.unknown` when the name is absent. + pub fn domainOf(self: *const ECTracker, name: []const u8) 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. + std.debug.assert(self.doms.items.len == self.names.items.len); + var i = self.names.items.len; + while (i > 0) { + i -= 1; + const slot = self.names.items[i] orelse continue; + if (std.mem.eql(u8, slot, name)) return self.doms.items[i]; + } + return .unknown; + } + + /// Record a fact about the named value's slot. + pub fn setDomain(self: *ECTracker, name: []const u8, d: Dom) void { + var i = self.names.items.len; + while (i > 0) { + i -= 1; + const slot = self.names.items[i] orelse continue; + if (std.mem.eql(u8, slot, name)) { + self.doms.items[i] = d; + return; + } + } + } + + /// Push a slot the caller tracks itself (used where raw opcodes create items). + pub fn pushTracked(self: *ECTracker, name: ?[]const u8, d: Dom) !void { + try self.names.append(self.allocator, name); + try self.doms.append(self.allocator, d); + } + + /// Pop a slot the caller tracks itself. Mirror of `pushTracked`. + pub fn popTracked(self: *ECTracker) void { + if (self.names.items.len == 0) return; + _ = self.names.pop(); + _ = self.doms.pop(); + } + + /// Remove the slot at an absolute (bottom-relative) index. + pub fn removeSlotAt(self: *ECTracker, index: usize) struct { name: ?[]const u8, dom: Dom } { + const n = self.names.orderedRemove(index); + const d = self.doms.orderedRemove(index); + return .{ .name = n, .dom = d }; } - fn takeBundle(self: *ECTracker) !EcOpBundle { + pub fn takeBundle(self: *ECTracker) !EcOpBundle { const ops = try self.ops.toOwnedSlice(self.allocator); errdefer self.allocator.free(ops); const owned_bytes = try self.owned_bytes.toOwnedSlice(self.allocator); self.names.deinit(self.allocator); + self.doms.deinit(self.allocator); + self.alt_doms.deinit(self.allocator); + // Names are referenced only while building; nothing in `ops` points at + // them, so they can go now rather than riding along in the bundle. + for (self.owned_names.items) |name| self.allocator.free(name); + self.owned_names.deinit(self.allocator); self.names = .empty; + self.doms = .empty; + self.alt_doms = .empty; + self.owned_names = .empty; self.ops = .empty; self.owned_bytes = .empty; return .{ @@ -171,7 +472,7 @@ const ECTracker = struct { return self.names.items.len; } - fn findDepth(self: *const ECTracker, name: []const u8) !usize { + pub fn findDepth(self: *const ECTracker, name: []const u8) !usize { var i = self.names.items.len; while (i > 0) { i -= 1; @@ -183,111 +484,122 @@ const ECTracker = struct { return error.UnsupportedBuiltin; } - fn emitRaw(self: *ECTracker, op: StackOp) !void { + pub fn emitRaw(self: *ECTracker, op: StackOp) !void { try self.ops.append(self.allocator, op); } - fn emitOpcode(self: *ECTracker, code: []const u8) !void { + pub fn emitOpcode(self: *ECTracker, code: []const u8) !void { try self.emitRaw(.{ .opcode = code }); } - fn emitPushIntRaw(self: *ECTracker, value: i64) !void { + pub fn emitPushIntRaw(self: *ECTracker, value: i64) !void { try self.emitRaw(.{ .push = .{ .integer = value } }); } - fn emitPushBytesRaw(self: *ECTracker, value: []const u8) !void { + pub fn emitPushBytesRaw(self: *ECTracker, value: []const u8) !void { try self.emitRaw(.{ .push = .{ .bytes = value } }); } - fn pushInt(self: *ECTracker, name: ?[]const u8, value: i64) !void { + pub fn pushInt(self: *ECTracker, name: ?[]const u8, value: i64) !void { try self.emitPushIntRaw(value); - try self.names.append(self.allocator, name); + try self.pushTracked(name, if (value >= 0) .non_negative else .unknown); } - fn pushOwnedBytes(self: *ECTracker, name: ?[]const u8, value: []u8) !void { + pub fn pushOwnedBytes(self: *ECTracker, name: ?[]const u8, value: []u8) !void { try self.owned_bytes.append(self.allocator, value); try self.emitPushBytesRaw(value); - try self.names.append(self.allocator, name); + // A byte blob is not a number until BIN2NUM decides how to read it. + try self.pushTracked(name, .unknown); } - fn pushStaticBytes(self: *ECTracker, name: ?[]const u8, value: []const u8) !void { + pub fn pushStaticBytes(self: *ECTracker, name: ?[]const u8, value: []const u8) !void { try self.emitPushBytesRaw(value); - try self.names.append(self.allocator, name); + try self.pushTracked(name, .unknown); } - fn dup(self: *ECTracker, name: ?[]const u8) !void { + pub fn dup(self: *ECTracker, name: ?[]const u8) !void { try self.emitRaw(.{ .dup = {} }); - try self.names.append(self.allocator, name); + const d: Dom = if (self.doms.items.len > 0) self.doms.items[self.doms.items.len - 1] else .unknown; + try self.pushTracked(name, d); } - fn drop(self: *ECTracker) !void { + pub fn drop(self: *ECTracker) !void { try self.emitRaw(.{ .drop = {} }); - _ = self.names.pop(); + self.popTracked(); } - fn swap(self: *ECTracker) !void { + pub fn swap(self: *ECTracker) !void { try self.emitRaw(.{ .swap = {} }); const len = self.names.items.len; if (len >= 2) { const tmp = self.names.items[len - 1]; self.names.items[len - 1] = self.names.items[len - 2]; self.names.items[len - 2] = tmp; + const dtmp = self.doms.items[len - 1]; + self.doms.items[len - 1] = self.doms.items[len - 2]; + self.doms.items[len - 2] = dtmp; } } - fn rot(self: *ECTracker) !void { + pub fn rot(self: *ECTracker) !void { try self.emitRaw(.{ .rot = {} }); const len = self.names.items.len; if (len >= 3) { - const rolled = self.names.orderedRemove(len - 3); - try self.names.append(self.allocator, rolled); + const rolled = self.removeSlotAt(len - 3); + try self.pushTracked(rolled.name, rolled.dom); } } - fn over(self: *ECTracker, name: ?[]const u8) !void { + pub fn over(self: *ECTracker, name: ?[]const u8) !void { try self.emitRaw(.{ .over = {} }); - try self.names.append(self.allocator, name); + const d: Dom = if (self.doms.items.len >= 2) self.doms.items[self.doms.items.len - 2] else .unknown; + try self.pushTracked(name, d); } - fn roll(self: *ECTracker, depth_from_top: usize) !void { + pub fn roll(self: *ECTracker, depth_from_top: usize) !void { if (depth_from_top == 0) return; if (depth_from_top == 1) return self.swap(); if (depth_from_top == 2) return self.rot(); try self.emitRaw(.{ .roll = @intCast(depth_from_top) }); const idx = self.names.items.len - 1 - depth_from_top; - const rolled = self.names.orderedRemove(idx); - try self.names.append(self.allocator, rolled); + const rolled = self.removeSlotAt(idx); + try self.pushTracked(rolled.name, rolled.dom); } - fn pick(self: *ECTracker, depth_from_top: usize, name: ?[]const u8) !void { + pub fn pick(self: *ECTracker, depth_from_top: usize, name: ?[]const u8) !void { if (depth_from_top == 0) return self.dup(name); if (depth_from_top == 1) return self.over(name); try self.emitRaw(.{ .pick = @intCast(depth_from_top) }); - try self.names.append(self.allocator, name); + // The copied slot sits at depth `depth_from_top` from the top. + const src: Dom = if (self.doms.items.len > depth_from_top) + self.doms.items[self.doms.items.len - 1 - depth_from_top] + else + .unknown; + try self.pushTracked(name, src); } - fn toTop(self: *ECTracker, name: []const u8) !void { + pub fn toTop(self: *ECTracker, name: []const u8) !void { try self.roll(try self.findDepth(name)); } - fn copyToTop(self: *ECTracker, name: []const u8, copy_name: ?[]const u8) !void { + pub fn copyToTop(self: *ECTracker, name: []const u8, copy_name: ?[]const u8) !void { try self.pick(try self.findDepth(name), copy_name); } - fn renameTop(self: *ECTracker, name: ?[]const u8) void { + pub fn renameTop(self: *ECTracker, name: ?[]const u8) void { if (self.names.items.len > 0) { self.names.items[self.names.items.len - 1] = name; } } - fn popNames(self: *ECTracker, count: usize) void { + pub fn popNames(self: *ECTracker, count: usize) void { var i: usize = 0; while (i < count and self.names.items.len > 0) : (i += 1) { - _ = self.names.pop(); + self.popTracked(); } } - fn rawBlock( + pub fn rawBlock( self: *ECTracker, consume_count: usize, produce_name: ?[]const u8, @@ -296,8 +608,107 @@ const ECTracker = struct { self.popNames(consume_count); try body(self); if (produce_name) |name| { - try self.names.append(self.allocator, name); + // Opaque opcodes: nothing is known about the result unless the + // caller proves it and records that with `setDomain` afterwards. + try self.pushTracked(name, .unknown); + } + } + + // -- constant pool ------------------------------------------------------- + // + // A pooled constant is an ordinary tracked slot; nothing about the stack + // model changes. `pushConst` just chooses, per call site and by emitted + // bytes, between copying that slot and re-pushing the literal. Nested + // trackers seeded from `names.items` inherit the slot for free, so pooled + // constants work unchanged inside an `OP_IF` arm. + + pub fn hasSlot(self: *const ECTracker, slot: []const u8) bool { + for (self.names.items) |n| { + const name = n orelse continue; + if (std.mem.eql(u8, name, slot)) return true; + } + return false; + } + + /// Park the script-number encoding of `value_be` in `slot` for the lifetime + /// of this emitter. No-op when pooling is off. + /// + /// The slot carries `.non_negative`, not `.unknown`. The reference spells + /// this `pushInt(slot, value)`, whose fact for a positive literal is + /// NonNegative; here the constant arrives as a byte slice, and + /// `pushOwnedBytes`'s blanket `.unknown` — "a byte blob is not a number + /// until BIN2NUM reads it" — is the wrong default for a value that already + /// IS a script number. Every `pick` off this slot inherits the fact. + /// + /// Measured: it moves no bytes today, on either curve family, under any flag + /// combination — no emitter currently passes a pooled constant as the + /// operand of a reduction that consults the lattice. It is stated anyway, + /// because the tracker is now shared with the NIST emitters and a fact that + /// quietly disagrees with the reference is the exact shape of divergence + /// this port is gated against. + pub fn poolConstant(self: *ECTracker, slot: []const u8, value_be: []const u8) !void { + if (!self.opts.constant_pool or self.hasSlot(slot)) return; + const encoded = try beToUnsignedScriptNumAlloc(self.allocator, value_be); + try self.pushOwnedBytes(slot, encoded); + self.setDomain(slot, .non_negative); + } + + /// Remove a pooled slot. No-op when pooling is off or the slot is absent. + pub fn releaseConstant(self: *ECTracker, slot: []const u8) !void { + if (!self.opts.constant_pool or !self.hasSlot(slot)) return; + try self.toTop(slot); + try self.drop(); + } + + /// Emitted bytes a `pushConst` of this constant would cost right now. + /// + /// The comparison is exact — the same encoders the emit pass uses — so + /// pooling can never make a call site bigger. A pick at depth d costs + /// `sizeOfScriptNumber(d) + 1`; depths 0 and 1 are OP_DUP / OP_OVER, + /// 1 byte each. + pub fn constCost(self: *const ECTracker, slot: []const u8, encoded_len: usize) usize { + const literal = pushDataCost(encoded_len); + if (self.opts.constant_pool and self.hasSlot(slot)) { + const d = self.findDepth(slot) catch return literal; + const pick_cost: usize = if (d <= 1) 1 else scriptNumberCost(@intCast(d)) + 1; + if (pick_cost < literal) return pick_cost; + } + return literal; + } + + /// Materialize the constant on top as `name`, from the pooled slot when that + /// is cheaper in emitted bytes than pushing the literal. + /// + /// `.non_negative` on both paths, for the reason `poolConstant` gives — the + /// picked copy inherits the fact from the slot, the literal needs it stated + /// here. + pub fn pushConst(self: *ECTracker, slot: []const u8, value_be: []const u8, name: []const u8) !void { + const encoded = try beToUnsignedScriptNumAlloc(self.allocator, value_be); + if (self.opts.constant_pool and self.hasSlot(slot)) { + const d = try self.findDepth(slot); + const pick_cost: usize = if (d <= 1) 1 else scriptNumberCost(@intCast(d)) + 1; + if (pick_cost < pushDataCost(encoded.len)) { + self.allocator.free(encoded); + try self.pick(d, name); + return; + } } + try self.pushOwnedBytes(name, encoded); + self.setDomain(name, .non_negative); + } + + pub fn toAlt(self: *ECTracker) !void { + try self.emitOpcode("OP_TOALTSTACK"); + if (self.names.items.len == 0) return; + const d = self.doms.items[self.doms.items.len - 1]; + self.popTracked(); + try self.alt_doms.append(self.allocator, d); + } + + pub fn fromAlt(self: *ECTracker, name: ?[]const u8) !void { + try self.emitOpcode("OP_FROMALTSTACK"); + const d: Dom = if (self.alt_doms.items.len > 0) self.alt_doms.pop().? else .unknown; + try self.pushTracked(name, d); } }; @@ -320,6 +731,7 @@ fn emitAddOpcode(t: *ECTracker) !void { try t.emitOpcode("OP_ADD"); } + fn emitSubOpcode(t: *ECTracker) !void { try t.emitOpcode("OP_SUB"); } @@ -352,6 +764,10 @@ fn emitModOpcode(t: *ECTracker) !void { try t.emitOpcode("OP_MOD"); } +fn emit0NotEqualOpcode(t: *ECTracker) !void { + try t.emitOpcode("OP_0NOTEQUAL"); +} + fn emitLessThanOpcode(t: *ECTracker) !void { try t.emitOpcode("OP_LESSTHAN"); } @@ -412,7 +828,7 @@ fn emitReverse32Raw(t: *ECTracker) !void { try t.emitRaw(.{ .drop = {} }); } -fn beToUnsignedScriptNumAlloc(allocator: Allocator, be: []const u8) ![]u8 { +pub fn beToUnsignedScriptNumAlloc(allocator: Allocator, be: []const u8) ![]u8 { var first: usize = 0; while (first < be.len and be[first] == 0) : (first += 1) {} if (first == be.len) { @@ -442,13 +858,11 @@ fn pow2ScriptNumAlloc(allocator: Allocator, bit: usize) ![]u8 { } fn pushFieldPNum(t: *ECTracker, name: []const u8) !void { - const encoded = try beToUnsignedScriptNumAlloc(t.allocator, field_p_be[0..]); - try t.pushOwnedBytes(name, encoded); + try t.pushConst(POOL_FIELD_P, field_p_be[0..], name); } fn pushCurveNNum(t: *ECTracker, name: []const u8) !void { - const encoded = try beToUnsignedScriptNumAlloc(t.allocator, curve_n_be[0..]); - try t.pushOwnedBytes(name, encoded); + try t.pushConst(POOL_GROUP_N, curve_n_be[0..], name); } fn pushPow2Divisor(t: *ECTracker, name: []const u8, bit: usize) !void { @@ -468,30 +882,93 @@ fn generatorPointAlloc(allocator: Allocator) ![]u8 { return point; } +/// `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. +fn fieldModShort(t: *ECTracker, a_name: []const u8, result_name: []const u8) !void { + try t.toTop(a_name); + try pushFieldPNum(t, "_fmods_p"); + try t.rawBlock(2, result_name, emitModOpcode); + t.setDomain(result_name, .reduced); +} + +/// Does the cheap `a - b + p` subtraction shape pay here? +/// +/// It references the prime TWICE where 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. +fn cheapSubPays(t: *const ECTracker) bool { + const c = t.constCost(POOL_FIELD_P, FIELD_P_SCRIPT_NUM_LEN); + return 2 * c + 2 < c + 8; +} + fn fieldMod(t: *ECTracker, a_name: []const u8, result_name: []const u8) !void { + if (t.opts.reduction_sinking and t.domainOf(a_name).isNonNegative()) { + try fieldModShort(t, a_name, result_name); + return; + } try t.toTop(a_name); try pushFieldPNum(t, "_fmod_p"); try t.rawBlock(2, result_name, emitFieldModSequence); + t.setDomain(result_name, .reduced); } fn fieldAdd(t: *ECTracker, a_name: []const u8, b_name: []const u8, result_name: []const u8) !void { + // Read the operand facts BEFORE rawBlock consumes their slots. + const sum_non_neg = t.domainOf(a_name).isNonNegative() and t.domainOf(b_name).isNonNegative(); try t.toTop(a_name); try t.toTop(b_name); try t.rawBlock(2, "_fadd_sum", emitAddOpcode); + if (sum_non_neg) t.setDomain("_fadd_sum", .non_negative); try fieldMod(t, "_fadd_sum", result_name); } fn fieldSub(t: *ECTracker, a_name: []const u8, b_name: []const u8, result_name: []const u8) !void { try t.toTop(a_name); try t.toTop(b_name); + // 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.opts.reduction_sinking and + t.domainOf(a_name).isNonNegative() and + t.domainOf(b_name) == .reduced and + cheapSubPays(t); + try t.rawBlock(2, "_fsub_diff", emitSubOpcode); + + if (cheap) { + try pushFieldPNum(t, "_fsub_p"); + try t.rawBlock(2, "_fsub_shift", emitAddOpcode); + t.setDomain("_fsub_shift", .non_negative); + try fieldModShort(t, "_fsub_shift", result_name); + return; + } try fieldMod(t, "_fsub_diff", result_name); } fn fieldMul(t: *ECTracker, a_name: []const u8, b_name: []const u8, result_name: []const u8) !void { + try fieldMulSigned(t, a_name, b_name, result_name, false); +} + +/// `fieldMul` with an explicit assertion about the product's sign, independent +/// of the operands — `fieldSqr` uses it, since a*a >= 0 for any a whatsoever. +fn fieldMulSigned( + t: *ECTracker, + a_name: []const u8, + b_name: []const u8, + result_name: []const u8, + product_non_negative: bool, +) !void { + const non_neg = product_non_negative or + (t.domainOf(a_name).isNonNegative() and t.domainOf(b_name).isNonNegative()); try t.toTop(a_name); try t.toTop(b_name); try t.rawBlock(2, "_fmul_prod", emitMulOpcode); + if (non_neg) t.setDomain("_fmul_prod", .non_negative); try fieldMod(t, "_fmul_prod", result_name); } @@ -500,6 +977,8 @@ fn emit2MulOpcode(t: *ECTracker) !void { } fn fieldMulConst(t: *ECTracker, a_name: []const u8, c: i64, result_name: []const u8) !void { + // Every call site passes a small positive c, so the product keeps a's sign. + const non_neg = c > 0 and t.domainOf(a_name).isNonNegative(); try t.toTop(a_name); if (c == 2) { // Use OP_2MUL (single opcode, no push needed) @@ -508,12 +987,14 @@ fn fieldMulConst(t: *ECTracker, a_name: []const u8, c: i64, result_name: []const try t.pushInt("_fmc_c", c); try t.rawBlock(2, "_fmc_prod", emitMulOpcode); } + if (non_neg) t.setDomain("_fmc_prod", .non_negative); try fieldMod(t, "_fmc_prod", result_name); } +/// `(a * a) mod p`. A square is non-negative whatever a's sign is. fn fieldSqr(t: *ECTracker, a_name: []const u8, result_name: []const u8) !void { try t.copyToTop(a_name, "_fsqr_copy"); - try fieldMul(t, a_name, "_fsqr_copy", result_name); + try fieldMulSigned(t, a_name, "_fsqr_copy", result_name, true); } fn fieldInv(t: *ECTracker, a_name: []const u8, result_name: []const u8) !void { @@ -552,14 +1033,19 @@ fn decomposePoint(t: *ECTracker, point_name: []const u8, x_name: []const u8, y_n try t.toTop(point_name); t.popNames(1); try emitSplit32Sequence(t); - try t.names.append(t.allocator, "_dp_xb"); - try t.names.append(t.allocator, "_dp_yb"); + try t.pushTracked("_dp_xb", .unknown); + try t.pushTracked("_dp_yb", .unknown); try t.toTop("_dp_yb"); try t.rawBlock(1, y_name, emitBytesToUnsignedNumSequence); + // 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(y_name, .non_negative); try t.toTop("_dp_xb"); try t.rawBlock(1, x_name, emitBytesToUnsignedNumSequence); + t.setDomain(x_name, .non_negative); try t.swap(); } @@ -750,8 +1236,16 @@ fn jacobianToAffine(t: *ECTracker, rx_name: []const u8, ry_name: []const u8) !vo try fieldMul(t, "jy", "_zinv3", ry_name); } -fn buildJacobianAddAffineInline(allocator: Allocator, base_names: []const ?[]const u8) !EcOpBundle { - var inner = try ECTracker.init(allocator, base_names); +fn buildJacobianAddAffineInline( + allocator: Allocator, + base_names: []const ?[]const u8, + opts: EcCodegenOptions, + base_doms: []const Dom, +) !EcOpBundle { + // The inner tracker inherits the stack state AND the lattice facts: the + // operands' proved domains are what decide which reduction shape the body + // emits, so dropping them here would silently fall back everywhere. + var inner = try ECTracker.initOpts(allocator, base_names, opts, base_doms); errdefer inner.deinit(); try jacobianAddAffineBody(&inner, false); @@ -889,8 +1383,13 @@ fn selectCoord( /// ecOnCurve first. /// /// Stack layout: [..., ax, ay, _k, jx, jy, jz] — same in and out. -fn buildJacobianAddOrDoubleInline(allocator: Allocator, base_names: []const ?[]const u8) !EcOpBundle { - var inner = try ECTracker.init(allocator, base_names); +fn buildJacobianAddOrDoubleInline( + allocator: Allocator, + base_names: []const ?[]const u8, + opts: EcCodegenOptions, + base_doms: []const Dom, +) !EcOpBundle { + var inner = try ECTracker.initOpts(allocator, base_names, opts, base_doms); errdefer inner.deinit(); // Keep the pre-add accumulator: it is what must be DOUBLED in the @@ -948,10 +1447,12 @@ fn buildJacobianAddOrDoubleInline(allocator: Allocator, base_names: []const ?[]c } fn emitEcAdd(t: *ECTracker) !void { + try t.poolConstant(POOL_FIELD_P, field_p_be[0..]); try decomposePoint(t, "_pa", "px", "py"); try decomposePoint(t, "_pb", "qx", "qy"); try affineAdd(t); try composePoint(t, "rx", "ry", "_result"); + try t.releaseConstant(POOL_FIELD_P); } /// Reduce a scalar to [0, n-1]: ((k mod n) + n) mod n. @@ -973,14 +1474,36 @@ fn emitScalarReduce(t: *ECTracker, k_name: []const u8, result_name: []const u8) } fn emitEcMul(t: *ECTracker, point_name: []const u8, scalar_name: []const u8) !void { + try t.poolConstant(POOL_FIELD_P, field_p_be[0..]); + try t.poolConstant(POOL_GROUP_N, curve_n_be[0..]); try decomposePoint(t, point_name, "ax", "ay"); // "k in [1, n-1]" is a PRECONDITION the caller cannot enforce — the scalar is // usually an unlock argument — so reduce it first. See emitScalarReduce. try t.toTop(scalar_name); try emitScalarReduce(t, scalar_name, "_kr"); - try t.pushStaticBytes("_3n", curve_3n_script_num_le[0..]); - try t.rawBlock(2, "_kn3", emitAddOpcode); + if (t.opts.constant_pool) { + // Three separate `+n` steps, each served from the pooled slot — the + // shape the reference emits. + try pushCurveNNum(t, "_n"); + try t.rawBlock(2, "_kn", emitAddOpcode); + try pushCurveNNum(t, "_n2"); + try t.rawBlock(2, "_kn2", emitAddOpcode); + try pushCurveNNum(t, "_n3"); + try t.rawBlock(2, "_kn3", emitAddOpcode); + } else { + // Pre-folded `3n` on the DEFAULT path, and only there. + // + // The reference emits three literal `+n` steps and lets its peephole + // reassociate them back to `push 3n; OP_ADD`. This tier's peephole folds + // only i64 `push_int` chains (see peephole.zig rule 27), and a 256-bit + // constant is a `push_data` blob here — so emitting three steps would + // ship 68 extra bytes rather than collapsing. Same shipped bytes as the + // reference, different pre-peephole spelling, which is exactly why the + // Zig parity test is gated on the POST-peephole hash. + try t.pushStaticBytes("_3n", curve_3n_script_num_le[0..]); + try t.rawBlock(2, "_kn3", emitAddOpcode); + } t.renameTop("_k"); try t.copyToTop("ax", "jx"); @@ -1011,9 +1534,9 @@ fn emitEcMul(t: *ECTracker, point_name: []const u8, scalar_name: []const u8) !vo // Only the final step can be handed two equal operands — see // buildJacobianAddOrDoubleInline for why, and for what it costs not to. var add_bundle = if (bit == 0) - try buildJacobianAddOrDoubleInline(t.allocator, t.names.items) + try buildJacobianAddOrDoubleInline(t.allocator, t.names.items, t.opts, t.doms.items) else - try buildJacobianAddAffineInline(t.allocator, t.names.items); + try buildJacobianAddAffineInline(t.allocator, t.names.items, t.opts, t.doms.items); errdefer add_bundle.deinit(); try t.owned_bytes.appendSlice(t.allocator, add_bundle.owned_bytes); @@ -1034,9 +1557,314 @@ fn emitEcMul(t: *ECTracker, point_name: []const u8, scalar_name: []const u8) !vo try t.drop(); try composePoint(t, "_rx", "_ry", "_result"); + try t.releaseConstant(POOL_GROUP_N); + try t.releaseConstant(POOL_FIELD_P); +} + +// =========================================================================== +// Fixed-base comb (secp256k1) +// =========================================================================== + +/// Render a comb table coordinate as a 32-byte big-endian buffer. +fn combCoordBeAlloc(allocator: Allocator, v: comb.Big) ![]u8 { + const out = try allocator.alloc(u8, 32); + var x = v; + var i: usize = 32; + while (i > 0) { + i -= 1; + out[i] = @intCast(@as(u8, @truncate(@as(u256, @intCast(x)) & 0xff))); + x >>= 8; + } + return out; +} + +/// Push a comb table coordinate as an unsigned script number. +fn pushCombCoord(t: *ECTracker, name: []const u8, v: comb.Big) !void { + const be = try combCoordBeAlloc(t.allocator, v); + defer t.allocator.free(be); + const encoded = try beToUnsignedScriptNumAlloc(t.allocator, be); + try t.pushOwnedBytes(name, encoded); +} + +/// Round `i`'s digit and the selected table entry, as `ax`/`ay`/`_flag`. +/// +/// Exactly one equality holds, so `sum(eq_j * T_j)` is that entry's coordinate +/// and every term is non-negative and below p — no reduction is needed, and the +/// result is `.reduced` by construction. When the digit is zero every term +/// vanishes and `_flag` is 0, so no add runs. +fn combEmitSelect(t: *ECTracker, i: usize, w: usize, d: usize) !void { + var buf: [24]u8 = undefined; + const entries = (@as(usize, 1) << @intCast(w)) - 1; + + var b: usize = 0; + while (b < w) : (b += 1) { + const shift = i + b * d; + const kc = try t.internName(try std.fmt.bufPrint(&buf, "_kc{d}", .{b})); + const sh = try t.internName(try std.fmt.bufPrint(&buf, "_sh{d}", .{b})); + try t.copyToTop("_k", kc); + if (shift == 0) { + t.renameTop(sh); + } else if (shift == 1) { + try t.rawBlock(1, sh, emit2DivOpcode); + } else { + const sd = try t.internName(try std.fmt.bufPrint(&buf, "_sd{d}", .{b})); + try t.pushInt(sd, @intCast(shift)); + try t.rawBlock(2, sh, emitRshiftnumOpcode); + } + const two = try t.internName(try std.fmt.bufPrint(&buf, "_two{d}", .{b})); + const bit = try t.internName(try std.fmt.bufPrint(&buf, "_b{d}", .{b})); + try t.pushInt(two, 2); + try t.rawBlock(2, bit, emitModOpcode); + t.setDomain(bit, .reduced); + } + + try t.toTop("_b0"); + t.renameTop("_idx"); + b = 1; + while (b < w) : (b += 1) { + const bit = try t.internName(try std.fmt.bufPrint(&buf, "_b{d}", .{b})); + const wt = try t.internName(try std.fmt.bufPrint(&buf, "_wt{d}", .{b})); + const bw = try t.internName(try std.fmt.bufPrint(&buf, "_bw{d}", .{b})); + try t.toTop(bit); + try t.pushInt(wt, @as(i64, 1) << @intCast(b)); + try t.rawBlock(2, bw, emitMulOpcode); + try t.toTop("_idx"); + try t.rawBlock(2, "_idx", emitAddOpcode); + } + t.setDomain("_idx", .reduced); + + var j: usize = 1; + while (j <= entries) : (j += 1) { + const ic = try t.internName(try std.fmt.bufPrint(&buf, "_ic{d}", .{j})); + const jv = try t.internName(try std.fmt.bufPrint(&buf, "_jv{d}", .{j})); + const eq = try t.internName(try std.fmt.bufPrint(&buf, "_eq{d}", .{j})); + try t.copyToTop("_idx", ic); + try t.pushInt(jv, @intCast(j)); + try t.rawBlock(2, eq, emitNumEqualOpcode); + t.setDomain(eq, .reduced); + } + + for ([_][]const u8{ "x", "y" }) |coord| { + const acc: []const u8 = if (coord[0] == 'x') "ax" else "ay"; + j = 1; + while (j <= entries) : (j += 1) { + const ec_n = try t.internName(try std.fmt.bufPrint(&buf, "_e{s}{d}", .{ coord, j })); + const tc = try t.internName(try std.fmt.bufPrint(&buf, "_t{s}{d}", .{ coord, j })); + const pr = try t.internName(try std.fmt.bufPrint(&buf, "_pr{s}{d}", .{ coord, j })); + const eq = try t.internName(try std.fmt.bufPrint(&buf, "_eq{d}", .{j})); + const tj = try t.internName(try std.fmt.bufPrint(&buf, "_T{s}{d}", .{ coord, j })); + try t.copyToTop(eq, ec_n); + try t.copyToTop(tj, tc); + try t.rawBlock(2, pr, emitMulOpcode); + if (j == 1) { + t.renameTop(acc); + } else { + try t.toTop(acc); + try t.rawBlock(2, acc, emitAddOpcode); + } + } + t.setDomain(acc, .reduced); + } + + j = entries; + while (j >= 1) : (j -= 1) { + const eq = try t.internName(try std.fmt.bufPrint(&buf, "_eq{d}", .{j})); + try t.toTop(eq); + try t.drop(); + if (j == 1) break; + } + + try t.toTop("_idx"); + try t.rawBlock(1, "_flag", emit0NotEqualOpcode); +} + +/// `k*G` by a Lim-Lee fixed-base comb instead of the 257-round binary ladder. +/// +/// The ladder doubles and conditionally adds once per SCALAR BIT. A comb splits +/// the scalar into `w` blocks of `d` bits and reads one bit from each block per +/// round, so it performs one doubling and one conditional add per COLUMN: the +/// round count falls from `w*d` to `d` at the price of a `2^w - 1` entry table. +/// G is a compile-time constant here, so the table costs nothing to build. +/// +/// SOUNDNESS. The cheap incomplete mixed add cannot represent a pre-add +/// accumulator equal to the addend, its negation, or the point at infinity. +/// `buildJacobianAddOrDoubleInline`'s comment justifies using it everywhere but +/// the ladder's LAST step by an interval argument over `c_i mod n`, and insists +/// that argument be re-derived by anything changing the offset or the iteration +/// count. A comb changes both, so it is re-derived: `comb.combSafeRounds` +/// evaluates the same argument as executable interval arithmetic over the comb's +/// own geometry, and any round it cannot prove gets the complete add-or-double +/// form instead. Nothing is assumed safe. +/// +/// The other half of that argument is that the accumulator never starts at +/// infinity, which needs the first digit non-zero. `comb.combGeometry` searches +/// for the scalar offset that guarantees it rather than reusing the ladder's +/// hardcoded `+3n` — right for secp256k1 at w=3, wrong for P-384. +/// +/// Stack in: [_k]. Stack out: [_result]. False when no geometry exists for `w`. +fn emitCombMulGen(t: *ECTracker, w: usize) !bool { + const curve = comb.SECP256K1_COMB_CURVE; + const params = comb.combGeometry(w, curve) orelse return false; + const d = params.d; + var table: [1 << comb.MAX_W]?comb.Point = undefined; + comb.combTable(w, d, curve, &table); + var safe: [comb.MAX_D]bool = undefined; + comb.combSafeRounds(params, curve, &safe); + const entries = (@as(usize, 1) << @intCast(w)) - 1; + var buf: [24]u8 = undefined; + + try t.poolConstant(POOL_FIELD_P, field_p_be[0..]); + try t.poolConstant(POOL_GROUP_N, curve_n_be[0..]); + + // k' = (k mod n) + m*n. The reduce is what confines k to [0, n-1] and so + // what makes the interval argument apply at all; see emitScalarReduce. + try t.toTop("_k"); + try emitScalarReduce(t, "_k", "_kr"); + t.renameTop("_k"); + var i: usize = 0; + while (i < params.offset_multiple) : (i += 1) { + const off = try t.internName(try std.fmt.bufPrint(&buf, "_off{d}", .{i})); + try pushCurveNNum(t, off); + try t.rawBlock(2, "_k", emitAddOpcode); + } + t.setDomain("_k", .non_negative); + + // Table, resident for the whole comb: picking an entry costs 2-3 bytes + // against a 34-byte literal push, and every round reads all of them. + var j: usize = 1; + while (j <= entries) : (j += 1) { + const pt = table[j].?; + const tx = try t.internName(try std.fmt.bufPrint(&buf, "_Tx{d}", .{j})); + const ty = try t.internName(try std.fmt.bufPrint(&buf, "_Ty{d}", .{j})); + try pushCombCoord(t, tx, pt.x); + try pushCombCoord(t, ty, pt.y); + t.setDomain(tx, .reduced); + t.setDomain(ty, .reduced); + } + + // Round d-1 initialises the accumulator. The first digit is non-zero by + // construction (combGeometry), so this is a real point, never infinity. + try combEmitSelect(t, d - 1, w, d); + try t.toTop("_flag"); + try t.drop(); + try t.toTop("ax"); + t.renameTop("jx"); + try t.toTop("ay"); + t.renameTop("jy"); + try t.pushInt("jz", 1); + t.setDomain("jz", .reduced); + + var round: usize = d - 1; + while (round > 0) { + round -= 1; + try jacobianDouble(t); + try combEmitSelect(t, round, w, d); + + // `jacobianAddAffineBody` documents its layout as + // [..., ax, ay, jx, jy, jz] and replaces the accumulator IN PLACE at the + // top. The selection leaves ax/ay above jz, so restore the contract + // before the branch — otherwise the add arm would reorder the stack and + // the empty else arm would not, leaving the two arms with different + // layouts at OP_ENDIF. + try t.toTop("_flag"); + try t.toAlt(); + try t.toTop("jx"); + try t.toTop("jy"); + try t.toTop("jz"); + try t.fromAlt("_flag"); + + t.popNames(1); // consumed by OP_IF + var add_bundle = if (safe[round]) + try buildJacobianAddAffineInline(t.allocator, t.names.items, t.opts, t.doms.items) + else + try buildJacobianAddOrDoubleInline(t.allocator, t.names.items, t.opts, t.doms.items); + errdefer add_bundle.deinit(); + + try t.owned_bytes.appendSlice(t.allocator, add_bundle.owned_bytes); + t.allocator.free(add_bundle.owned_bytes); + add_bundle.owned_bytes = &.{}; + try t.emitRaw(.{ .@"if" = .{ .then = add_bundle.ops, .@"else" = null } }); + add_bundle.ops = &.{}; + + // The addend was selected fresh for this round; the add only copied it. + try t.toTop("ay"); + try t.drop(); + try t.toTop("ax"); + try t.drop(); + } + + try jacobianToAffine(t, "_rx", "_ry"); + + j = entries; + while (j >= 1) : (j -= 1) { + const ty = try t.internName(try std.fmt.bufPrint(&buf, "_Ty{d}", .{j})); + const tx = try t.internName(try std.fmt.bufPrint(&buf, "_Tx{d}", .{j})); + try t.toTop(ty); + try t.drop(); + try t.toTop(tx); + try t.drop(); + if (j == 1) break; + } + try t.toTop("_k"); + try t.drop(); + + try composePoint(t, "_rx", "_ry", "_result"); + try t.releaseConstant(POOL_GROUP_N); + try t.releaseConstant(POOL_FIELD_P); + return true; +} + +/// Emit the cheapest comb over the candidate window widths into `t`. +/// +/// Each candidate is rendered in full and scored with the same byte-cost model +/// the emitter is measured by, and the smallest wins — the window width is not +/// hardcoded. w=1 is the binary ladder and is excluded; beyond w=4 the `2^w` +/// selection logic outgrows the saving. +/// +/// Returns false when no candidate could be built, so the caller falls back to +/// the ladder rather than emitting nothing. +fn emitCombBest(t: *ECTracker) !bool { + var best_w: ?usize = null; + var best_bytes: usize = 0; + for ([_]usize{ 2, 3, 4 }) |w| { + var probe = try ECTracker.initOpts(t.allocator, t.names.items, t.opts, t.doms.items); + defer probe.deinit(); + const built = emitCombMulGen(&probe, w) catch continue; + if (!built) continue; + const bytes = estimateScriptBytes(probe.ops.items); + if (best_w == null or bytes < best_bytes) { + best_w = w; + best_bytes = bytes; + } + } + const w = best_w orelse return false; + return emitCombMulGen(t, w); +} + +/// Render the comb at one window width, for the width-selection test. +/// +/// The emitter picks `w` by rendering every candidate and keeping the smallest; +/// this exposes a single candidate so the test can pin WHICH width wins rather +/// than only that the total matches. +pub fn buildCombProbeForTest(allocator: Allocator, w: usize) !EcOpBundle { + var t = try ECTracker.initOpts(allocator, &.{"_k"}, .{ + .constant_pool = true, + .reduction_sinking = true, + .fixed_base_comb = true, + }, null); + errdefer t.deinit(); + _ = try emitCombMulGen(&t, w); + return t.takeBundle(); } fn emitEcMulGen(t: *ECTracker) !void { + // G is a compile-time constant, so this is the one secp256k1 call site where + // a fixed-base comb applies. `emitEcMul` cannot use it: its base arrives at + // run time. + if (t.opts.fixed_base_comb) { + if (try emitCombBest(t)) return; + } + const point = try generatorPointAlloc(t.allocator); try t.pushOwnedBytes("_pt", point); try t.swap(); @@ -1044,13 +1872,16 @@ fn emitEcMulGen(t: *ECTracker) !void { } fn emitEcNegate(t: *ECTracker) !void { + try t.poolConstant(POOL_FIELD_P, field_p_be[0..]); try decomposePoint(t, "_pt", "_nx", "_ny"); try pushFieldPNum(t, "_fp"); try fieldSub(t, "_fp", "_ny", "_neg_y"); try composePoint(t, "_nx", "_neg_y", "_result"); + try t.releaseConstant(POOL_FIELD_P); } fn emitEcOnCurve(t: *ECTracker) !void { + try t.poolConstant(POOL_FIELD_P, field_p_be[0..]); try decomposePoint(t, "_pt", "_x", "_y"); // GAP-301: coordinate canonicity. `decomposePoint` BIN2NUMs each coordinate @@ -1084,6 +1915,7 @@ fn emitEcOnCurve(t: *ECTracker) !void { try t.toTop("_canon"); try t.toTop("_curve_eq"); try t.rawBlock(2, "_result", emitBoolAndOpcode); + try t.releaseConstant(POOL_FIELD_P); } fn containsOpcode(ops: []const StackOp, opcode: []const u8) bool { diff --git a/compilers/zig/src/passes/helpers/ec_flag_parity_test.zig b/compilers/zig/src/passes/helpers/ec_flag_parity_test.zig new file mode 100644 index 000000000..ee54a1677 --- /dev/null +++ b/compilers/zig/src/passes/helpers/ec_flag_parity_test.zig @@ -0,0 +1,389 @@ +//! Cross-tier parity for the EXPERIMENTAL EC size flags. +//! +//! The flags default off, so the ordinary conformance suite — which compiles +//! with defaults — cannot see them at all. Seven tiers could each ship a +//! DIFFERENT `--ec-constant-pool` and the suite would stay green. +//! +//! That matters because the flags are not cosmetic: they change which reduction +//! form is emitted and which addition formula each ladder round uses. A tier +//! that ports the constant pool but not the sign lattice's `.reduced` +//! precondition produces a script that is smaller, passes its own tests, and is +//! wrong on `ecAdd((0,1), (2^256-1,1))`. Byte-identical output against a single +//! reference is the only cheap check that catches that. +//! +//! WHAT THIS TIER COMPARES, AND WHY IT IS NOT THE HASH. The other six tiers +//! reproduce the reference's RAW emitter output op for op, so they assert its +//! SHA-256. This tier cannot, in two places, both of them differences in +//! SPELLING that its own peephole normalises away before the script ships: +//! +//! 1. `k + 3n`. Every binary ladder emits it pre-folded, because this +//! peephole reassociates only i64 `push_int` chains (peephole.zig rule 27) +//! and a 256/384-bit constant is a `push_data` blob here. The reference +//! emits three `+n` steps that its own peephole collapses to the same +//! thing. On secp256k1 the reference pools those pushes, so this tier +//! matches raw-for-raw whenever the pool is on; on the NIST curves it uses +//! raw literals under every variant, so the divergence is constant there. +//! 2. `push [0x02]` / `push [0x03]`. The reference applies MINIMALDATA and +//! writes `OP_2` / `OP_3`; this tier's push encoder always writes the +//! length-prefixed blob. Pre-existing and flag-independent — it is there +//! with every flag off, on emitters no flag reaches. +//! +//! So the gate here is the raw BYTE COUNT against the fixture, with both +//! divergences priced EXACTLY rather than waved through — if either widens, or +//! appears on an emitter it does not name, this test fails. The whole-script +//! byte identity is then covered end to end by compiling the same contract +//! through this CLI and the TypeScript one and diffing the hex. + +const std = @import("std"); +const testing = std.testing; +const ec = @import("ec_emitters.zig"); +const nist = @import("nist_ec_emitters.zig"); +const cost_model = @import("ec_cost_model.zig"); +const registry = @import("crypto_builtins.zig"); + +const Variant = struct { name: []const u8, opts: ec.EcCodegenOptions }; + +const VARIANTS = [_]Variant{ + .{ .name = "off", .opts = .{} }, + .{ .name = "pool", .opts = .{ .constant_pool = true } }, + .{ .name = "sink", .opts = .{ .constant_pool = true, .reduction_sinking = true } }, + .{ .name = "comb", .opts = .{ + .constant_pool = true, + .reduction_sinking = true, + .fixed_base_comb = true, + } }, +}; + +const Case = struct { name: []const u8, builtin: registry.CryptoBuiltin }; + +const CASES = [_]Case{ + .{ .name = "EcAdd", .builtin = .ec_add }, + .{ .name = "EcMul", .builtin = .ec_mul }, + .{ .name = "EcMulGen", .builtin = .ec_mul_gen }, + .{ .name = "EcNegate", .builtin = .ec_negate }, + .{ .name = "EcOnCurve", .builtin = .ec_on_curve }, +}; + +const NIST_CASES = [_]Case{ + .{ .name = "P256Add", .builtin = .p256_add }, + .{ .name = "P256Mul", .builtin = .p256_mul }, + .{ .name = "P256MulGen", .builtin = .p256_mul_gen }, + .{ .name = "P256Negate", .builtin = .p256_negate }, + .{ .name = "P256OnCurve", .builtin = .p256_on_curve }, + .{ .name = "P256EncodeCompressed", .builtin = .p256_encode_compressed }, + .{ .name = "VerifyECDSA_P256", .builtin = .verify_ecdsa_p256 }, + .{ .name = "P384Add", .builtin = .p384_add }, + .{ .name = "P384Mul", .builtin = .p384_mul }, + .{ .name = "P384MulGen", .builtin = .p384_mul_gen }, + .{ .name = "P384Negate", .builtin = .p384_negate }, + .{ .name = "P384OnCurve", .builtin = .p384_on_curve }, + .{ .name = "P384EncodeCompressed", .builtin = .p384_encode_compressed }, + .{ .name = "VerifyECDSA_P384", .builtin = .verify_ecdsa_p384 }, +}; + +// --------------------------------------------------------------------------- +// The documented divergences, priced exactly. +// +// Each is a difference in SPELLING, not in what the script computes: both are +// normalised away by this tier's peephole, so the shipped hex still matches the +// reference byte for byte. They are stated as exact per-emitter numbers rather +// than a tolerance so that a divergence which widens, or appears on an emitter +// that had none, fails here. +// --------------------------------------------------------------------------- + +/// Bytes the reference's raw output carries that this tier's does not, per +/// BINARY LADDER, from `k + 3n`. +/// +/// The reference pushes `n` three times and adds three times, and lets its +/// peephole reassociate that to `push 3n; OP_ADD`. This tier's peephole folds +/// only i64 `push_int` chains (peephole.zig rule 27) and a 256/384-bit constant +/// is a `push_data` blob here, so it pre-folds instead. secp256k1's `n` and +/// P-256's both encode to 33 bytes, so the arithmetic is the same there: +/// 3*(1 + 33) + 3 == 105 against (1 + 33) + 1 == 35. +const P256_THREE_N: i64 = -70; +/// P-384's `n` encodes to 49 bytes: 3*(1 + 49) + 3 == 153 against 51. +const P384_THREE_N: i64 = -102; + +/// A `push` of a one-byte blob whose value is 1..16. +/// +/// The reference applies MINIMALDATA and spells `push [0x02]` as `OP_2`, one +/// byte; this tier's push encoder always writes the length-prefixed form, two. +/// Two such pushes per site — the `0x02` / `0x03` prefix pair — so `+2`. +/// +/// PRE-EXISTING and flag-independent: it is already there with every flag off, +/// on emitters (`p256EncodeCompressed`) that no flag reaches at all. +const MINIMAL_PUSH_PAIR: i64 = 2; + +/// How many binary ladders this emitter runs under this variant. +/// +/// `p256Mul` / `p384Mul` take their base at run time, so no flag can turn their +/// ladder into a comb. `verifyECDSA` runs two — `u1*G`, whose base IS a +/// constant, and `u2*Q`, whose is not — so the comb removes exactly one. +fn ladderCount(name: []const u8, variant: []const u8) i64 { + const combing = std.mem.eql(u8, variant, "comb"); + if (std.mem.eql(u8, name, "EcMul") or + std.mem.eql(u8, name, "P256Mul") or + std.mem.eql(u8, name, "P384Mul")) return 1; + if (std.mem.eql(u8, name, "EcMulGen") or + std.mem.eql(u8, name, "P256MulGen") or + std.mem.eql(u8, name, "P384MulGen")) return if (combing) 0 else 1; + if (std.mem.eql(u8, name, "VerifyECDSA_P256") or + std.mem.eql(u8, name, "VerifyECDSA_P384")) return if (combing) 1 else 2; + return 0; +} + +/// The exact expected difference from the reference, and ZERO for anything the +/// two divergences above do not name. +fn allowedDelta(name: []const u8, variant: []const u8) i64 { + var delta: i64 = 0; + + // secp256k1 spells its `3n` with POOLED pushes in the reference, so this + // tier matches it raw-for-raw as soon as the pool is on and diverges only + // under `off`. The NIST reference uses raw literals under every variant, so + // its divergence is constant. Do not merge these two cases. + if (std.mem.eql(u8, name, "EcMul") or std.mem.eql(u8, name, "EcMulGen")) { + if (std.mem.eql(u8, variant, "off")) delta += ladderCount(name, variant) * P256_THREE_N; + } else if (std.mem.startsWith(u8, name, "P256") or + std.mem.eql(u8, name, "VerifyECDSA_P256")) + { + delta += ladderCount(name, variant) * P256_THREE_N; + } else if (std.mem.startsWith(u8, name, "P384") or + std.mem.eql(u8, name, "VerifyECDSA_P384")) + { + delta += ladderCount(name, variant) * P384_THREE_N; + } + + // The `0x02` / `0x03` prefix pair: `decompressPubKey`'s SEC1 check, and the + // parity select in `pNNNEncodeCompressed`. + if (std.mem.eql(u8, name, "P256EncodeCompressed") or + std.mem.eql(u8, name, "P384EncodeCompressed") or + std.mem.eql(u8, name, "VerifyECDSA_P256") or + std.mem.eql(u8, name, "VerifyECDSA_P384")) delta += MINIMAL_PUSH_PAIR; + + return delta; +} + +fn fixtureBytes(json: []const u8, emitter: []const u8, variant: []const u8) !i64 { + // Anchored on the emitter's key so `EcMul` cannot match inside `EcMulGen`. + var key_buf: [64]u8 = undefined; + const ekey = try std.fmt.bufPrint(&key_buf, "\"{s}\": {{", .{emitter}); + const at = std.mem.indexOf(u8, json, ekey) orelse return error.EmitterMissing; + var vbuf: [32]u8 = undefined; + const vkey = try std.fmt.bufPrint(&vbuf, "\"{s}\": {{", .{variant}); + const vat = (std.mem.indexOf(u8, json[at..], vkey) orelse return error.VariantMissing) + at; + const bat = (std.mem.indexOf(u8, json[vat..], "\"bytes\":") orelse + return error.BytesMissing) + vat + "\"bytes\":".len; + var end = bat; + while (end < json.len and json[end] != ',') : (end += 1) {} + return std.fmt.parseInt(i64, std.mem.trim(u8, json[bat..end], " \n\r\t"), 10); +} + +fn readFixture(allocator: std.mem.Allocator, io: std.Io) ![]u8 { + return std.Io.Dir.cwd().readFileAlloc( + io, + "../../conformance/ec-flag-parity/expected.json", + allocator, + .limited(1 << 20), + ); +} + +fn checkParity(json: []const u8, c: Case, v: Variant, bundle_ops: []const ec.StackOp) !void { + const got: i64 = @intCast(cost_model.estimateScriptBytes(bundle_ops)); + const want = try fixtureBytes(json, c.name, v.name); + const expected = want + allowedDelta(c.name, v.name); + if (got != expected) { + std.debug.print( + "{s} under {s}: Zig emits {d} bytes, expected {d} (reference {d})\n", + .{ c.name, v.name, got, expected, want }, + ); + return error.ParityMismatch; + } +} + +test "EC flag parity against the TypeScript reference" { + const allocator = testing.allocator; + const json = try readFixture(allocator, std.testing.io); + defer allocator.free(json); + + for (CASES) |c| { + for (VARIANTS) |v| { + var bundle = try ec.buildBuiltinOpsOpts(allocator, c.builtin, v.opts); + defer bundle.deinit(); + try checkParity(json, c, v, bundle.ops); + } + } +} + +test "NIST curve flag parity against the TypeScript reference" { + const allocator = testing.allocator; + const json = try readFixture(allocator, std.testing.io); + defer allocator.free(json); + + // An arena, not `testing.allocator`. `verifyECDSA_P384` under `off` is a + // 2 MB script — nearly four million tracked ops — and 56 of these are built + // here; the leak-checking allocator's per-allocation bookkeeping is what + // dominates the runtime, not the emitters. Allocation discipline for these + // same builtins is covered by the op-count goldens next door, which do run + // on `testing.allocator`. + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + + for (NIST_CASES) |c| { + for (VARIANTS) |v| { + defer _ = arena.reset(.retain_capacity); + var bundle = try nist.buildBuiltinOpsOpts(arena.allocator(), c.builtin, v.opts); + defer bundle.deinit(); + try checkParity(json, c, v, bundle.ops); + } + } +} + +test "the flags default off byte-identically" { + const allocator = testing.allocator; + const json = try readFixture(allocator, std.testing.io); + defer allocator.free(json); + + // An all-false options value must reproduce what the tier ships today. This + // is what keeps the existing goldens, the size baseline and every cross-tier + // hex comparison from moving while the flags are experimental. + for (CASES) |c| { + var a = try ec.buildBuiltinOps(allocator, c.builtin); + defer a.deinit(); + var b = try ec.buildBuiltinOpsOpts(allocator, c.builtin, .{}); + defer b.deinit(); + try testing.expectEqual( + cost_model.estimateScriptBytes(a.ops), + cost_model.estimateScriptBytes(b.ops), + ); + } + // Arena for the NIST half, for the reason the parity test above gives. + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + for (NIST_CASES) |c| { + defer _ = arena.reset(.retain_capacity); + var a = try nist.buildBuiltinOps(arena.allocator(), c.builtin); + defer a.deinit(); + var b = try nist.buildBuiltinOpsOpts(arena.allocator(), c.builtin, .{}); + defer b.deinit(); + try testing.expectEqual( + cost_model.estimateScriptBytes(a.ops), + cost_model.estimateScriptBytes(b.ops), + ); + } +} + +test "the fixture is non-vacuous" { + const allocator = testing.allocator; + const json = try readFixture(allocator, std.testing.io); + defer allocator.free(json); + + // A fixture where every variant had the same size would pass in a tier that + // ignored the flags entirely. + try testing.expect( + try fixtureBytes(json, "EcMul", "pool") < try fixtureBytes(json, "EcMul", "off"), + ); + try testing.expect( + try fixtureBytes(json, "EcMul", "sink") < try fixtureBytes(json, "EcMul", "pool"), + ); + try testing.expect( + try fixtureBytes(json, "EcMulGen", "comb") < try fixtureBytes(json, "EcMulGen", "sink"), + ); + // `ecMul` takes its base at run time, so the comb cannot apply there. + try testing.expectEqual( + try fixtureBytes(json, "EcMul", "sink"), + try fixtureBytes(json, "EcMul", "comb"), + ); + + for ([_][]const u8{ "P256", "P384" }) |curve| { + var buf: [32]u8 = undefined; + const mul = try std.fmt.bufPrint(&buf, "{s}Mul", .{curve}); + try testing.expect(try fixtureBytes(json, mul, "pool") < try fixtureBytes(json, mul, "off")); + try testing.expect(try fixtureBytes(json, mul, "sink") < try fixtureBytes(json, mul, "pool")); + // Same reason as `ecMul`: the base is an argument, not a constant. + try testing.expectEqual( + try fixtureBytes(json, mul, "sink"), + try fixtureBytes(json, mul, "comb"), + ); + } + try testing.expect( + try fixtureBytes(json, "P256MulGen", "comb") < try fixtureBytes(json, "P256MulGen", "sink"), + ); + try testing.expect( + try fixtureBytes(json, "P384MulGen", "comb") < try fixtureBytes(json, "P384MulGen", "sink"), + ); + // Only the `u1*G` half combs, so the verifier shrinks but by much less than + // a whole ladder — a tier that combed `u2*Q` too would be combing an + // attacker-supplied base, which comb.zig's interval argument does not cover. + try testing.expect( + try fixtureBytes(json, "VerifyECDSA_P256", "comb") < + try fixtureBytes(json, "VerifyECDSA_P256", "sink"), + ); + try testing.expect( + try fixtureBytes(json, "VerifyECDSA_P384", "comb") < + try fixtureBytes(json, "VerifyECDSA_P384", "sink"), + ); +} + +test "the comb agrees with the reference on the chosen window width" { + // w is not hardcoded: the emitter renders w in {2,3,4} and keeps the + // smallest. If this tier's cost model scored a different winner than the + // reference's, `EcMulGen/comb` above would already differ — this pins the + // reason, so a future change to the candidate set fails here with a clear + // message rather than as an opaque byte count. + const allocator = testing.allocator; + var best_w: usize = 0; + var best: usize = std.math.maxInt(usize); + for ([_]usize{ 2, 3, 4 }) |w| { + var probe = try ec.buildCombProbeForTest(allocator, w); + defer probe.deinit(); + const bytes = cost_model.estimateScriptBytes(probe.ops); + if (bytes < best) { + best = bytes; + best_w = w; + } + } + try testing.expectEqual(@as(usize, 3), best_w); +} + +test "the NIST comb paths do not leak" { + // The parity tests above run on an arena, which cannot see a leak, and the + // op-count goldens next door only exercise the DEFAULT path — so nothing + // else runs the comb, the pooled slots or the verifier's transferred bundle + // under a leak-checking allocator. The two cheapest emitters that reach all + // three do it here. + const allocator = testing.allocator; + const opts = ec.EcCodegenOptions{ + .constant_pool = true, + .reduction_sinking = true, + .fixed_base_comb = true, + }; + for ([_]registry.CryptoBuiltin{ .p256_mul_gen, .verify_ecdsa_p256 }) |b| { + var bundle = try nist.buildBuiltinOpsOpts(allocator, b, opts); + bundle.deinit(); + } +} + +test "the NIST combs agree with the reference on the chosen window width" { + // Same argument as the secp256k1 case above. Stated per curve because the + // geometry search is per curve: P-256 at w=3 lands on the ladder's own +3n + // offset, P-384 at w=3 needs +5n, and a tier that hardcoded the ladder's + // offset would still pick w=3 while emitting a comb whose leading digit can + // vanish. + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + for ([_]bool{ false, true }) |p384| { + var best_w: usize = 0; + var best: usize = std.math.maxInt(usize); + for ([_]usize{ 2, 3, 4 }) |w| { + defer _ = arena.reset(.retain_capacity); + var probe = try nist.buildCombProbeForTest(arena.allocator(), p384, w); + defer probe.deinit(); + const bytes = cost_model.estimateScriptBytes(probe.ops); + if (bytes < best) { + best = bytes; + best_w = w; + } + } + try testing.expectEqual(@as(usize, 3), best_w); + } +} diff --git a/compilers/zig/src/passes/helpers/nist_ec_emitters.zig b/compilers/zig/src/passes/helpers/nist_ec_emitters.zig index 124e37c6f..a1b5d6584 100644 --- a/compilers/zig/src/passes/helpers/nist_ec_emitters.zig +++ b/compilers/zig/src/passes/helpers/nist_ec_emitters.zig @@ -1,7 +1,8 @@ //! NIST P-256 and P-384 elliptic curve codegen for Bitcoin Script. //! -//! Follows the same pattern as ec_emitters.zig and bn254_emitters.zig. -//! Uses ECTracker for named stack state tracking. +//! Follows the same pattern as ec_emitters.zig and bn254_emitters.zig, and +//! shares that module's `ECTracker` — including its sign lattice — rather than +//! keeping a second copy. See the tracker section below for why. //! //! Point representation: //! P-256: 64 bytes (x[32] || y[32], big-endian unsigned) @@ -9,9 +10,14 @@ //! //! Key difference from secp256k1: a = -3 (not 0), giving an optimized //! Jacobian doubling formula. +//! +//! The three EXPERIMENTAL size flags (`EcCodegenOptions`) are honoured here as +//! they are for secp256k1: an all-false value leaves every emitter byte- +//! identical to what this tier shipped before they existed. const std = @import("std"); const ec = @import("ec_emitters.zig"); +const comb = @import("comb.zig"); const registry = @import("crypto_builtins.zig"); const Allocator = std.mem.Allocator; @@ -19,6 +25,10 @@ const StackOp = ec.StackOp; const StackIf = ec.StackIf; const PushValue = ec.PushValue; const EcOpBundle = ec.EcOpBundle; +const Dom = ec.Dom; +const EcCodegenOptions = ec.EcCodegenOptions; +const POOL_FIELD_P = ec.POOL_FIELD_P; +const POOL_GROUP_N = ec.POOL_GROUP_N; // =========================================================================== // P-256 (secp256r1) constants — 32-byte big-endian @@ -202,21 +212,21 @@ const p384_3n_be = [_]u8{ // Helper: encode big-endian bytes to Bitcoin Script number (unsigned LE + sign byte) // =========================================================================== -fn beToUnsignedScriptNumAlloc(allocator: Allocator, be: []const u8) ![]u8 { +/// Shared with `ec_emitters.zig` rather than reimplemented: a second encoder +/// free to drift is a second spelling of the same constant, and the pool prices +/// call sites off the length this returns. +const beToUnsignedScriptNumAlloc = ec.beToUnsignedScriptNumAlloc; + +/// Length of `be`'s unsigned script-number encoding, without allocating. +/// +/// `cheapSubPays` prices the prime before anything is emitted, and the pool's +/// cheaper-of-two comparison must be exact or it could make a call site bigger. +fn scriptNumLen(be: []const u8) usize { var first: usize = 0; while (first < be.len and be[first] == 0) : (first += 1) {} - if (first == be.len) { - return allocator.dupe(u8, &.{}); - } + if (first == be.len) return 0; const trimmed = be[first..]; - const needs_sign_byte = (trimmed[0] & 0x80) != 0; - const out_len = trimmed.len + @as(usize, if (needs_sign_byte) 1 else 0); - const out = try allocator.alloc(u8, out_len); - for (trimmed, 0..) |_, idx| { - out[idx] = trimmed[trimmed.len - 1 - idx]; - } - if (needs_sign_byte) out[out_len - 1] = 0; - return out; + return trimmed.len + @as(usize, if ((trimmed[0] & 0x80) != 0) 1 else 0); } /// Get bit `i` (0 = LSB) of a big-endian byte slice. @@ -254,6 +264,12 @@ const NistCurveParams = struct { sqrt_exp_be: []const u8, gen_x_be: []const u8, gen_y_be: []const u8, + /// The same curve for `comb.zig`'s compile-time table. Kept here so the + /// fixed-base comb can never be handed a curve whose field prime disagrees + /// with the one the emitted reductions use — that would build a table of + /// points on a DIFFERENT curve, which this curve's on-curve check would + /// happily accept. + comb_curve: comb.Curve, }; const p256_params = NistCurveParams{ @@ -267,6 +283,7 @@ const p256_params = NistCurveParams{ .sqrt_exp_be = p256_sqrt_exp_be[0..], .gen_x_be = p256_gx_be[0..], .gen_y_be = p256_gy_be[0..], + .comb_curve = comb.P256_COMB_CURVE, }; const p384_params = NistCurveParams{ @@ -280,182 +297,95 @@ const p384_params = NistCurveParams{ .sqrt_exp_be = p384_sqrt_exp_be[0..], .gen_x_be = p384_gx_be[0..], .gen_y_be = p384_gy_be[0..], + .comb_curve = comb.P384_COMB_CURVE, }; // =========================================================================== -// NistTracker — named stack state tracker for NIST EC operations +// Tracker — shared with ec_emitters.zig // =========================================================================== -const NistTracker = struct { - allocator: Allocator, - names: std.ArrayListUnmanaged(?[]const u8), - ops: std.ArrayListUnmanaged(StackOp), - owned_bytes: std.ArrayListUnmanaged([]u8), - params: *const NistCurveParams, - - fn init(allocator: Allocator, initial_names: []const ?[]const u8, params: *const NistCurveParams) !NistTracker { - var names: std.ArrayListUnmanaged(?[]const u8) = .empty; - errdefer names.deinit(allocator); - try names.appendSlice(allocator, initial_names); - return .{ - .allocator = allocator, - .names = names, - .ops = .empty, - .owned_bytes = .empty, - .params = params, - }; - } - - fn deinit(self: *NistTracker) void { - ec.deinitOpsRecursive(self.allocator, self.ops.items); - self.ops.deinit(self.allocator); - self.names.deinit(self.allocator); - for (self.owned_bytes.items) |bytes| self.allocator.free(bytes); - self.owned_bytes.deinit(self.allocator); - } - - fn takeBundle(self: *NistTracker) !EcOpBundle { - const ops = try self.ops.toOwnedSlice(self.allocator); - errdefer self.allocator.free(ops); - const owned_bytes = try self.owned_bytes.toOwnedSlice(self.allocator); - self.names.deinit(self.allocator); - self.names = .empty; - self.ops = .empty; - self.owned_bytes = .empty; - return .{ - .allocator = self.allocator, - .ops = ops, - .owned_bytes = owned_bytes, - }; - } - - fn findDepth(self: *const NistTracker, name: []const u8) !usize { - var i = self.names.items.len; - while (i > 0) { - i -= 1; - const slot = self.names.items[i] orelse continue; - if (std.mem.eql(u8, slot, name)) { - return self.names.items.len - 1 - i; - } - } - return error.NameNotFound; - } - - fn emitRaw(self: *NistTracker, op: StackOp) !void { - try self.ops.append(self.allocator, op); - } - - fn emitOpcode(self: *NistTracker, code: []const u8) !void { - try self.emitRaw(.{ .opcode = code }); - } - - fn emitPushInt(self: *NistTracker, value: i64) !void { - try self.emitRaw(.{ .push = .{ .integer = value } }); - } - - fn pushInt(self: *NistTracker, name: ?[]const u8, value: i64) !void { - try self.emitPushInt(value); - try self.names.append(self.allocator, name); - } - - fn pushOwnedBytes(self: *NistTracker, name: ?[]const u8, value: []u8) !void { - try self.owned_bytes.append(self.allocator, value); - try self.emitRaw(.{ .push = .{ .bytes = value } }); - try self.names.append(self.allocator, name); - } - - fn pushStaticBytes(self: *NistTracker, name: ?[]const u8, value: []const u8) !void { - try self.emitRaw(.{ .push = .{ .bytes = value } }); - try self.names.append(self.allocator, name); - } - - fn pushBigIntBE(self: *NistTracker, name: ?[]const u8, be: []const u8) !void { - const encoded = try beToUnsignedScriptNumAlloc(self.allocator, be); - try self.pushOwnedBytes(name, encoded); - } +/// The NIST emitters run on `ec_emitters.ECTracker`, the same tracker the +/// secp256k1 ones use. They kept a private copy of it until the size flags +/// landed; the copy carried no sign lattice, and adding a second one would have +/// been two chances to prove `.reduced` where only `.non_negative` holds — a +/// script that is smaller, passes every local test, and is wrong on an +/// adversarial coordinate. +/// +/// The curve-specific state that copy carried (`params`) is a function +/// parameter here instead. Nothing about a tracker is per-curve: the prime, the +/// order and the coordinate width all reach the emitters through the call, and +/// one emitter only ever works on one curve. +const NistTracker = ec.ECTracker; - fn dup(self: *NistTracker, name: ?[]const u8) !void { - try self.emitRaw(.{ .dup = {} }); - try self.names.append(self.allocator, name); - } +/// Push a positive big-endian constant as a script number. +/// +/// The reference spells this `t.pushInt(name, value)`, and the `.non_negative` +/// fact that comes with a positive literal there is load-bearing: it is what +/// lets `fieldAdd(x^3 - 3x, b)` take the short reduction. Arriving as a byte +/// slice it would otherwise be `.unknown` — see `ECTracker.poolConstant`. +fn pushBigIntBE(t: *NistTracker, name: []const u8, be: []const u8) !void { + const encoded = try beToUnsignedScriptNumAlloc(t.allocator, be); + try t.pushOwnedBytes(name, encoded); + t.setDomain(name, .non_negative); +} - fn drop(self: *NistTracker) !void { - try self.emitRaw(.{ .drop = {} }); - _ = self.names.pop(); - } +/// The field prime, from the pooled slot when that is cheaper. +fn pushFieldP(t: *NistTracker, name: []const u8, p_be: []const u8) !void { + try t.pushConst(POOL_FIELD_P, p_be, name); +} - fn swap(self: *NistTracker) !void { - try self.emitRaw(.{ .swap = {} }); - const len = self.names.items.len; - if (len >= 2) { - const tmp = self.names.items[len - 1]; - self.names.items[len - 1] = self.names.items[len - 2]; - self.names.items[len - 2] = tmp; - } - } +/// The group order, from the pooled slot when that is cheaper. +fn pushGroupN(t: *NistTracker, name: []const u8, n_be: []const u8) !void { + try t.pushConst(POOL_GROUP_N, n_be, name); +} - fn over(self: *NistTracker, name: ?[]const u8) !void { - try self.emitRaw(.{ .over = {} }); - try self.names.append(self.allocator, name); - } +// `rawBlock` takes a plain function pointer, not a closure, so every body it +// can run has to be a fixed sequence with its operands already on the stack. +// These are those bodies; anything parameterised (the byte reversals, the +// width-dependent splits) does its own popNames / emit / pushTracked instead. - fn rot(self: *NistTracker) !void { - try self.emitRaw(.{ .rot = {} }); - const len = self.names.items.len; - if (len >= 3) { - const rolled = self.names.orderedRemove(len - 3); - try self.names.append(self.allocator, rolled); - } - } +fn emitAddOpcode(t: *NistTracker) !void { + try t.emitOpcode("OP_ADD"); +} - fn roll(self: *NistTracker, depth_from_top: usize) !void { - if (depth_from_top == 0) return; - if (depth_from_top == 1) return self.swap(); - if (depth_from_top == 2) return self.rot(); - try self.emitRaw(.{ .roll = @intCast(depth_from_top) }); - const idx = self.names.items.len - 1 - depth_from_top; - const rolled = self.names.orderedRemove(idx); - try self.names.append(self.allocator, rolled); - } +fn emitSubOpcode(t: *NistTracker) !void { + try t.emitOpcode("OP_SUB"); +} - fn pick(self: *NistTracker, depth_from_top: usize, name: ?[]const u8) !void { - if (depth_from_top == 0) return self.dup(name); - if (depth_from_top == 1) return self.over(name); - try self.emitRaw(.{ .pick = @intCast(depth_from_top) }); - try self.names.append(self.allocator, name); - } +fn emitMulOpcode(t: *NistTracker) !void { + try t.emitOpcode("OP_MUL"); +} - fn toTop(self: *NistTracker, name: []const u8) !void { - try self.roll(try self.findDepth(name)); - } +fn emitModOpcode(t: *NistTracker) !void { + try t.emitOpcode("OP_MOD"); +} - fn copyToTop(self: *NistTracker, name: []const u8, copy_name: ?[]const u8) !void { - try self.pick(try self.findDepth(name), copy_name); - } +fn emit2DivOpcode(t: *NistTracker) !void { + try t.emitOpcode("OP_2DIV"); +} - fn renameTop(self: *NistTracker, name: ?[]const u8) void { - if (self.names.items.len > 0) { - self.names.items[self.names.items.len - 1] = name; - } - } +fn emitRshiftnumOpcode(t: *NistTracker) !void { + try t.emitOpcode("OP_RSHIFTNUM"); +} - fn popNames(self: *NistTracker, count: usize) void { - var i: usize = 0; - while (i < count and self.names.items.len > 0) : (i += 1) { - _ = self.names.pop(); - } - } +fn emitNumEqualOpcode(t: *NistTracker) !void { + try t.emitOpcode("OP_NUMEQUAL"); +} - fn toAlt(self: *NistTracker) !void { - try self.emitOpcode("OP_TOALTSTACK"); - _ = self.names.pop(); - } +fn emit0NotEqualOpcode(t: *NistTracker) !void { + try t.emitOpcode("OP_0NOTEQUAL"); +} - fn fromAlt(self: *NistTracker, name: ?[]const u8) !void { - try self.emitOpcode("OP_FROMALTSTACK"); - try self.names.append(self.allocator, name); - } -}; +fn emitModSequence(t: *NistTracker) !void { + try t.emitOpcode("OP_2DUP"); + try t.emitOpcode("OP_MOD"); + try t.emitRaw(.{ .rot = {} }); + try t.emitRaw(.{ .drop = {} }); + try t.emitRaw(.{ .over = {} }); + try t.emitOpcode("OP_ADD"); + try t.emitRaw(.{ .swap = {} }); + try t.emitOpcode("OP_MOD"); +} // =========================================================================== // Byte reversal emitters (for coord_bytes = 32 or 48) @@ -465,7 +395,7 @@ fn emitReverseN(t: *NistTracker, n: usize) !void { try t.emitOpcode("OP_0"); try t.emitRaw(.{ .swap = {} }); for (0..n) |_| { - try t.emitPushInt(1); + try t.emitPushIntRaw(1); try t.emitOpcode("OP_SPLIT"); try t.emitRaw(.{ .rot = {} }); try t.emitRaw(.{ .rot = {} }); @@ -487,9 +417,9 @@ fn emitBytesToUnsignedNum(t: *NistTracker, coord_bytes: usize) !void { /// Convert an unsigned script-num on TOS to N big-endian bytes. fn emitUnsignedNumToBeBytes(t: *NistTracker, coord_bytes: usize) !void { const n_plus_1 = @as(i64, @intCast(coord_bytes + 1)); - try t.emitPushInt(n_plus_1); + try t.emitPushIntRaw(n_plus_1); try t.emitOpcode("OP_NUM2BIN"); - try t.emitPushInt(@as(i64, @intCast(coord_bytes))); + try t.emitPushIntRaw(@as(i64, @intCast(coord_bytes))); try t.emitOpcode("OP_SPLIT"); try t.emitRaw(.{ .drop = {} }); try emitReverseN(t, coord_bytes); @@ -499,111 +429,184 @@ fn emitUnsignedNumToBeBytes(t: *NistTracker, coord_bytes: usize) !void { // Point decompose / compose // =========================================================================== -fn decomposePoint(t: *NistTracker, point_name: []const u8, x_name: []const u8, y_name: []const u8) !void { - const cb = t.params.coord_bytes; +fn decomposePoint( + t: *NistTracker, + c: *const NistCurveParams, + point_name: []const u8, + x_name: []const u8, + y_name: []const u8, +) !void { + const cb = c.coord_bytes; try t.toTop(point_name); t.popNames(1); - try t.emitPushInt(@intCast(cb)); + try t.emitPushIntRaw(@intCast(cb)); try t.emitOpcode("OP_SPLIT"); - try t.names.append(t.allocator, "_dp_xb"); - try t.names.append(t.allocator, "_dp_yb"); + try t.pushTracked("_dp_xb", .unknown); + try t.pushTracked("_dp_yb", .unknown); // Convert y_bytes (on top) to num try t.toTop("_dp_yb"); t.popNames(1); try emitBytesToUnsignedNum(t, cb); - try t.names.append(t.allocator, y_name); + try t.pushTracked(y_name, .unknown); + // A 0x00 sign byte is appended before BIN2NUM, so the coordinate decodes + // UNSIGNED: >= 0, but it may be up to 2^(8*cb) - 1 and therefore >= p. That + // gap is exactly what the subtraction precondition turns on — recording + // `.reduced` here would make `p256Add((0,1), (2^256-1,1))` wrong by exactly + // 2^256 - p while passing every ordinary test. + t.setDomain(y_name, .non_negative); // Convert x_bytes to num try t.toTop("_dp_xb"); t.popNames(1); try emitBytesToUnsignedNum(t, cb); - try t.names.append(t.allocator, x_name); + try t.pushTracked(x_name, .unknown); + t.setDomain(x_name, .non_negative); try t.swap(); } -fn composePoint(t: *NistTracker, x_name: []const u8, y_name: []const u8, result_name: []const u8) !void { - const cb = t.params.coord_bytes; +fn composePoint( + t: *NistTracker, + c: *const NistCurveParams, + x_name: []const u8, + y_name: []const u8, + result_name: []const u8, +) !void { + const cb = c.coord_bytes; try t.toTop(x_name); t.popNames(1); try emitUnsignedNumToBeBytes(t, cb); - try t.names.append(t.allocator, "_cp_xb"); + try t.pushTracked("_cp_xb", .unknown); try t.toTop(y_name); t.popNames(1); try emitUnsignedNumToBeBytes(t, cb); - try t.names.append(t.allocator, "_cp_yb"); + try t.pushTracked("_cp_yb", .unknown); try t.toTop("_cp_xb"); try t.toTop("_cp_yb"); t.popNames(2); try t.emitOpcode("OP_CAT"); - try t.names.append(t.allocator, result_name); + try t.pushTracked(result_name, .unknown); } // =========================================================================== // Field arithmetic (parameterized by the field prime) // =========================================================================== +/// `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. +fn fieldModShort(t: *NistTracker, a_name: []const u8, p_be: []const u8, result_name: []const u8) !void { + try t.toTop(a_name); + try pushFieldP(t, "_fmods_p", p_be); + try t.rawBlock(2, result_name, emitModOpcode); + t.setDomain(result_name, .reduced); +} + +/// Does the cheap `a - b + p` subtraction shape pay here? +/// +/// It references the prime TWICE where 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 the rewrite makes the script LARGER, +/// which is why it is a cost comparison and not a flag. +fn cheapSubPays(t: *const NistTracker, p_be: []const u8) bool { + const c = t.constCost(POOL_FIELD_P, scriptNumLen(p_be)); + return 2 * c + 2 < c + 8; +} + fn fieldMod(t: *NistTracker, a_name: []const u8, p_be: []const u8, result_name: []const u8) !void { + if (t.opts.reduction_sinking and t.domainOf(a_name).isNonNegative()) { + try fieldModShort(t, a_name, p_be, result_name); + return; + } try t.toTop(a_name); - try t.pushBigIntBE("_fmod_p", p_be); - t.popNames(2); - try t.emitOpcode("OP_2DUP"); - try t.emitOpcode("OP_MOD"); - try t.emitRaw(.{ .rot = {} }); - try t.emitRaw(.{ .drop = {} }); - try t.emitRaw(.{ .over = {} }); - try t.emitOpcode("OP_ADD"); - try t.emitRaw(.{ .swap = {} }); - try t.emitOpcode("OP_MOD"); - try t.names.append(t.allocator, result_name); + try pushFieldP(t, "_fmod_p", p_be); + try t.rawBlock(2, result_name, emitModSequence); + t.setDomain(result_name, .reduced); } fn fieldAdd(t: *NistTracker, a_name: []const u8, b_name: []const u8, p_be: []const u8, result_name: []const u8) !void { + // Read the operand facts BEFORE rawBlock consumes their slots. + const sum_non_neg = t.domainOf(a_name).isNonNegative() and t.domainOf(b_name).isNonNegative(); try t.toTop(a_name); try t.toTop(b_name); - t.popNames(2); - try t.emitOpcode("OP_ADD"); - try t.names.append(t.allocator, "_fadd_sum"); + try t.rawBlock(2, "_fadd_sum", emitAddOpcode); + if (sum_non_neg) t.setDomain("_fadd_sum", .non_negative); try fieldMod(t, "_fadd_sum", p_be, result_name); } fn fieldSub(t: *NistTracker, a_name: []const u8, b_name: []const u8, p_be: []const u8, result_name: []const u8) !void { try t.toTop(a_name); try t.toTop(b_name); - t.popNames(2); - try t.emitOpcode("OP_SUB"); - try t.names.append(t.allocator, "_fsub_diff"); + // 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 / 48 unsigned bytes can exceed p, which is precisely the + // `p256Add((0,1), (2^256-1,1))` counterexample. + const cheap = t.opts.reduction_sinking and + t.domainOf(a_name).isNonNegative() and + t.domainOf(b_name) == .reduced and + cheapSubPays(t, p_be); + + try t.rawBlock(2, "_fsub_diff", emitSubOpcode); + + if (cheap) { + try pushFieldP(t, "_fsub_p", p_be); + try t.rawBlock(2, "_fsub_shift", emitAddOpcode); + t.setDomain("_fsub_shift", .non_negative); + try fieldModShort(t, "_fsub_shift", p_be, result_name); + return; + } try fieldMod(t, "_fsub_diff", p_be, result_name); } fn fieldMul(t: *NistTracker, a_name: []const u8, b_name: []const u8, p_be: []const u8, result_name: []const u8) !void { + try fieldMulSigned(t, a_name, b_name, p_be, result_name, false); +} + +/// `fieldMul` with an explicit assertion about the product's sign, independent +/// of the operands — `fieldSqr` uses it, since a*a >= 0 for any a whatsoever. +fn fieldMulSigned( + t: *NistTracker, + a_name: []const u8, + b_name: []const u8, + p_be: []const u8, + result_name: []const u8, + product_non_negative: bool, +) !void { + const non_neg = product_non_negative or + (t.domainOf(a_name).isNonNegative() and t.domainOf(b_name).isNonNegative()); try t.toTop(a_name); try t.toTop(b_name); - t.popNames(2); - try t.emitOpcode("OP_MUL"); - try t.names.append(t.allocator, "_fmul_prod"); + try t.rawBlock(2, "_fmul_prod", emitMulOpcode); + if (non_neg) t.setDomain("_fmul_prod", .non_negative); try fieldMod(t, "_fmul_prod", p_be, result_name); } +/// `(a * a) mod p`. A square is non-negative whatever a's sign is. fn fieldSqr(t: *NistTracker, a_name: []const u8, p_be: []const u8, result_name: []const u8) !void { try t.copyToTop(a_name, "_fsqr_copy"); - try fieldMul(t, a_name, "_fsqr_copy", p_be, result_name); + try fieldMulSigned(t, a_name, "_fsqr_copy", p_be, result_name, true); +} + +fn emit2MulOpcode(t: *NistTracker) !void { + try t.emitOpcode("OP_2MUL"); } fn fieldMulConst(t: *NistTracker, a_name: []const u8, c: i64, p_be: []const u8, result_name: []const u8) !void { + // Every call site passes a small positive c, so the product keeps a's sign. + const non_neg = c > 0 and t.domainOf(a_name).isNonNegative(); try t.toTop(a_name); - t.popNames(1); if (c == 2) { - try t.emitOpcode("OP_2MUL"); + try t.rawBlock(1, "_fmc_prod", emit2MulOpcode); } else { - try t.emitPushInt(c); - try t.emitOpcode("OP_MUL"); + try t.pushInt("_fmc_c", c); + try t.rawBlock(2, "_fmc_prod", emitMulOpcode); } - try t.names.append(t.allocator, "_fmc_prod"); + if (non_neg) t.setDomain("_fmc_prod", .non_negative); try fieldMod(t, "_fmc_prod", p_be, result_name); } @@ -648,27 +651,23 @@ fn fieldInv(t: *NistTracker, a_name: []const u8, exp_be: []const u8, p_be: []con // Group-order arithmetic (mod n) // =========================================================================== +/// `((a mod n) + n) mod n`, always in the long form. +/// +/// Deliberately NOT sunk the way `fieldMod` is. The lattice tracks values +/// against the FIELD prime, and the pool's `.reduced` fact means "in [0, p)" — +/// which says nothing about [0, n). Reusing the short form here would be +/// proving a bound about the wrong modulus; the scalar reduce that gates the +/// ladder's whole interval argument runs through this function. fn groupMod(t: *NistTracker, a_name: []const u8, n_be: []const u8, result_name: []const u8) !void { try t.toTop(a_name); - try t.pushBigIntBE("_gmod_n", n_be); - t.popNames(2); - try t.emitOpcode("OP_2DUP"); - try t.emitOpcode("OP_MOD"); - try t.emitRaw(.{ .rot = {} }); - try t.emitRaw(.{ .drop = {} }); - try t.emitRaw(.{ .over = {} }); - try t.emitOpcode("OP_ADD"); - try t.emitRaw(.{ .swap = {} }); - try t.emitOpcode("OP_MOD"); - try t.names.append(t.allocator, result_name); + try pushGroupN(t, "_gmod_n", n_be); + try t.rawBlock(2, result_name, emitModSequence); } fn groupMul(t: *NistTracker, a_name: []const u8, b_name: []const u8, n_be: []const u8, result_name: []const u8) !void { try t.toTop(a_name); try t.toTop(b_name); - t.popNames(2); - try t.emitOpcode("OP_MUL"); - try t.names.append(t.allocator, "_gmul_prod"); + try t.rawBlock(2, "_gmul_prod", emitMulOpcode); try groupMod(t, "_gmul_prod", n_be, result_name); } @@ -717,20 +716,20 @@ fn groupInv(t: *NistTracker, a_name: []const u8, exp_be: []const u8, n_be: []con /// rejects even though both are documented as THE gate for untrusted points. fn emitCanonicityGuard(t: *NistTracker, x_name: []const u8, y_name: []const u8, p_be: []const u8) !void { try t.copyToTop(x_name, "_x_lt"); - try t.pushBigIntBE("_p_for_x", p_be); + try pushFieldP(t, "_p_for_x", p_be); t.popNames(2); try t.emitOpcode("OP_LESSTHAN"); - try t.names.append(t.allocator, "_x_canon"); + try t.pushTracked("_x_canon", .unknown); try t.copyToTop(y_name, "_y_lt"); - try t.pushBigIntBE("_p_for_y", p_be); + try pushFieldP(t, "_p_for_y", p_be); t.popNames(2); try t.emitOpcode("OP_LESSTHAN"); - try t.names.append(t.allocator, "_y_canon"); + try t.pushTracked("_y_canon", .unknown); try t.toTop("_x_canon"); try t.toTop("_y_canon"); t.popNames(2); try t.emitOpcode("OP_BOOLAND"); - try t.names.append(t.allocator, "_canon"); + try t.pushTracked("_canon", .unknown); } /// Affine point addition. @@ -770,24 +769,25 @@ fn emitCanonicityGuard(t: *NistTracker, x_name: []const u8, y_name: []const u8, /// /// The mask is a bare OP_MUL with no reduction: rx, ry are already in [0, p) /// and notinf is 0 or 1, so the product is canonical either way. -fn affineAdd(t: *NistTracker, p_be: []const u8) !void { +fn affineAdd(t: *NistTracker, c: *const NistCurveParams) !void { + const p_be = c.field_p_be; try t.copyToTop("px", "_px_eq"); try t.copyToTop("qx", "_qx_eq"); t.popNames(2); try t.emitOpcode("OP_NUMEQUAL"); - try t.names.append(t.allocator, "_xeq"); + try t.pushTracked("_xeq", .unknown); try t.copyToTop("py", "_py_eq"); try t.copyToTop("qy", "_qy_eq"); t.popNames(2); try t.emitOpcode("OP_NUMEQUAL"); - try t.names.append(t.allocator, "_yeq"); + try t.pushTracked("_yeq", .unknown); try t.copyToTop("_xeq", "_xeq_c"); try t.toTop("_yeq"); t.popNames(2); try t.emitOpcode("OP_BOOLAND"); - try t.names.append(t.allocator, "_cond"); + try t.pushTracked("_cond", .unknown); // notinf = NOT(xeq - cond): 1 exactly when px == qx and the points differ. try t.toTop("_xeq"); @@ -795,7 +795,7 @@ fn affineAdd(t: *NistTracker, p_be: []const u8) !void { t.popNames(2); try t.emitOpcode("OP_SUB"); try t.emitOpcode("OP_NOT"); - try t.names.append(t.allocator, "_notinf"); + try t.pushTracked("_notinf", .unknown); // chord numerator / denominator try t.copyToTop("qy", "_qy1"); @@ -829,7 +829,7 @@ fn affineAdd(t: *NistTracker, p_be: []const u8) !void { try fieldMul(t, "_den_diff", "_cond_d", p_be, "_den_sel"); try fieldAdd(t, "_den_chord", "_den_sel", p_be, "_s_den"); - try fieldInv(t, "_s_den", t.params.field_p_minus_2_be, p_be, "_s_den_inv"); + try fieldInv(t, "_s_den", c.field_p_minus_2_be, p_be, "_s_den_inv"); try fieldMul(t, "_s_num", "_s_den_inv", p_be, "_s"); try t.copyToTop("_s", "_s_keep"); @@ -860,12 +860,12 @@ fn affineAdd(t: *NistTracker, p_be: []const u8) !void { try t.copyToTop("_notinf", "_notinf_x"); t.popNames(2); try t.emitOpcode("OP_MUL"); - try t.names.append(t.allocator, "rx"); + try t.pushTracked("rx", .unknown); try t.toTop("ry"); try t.toTop("_notinf"); t.popNames(2); try t.emitOpcode("OP_MUL"); - try t.names.append(t.allocator, "ry"); + try t.pushTracked("ry", .unknown); } // =========================================================================== @@ -958,11 +958,20 @@ fn jacobianToAffine(t: *NistTracker, rx_name: []const u8, ry_name: []const u8, p // Jacobian mixed addition (point_jacobian + point_affine) — for inside OP_IF // =========================================================================== -fn buildJacobianAddAffineInline(allocator: Allocator, base_names: []const ?[]const u8, params: *const NistCurveParams) !EcOpBundle { - var inner = try NistTracker.init(allocator, base_names, params); +fn buildJacobianAddAffineInline( + allocator: Allocator, + base_names: []const ?[]const u8, + params: *const NistCurveParams, + opts: EcCodegenOptions, + base_doms: []const Dom, +) !EcOpBundle { + // The inner tracker inherits the stack state AND the lattice facts: the + // operands' proved domains are what decide which reduction shape the body + // emits, so dropping them here would silently fall back everywhere. + var inner = try NistTracker.initOpts(allocator, base_names, opts, base_doms); errdefer inner.deinit(); - try jacobianAddAffineBody(&inner, false); + try jacobianAddAffineBody(&inner, params, false); return inner.takeBundle(); } @@ -972,8 +981,8 @@ fn buildJacobianAddAffineInline(allocator: Allocator, base_names: []const ?[]con /// exactly when the Jacobian accumulator is the same curve point as the affine /// operand, the one case these formulas cannot compute. See /// buildJacobianAddOrDoubleInline. -fn jacobianAddAffineBody(inner: *NistTracker, keep_hr: bool) !void { - const p_be = inner.params.field_p_be; +fn jacobianAddAffineBody(inner: *NistTracker, c: *const NistCurveParams, keep_hr: bool) !void { + const p_be = c.field_p_be; try inner.copyToTop("jz", "_jz_for_z1cu"); try inner.copyToTop("jz", "_jz_for_z3"); @@ -1049,12 +1058,13 @@ fn jacobianAddAffineBody(inner: *NistTracker, keep_hr: bool) !void { /// Consumes add_name, dbl_name and cond_name. fn selectCoord( t: *NistTracker, + c: *const NistCurveParams, add_name: []const u8, dbl_name: []const u8, cond_name: []const u8, result_name: []const u8, ) !void { - const p_be = t.params.field_p_be; + const p_be = c.field_p_be; try t.copyToTop(add_name, "_sel_add_c"); try fieldSub(t, dbl_name, "_sel_add_c", p_be, "_sel_diff"); try fieldMul(t, "_sel_diff", cond_name, p_be, "_sel_scaled"); @@ -1111,8 +1121,14 @@ fn selectCoord( /// the reduce must redo the interval check, not assume this still holds. /// /// Stack layout: [..., ax, ay, _k, jx, jy, jz] — same in and out. -fn buildJacobianAddOrDoubleInline(allocator: Allocator, base_names: []const ?[]const u8, params: *const NistCurveParams) !EcOpBundle { - var inner = try NistTracker.init(allocator, base_names, params); +fn buildJacobianAddOrDoubleInline( + allocator: Allocator, + base_names: []const ?[]const u8, + params: *const NistCurveParams, + opts: EcCodegenOptions, + base_doms: []const Dom, +) !EcOpBundle { + var inner = try NistTracker.initOpts(allocator, base_names, opts, base_doms); errdefer inner.deinit(); const p_be = params.field_p_be; @@ -1123,7 +1139,7 @@ fn buildJacobianAddOrDoubleInline(allocator: Allocator, base_names: []const ?[]c try inner.copyToTop("jy", "_sy"); try inner.copyToTop("jz", "_sz"); - try jacobianAddAffineBody(&inner, true); + try jacobianAddAffineBody(&inner, params, true); // cond = (H == 0) AND (R == 0). Requiring R == 0 too keeps the // accumulator == -P case (k = 0) on the add path, where Z3 = 0 correctly @@ -1132,17 +1148,17 @@ fn buildJacobianAddOrDoubleInline(allocator: Allocator, base_names: []const ?[]c try inner.pushInt("_zero_h", 0); inner.popNames(2); try inner.emitOpcode("OP_NUMEQUAL"); - try inner.names.append(inner.allocator, "_h_is0"); + try inner.pushTracked("_h_is0", .unknown); try inner.toTop("_R_keep"); try inner.pushInt("_zero_r", 0); inner.popNames(2); try inner.emitOpcode("OP_NUMEQUAL"); - try inner.names.append(inner.allocator, "_r_is0"); + try inner.pushTracked("_r_is0", .unknown); try inner.toTop("_h_is0"); try inner.toTop("_r_is0"); inner.popNames(2); try inner.emitOpcode("OP_BOOLAND"); - try inner.names.append(inner.allocator, "_cond"); + try inner.pushTracked("_cond", .unknown); // Move the add result aside so jacobianDouble can work on jx/jy/jz again, // this time holding the saved accumulator. @@ -1167,12 +1183,12 @@ fn buildJacobianAddOrDoubleInline(allocator: Allocator, base_names: []const ?[]c inner.renameTop("_dbl_z"); try inner.copyToTop("_cond", "_cond_x"); - try selectCoord(&inner, "_add_x", "_dbl_x", "_cond_x", "jx"); + try selectCoord(&inner, params, "_add_x", "_dbl_x", "_cond_x", "jx"); try inner.copyToTop("_cond", "_cond_y"); - try selectCoord(&inner, "_add_y", "_dbl_y", "_cond_y", "jy"); + try selectCoord(&inner, params, "_add_y", "_dbl_y", "_cond_y", "jy"); try inner.toTop("_cond"); inner.renameTop("_cond_z"); - try selectCoord(&inner, "_add_z", "_dbl_z", "_cond_z", "jz"); + try selectCoord(&inner, params, "_add_z", "_dbl_z", "_cond_z", "jz"); return inner.takeBundle(); } @@ -1184,32 +1200,49 @@ fn buildJacobianAddOrDoubleInline(allocator: Allocator, base_names: []const ?[]c /// buildScalarMulBundle creates a standalone bundle for scalar multiplication. /// Expects exactly two items on the stack: [point, scalar] (scalar on top). /// Produces exactly one result item: the result point. -fn buildScalarMulBundle(allocator: Allocator, params: *const NistCurveParams) !EcOpBundle { - var t = try NistTracker.init(allocator, &.{ "_pt", "_k" }, params); +fn buildScalarMulBundle( + allocator: Allocator, + params: *const NistCurveParams, + opts: EcCodegenOptions, +) !EcOpBundle { + var t = try NistTracker.initOpts(allocator, &.{ "_pt", "_k" }, opts, null); errdefer t.deinit(); - try emitScalarMulOnTracker(&t); + try emitScalarMulOnTracker(&t, params); return t.takeBundle(); } /// emitScalarMulOnTracker performs scalar mul using the tracker's current names. /// The tracker must have "_pt" and "_k" as named items (in any position). -fn emitScalarMulOnTracker(t: *NistTracker) !void { - const c = t.params; +fn emitScalarMulOnTracker(t: *NistTracker, c: *const NistCurveParams) !void { const p_be = c.field_p_be; - try decomposePoint(t, "_pt", "ax", "ay"); + try t.poolConstant(POOL_FIELD_P, c.field_p_be); + try t.poolConstant(POOL_GROUP_N, c.group_n_be); + try decomposePoint(t, c, "_pt", "ax", "ay"); - // k' = k + 3n (pre-compute 3n to match Go peephole optimizer output) + // k' = k + 3n, PRE-FOLDED — on every path, including the pooled ones. + // + // The reference emits three literal `+n` steps (`cEmitMul`, raw `pushInt` + // under every flag combination) and lets its peephole reassociate them back + // to `push 3n; OP_ADD`. This tier's peephole folds only i64 `push_int` + // chains (peephole.zig rule 27) and a 256/384-bit constant is a `push_data` + // blob here, so three steps would SHIP 70 / 102 extra bytes rather than + // collapsing. Same shipped bytes as the reference, different pre-peephole + // spelling — which is why the Zig parity test allows exactly that delta on + // exactly these emitters and zero everywhere else. + // + // Note this differs from the secp256k1 ladder next door: there the reference + // uses POOLED pushes, so that tier emits three pooled steps and matches + // raw-for-raw whenever the pool is on. Do not copy this shape there, or that + // one here. // // The "k in [1, n-1]" precondition is one the caller cannot enforce — the // scalar is usually an unlock argument — so reduce it to [0, n-1] first. // groupMod IS ((k mod n) + n) mod n, which is exactly that. try t.toTop("_k"); try groupMod(t, "_k", c.group_n_be, "_kr"); - try t.pushBigIntBE("_3n", c.three_n_be); - t.popNames(2); - try t.emitOpcode("OP_ADD"); - try t.names.append(t.allocator, "_k"); + try pushBigIntBE(t, "_3n", c.three_n_be); + try t.rawBlock(2, "_k", emitAddOpcode); // Determine iteration count based on 3n bit length. // The max value of k+3n is 4n-1 which has the same MSB as 3n. @@ -1230,19 +1263,19 @@ fn emitScalarMulOnTracker(t: *NistTracker) !void { if (bit == 1) { t.popNames(1); try t.emitOpcode("OP_2DIV"); - try t.names.append(t.allocator, "_shifted"); + try t.pushTracked("_shifted", .unknown); } else if (bit > 1) { try t.pushInt("_shift", bit); t.popNames(2); try t.emitOpcode("OP_RSHIFTNUM"); - try t.names.append(t.allocator, "_shifted"); + try t.pushTracked("_shifted", .unknown); } else { t.renameTop("_shifted"); } try t.pushInt("_two", 2); t.popNames(2); try t.emitOpcode("OP_MOD"); - try t.names.append(t.allocator, "_bit"); + try t.pushTracked("_bit", .unknown); // Conditional add try t.toTop("_bit"); @@ -1251,9 +1284,9 @@ fn emitScalarMulOnTracker(t: *NistTracker) !void { // Only the final step can be handed two equal operands — see // buildJacobianAddOrDoubleInline for why, and for what it costs not to. var add_bundle = if (bit == 0) - try buildJacobianAddOrDoubleInline(t.allocator, t.names.items, c) + try buildJacobianAddOrDoubleInline(t.allocator, t.names.items, c, t.opts, t.doms.items) else - try buildJacobianAddAffineInline(t.allocator, t.names.items, c); + try buildJacobianAddAffineInline(t.allocator, t.names.items, c, t.opts, t.doms.items); errdefer add_bundle.deinit(); try t.owned_bytes.appendSlice(t.allocator, add_bundle.owned_bytes); @@ -1273,15 +1306,24 @@ fn emitScalarMulOnTracker(t: *NistTracker) !void { try t.toTop("_k"); try t.drop(); - try composePoint(t, "_rx", "_ry", "_result"); + try composePoint(t, c, "_rx", "_ry", "_result"); + try t.releaseConstant(POOL_GROUP_N); + try t.releaseConstant(POOL_FIELD_P); } /// emitScalarMulInline emits scalar mul ops into `outer` tracker. /// Before calling, the outer tracker must have pushed the point and scalar /// (point then scalar, scalar on top) and removed their names via popNames(2). /// After the call, one result name is appended to the outer tracker. -fn emitScalarMulInline(outer: *NistTracker, result_name: []const u8) !void { - var bundle = try buildScalarMulBundle(outer.allocator, outer.params); +fn emitScalarMulInline( + outer: *NistTracker, + params: *const NistCurveParams, + result_name: []const u8, +) !void { + // The ladder runs on its OWN tracker seeded with just its two operands, so + // it cannot see — and cannot pool against — anything the caller left below + // them. That is why it pools its own copies of p and n. + var bundle = try buildScalarMulBundle(outer.allocator, params, outer.opts); errdefer bundle.deinit(); // Transfer owned_bytes pointers to outer tracker, then free the outer slice. @@ -1294,7 +1336,310 @@ fn emitScalarMulInline(outer: *NistTracker, result_name: []const u8) !void { outer.allocator.free(bundle.ops); bundle.ops = &.{}; // prevent double-free in errdefer/deinit - try outer.names.append(outer.allocator, result_name); + try outer.pushTracked(result_name, .unknown); +} + +// =========================================================================== +// Fixed-base comb (P-256 / P-384) +// =========================================================================== + +/// Render a comb table coordinate as a `len`-byte big-endian buffer. +fn combCoordBeAlloc(allocator: Allocator, v: comb.Big, len: usize) ![]u8 { + const out = try allocator.alloc(u8, len); + var x = v; + var i: usize = len; + while (i > 0) { + i -= 1; + out[i] = @truncate(@as(u1024, @intCast(x)) & 0xff); + x >>= 8; + } + return out; +} + +/// Push a comb table coordinate as an unsigned script number. +fn pushCombCoord(t: *NistTracker, name: []const u8, v: comb.Big, len: usize) !void { + const be = try combCoordBeAlloc(t.allocator, v, len); + defer t.allocator.free(be); + const encoded = try beToUnsignedScriptNumAlloc(t.allocator, be); + try t.pushOwnedBytes(name, encoded); +} + +/// Round `i`'s digit and the selected table entry, as `ax`/`ay`/`_flag`. +/// +/// Exactly one equality holds, so `sum(eq_j * T_j)` is that entry's coordinate +/// and every term is non-negative and below p — no reduction is needed, and the +/// result is `.reduced` by construction. When the digit is zero every term +/// vanishes and `_flag` is 0, so no add runs. +fn combEmitSelect(t: *NistTracker, i: usize, w: usize, d: usize) !void { + var buf: [24]u8 = undefined; + const entries = (@as(usize, 1) << @intCast(w)) - 1; + + var b: usize = 0; + while (b < w) : (b += 1) { + const shift = i + b * d; + const kc = try t.internName(try std.fmt.bufPrint(&buf, "_kc{d}", .{b})); + const sh = try t.internName(try std.fmt.bufPrint(&buf, "_sh{d}", .{b})); + try t.copyToTop("_k", kc); + if (shift == 0) { + t.renameTop(sh); + } else if (shift == 1) { + try t.rawBlock(1, sh, emit2DivOpcode); + } else { + const sd = try t.internName(try std.fmt.bufPrint(&buf, "_sd{d}", .{b})); + try t.pushInt(sd, @intCast(shift)); + try t.rawBlock(2, sh, emitRshiftnumOpcode); + } + const two = try t.internName(try std.fmt.bufPrint(&buf, "_two{d}", .{b})); + const bit = try t.internName(try std.fmt.bufPrint(&buf, "_b{d}", .{b})); + try t.pushInt(two, 2); + try t.rawBlock(2, bit, emitModOpcode); + t.setDomain(bit, .reduced); + } + + try t.toTop("_b0"); + t.renameTop("_idx"); + b = 1; + while (b < w) : (b += 1) { + const bit = try t.internName(try std.fmt.bufPrint(&buf, "_b{d}", .{b})); + const wt = try t.internName(try std.fmt.bufPrint(&buf, "_wt{d}", .{b})); + const bw = try t.internName(try std.fmt.bufPrint(&buf, "_bw{d}", .{b})); + try t.toTop(bit); + try t.pushInt(wt, @as(i64, 1) << @intCast(b)); + try t.rawBlock(2, bw, emitMulOpcode); + try t.toTop("_idx"); + try t.rawBlock(2, "_idx", emitAddOpcode); + } + t.setDomain("_idx", .reduced); + + var j: usize = 1; + while (j <= entries) : (j += 1) { + const ic = try t.internName(try std.fmt.bufPrint(&buf, "_ic{d}", .{j})); + const jv = try t.internName(try std.fmt.bufPrint(&buf, "_jv{d}", .{j})); + const eq = try t.internName(try std.fmt.bufPrint(&buf, "_eq{d}", .{j})); + try t.copyToTop("_idx", ic); + try t.pushInt(jv, @intCast(j)); + try t.rawBlock(2, eq, emitNumEqualOpcode); + t.setDomain(eq, .reduced); + } + + for ([_][]const u8{ "x", "y" }) |coord| { + const acc: []const u8 = if (coord[0] == 'x') "ax" else "ay"; + j = 1; + while (j <= entries) : (j += 1) { + const ec_n = try t.internName(try std.fmt.bufPrint(&buf, "_e{s}{d}", .{ coord, j })); + const tc = try t.internName(try std.fmt.bufPrint(&buf, "_t{s}{d}", .{ coord, j })); + const pr = try t.internName(try std.fmt.bufPrint(&buf, "_pr{s}{d}", .{ coord, j })); + const eq = try t.internName(try std.fmt.bufPrint(&buf, "_eq{d}", .{j})); + const tj = try t.internName(try std.fmt.bufPrint(&buf, "_T{s}{d}", .{ coord, j })); + try t.copyToTop(eq, ec_n); + try t.copyToTop(tj, tc); + try t.rawBlock(2, pr, emitMulOpcode); + if (j == 1) { + t.renameTop(acc); + } else { + try t.toTop(acc); + try t.rawBlock(2, acc, emitAddOpcode); + } + } + t.setDomain(acc, .reduced); + } + + j = entries; + while (j >= 1) : (j -= 1) { + const eq = try t.internName(try std.fmt.bufPrint(&buf, "_eq{d}", .{j})); + try t.toTop(eq); + try t.drop(); + if (j == 1) break; + } + + try t.toTop("_idx"); + try t.rawBlock(1, "_flag", emit0NotEqualOpcode); +} + +/// `k*G` by a Lim-Lee fixed-base comb instead of the binary ladder. +/// +/// The ladder doubles and conditionally adds once per SCALAR BIT. A comb splits +/// the scalar into `w` blocks of `d` bits and reads one bit from each block per +/// round, so it performs one doubling and one conditional add per COLUMN: the +/// round count falls from `w*d` to `d` at the price of a `2^w - 1` entry table. +/// G is a compile-time constant here, so the table costs nothing to build. +/// +/// SOUNDNESS. The cheap incomplete mixed add cannot represent a pre-add +/// accumulator equal to the addend, its negation, or the point at infinity. +/// `buildJacobianAddOrDoubleInline`'s comment justifies using it everywhere but +/// the ladder's LAST step by an interval argument over `c_i mod n`, and insists +/// that argument be re-derived by anything changing the offset or the iteration +/// count. A comb changes both, so it is re-derived: `comb.combSafeRounds` +/// evaluates the same argument as executable interval arithmetic over the comb's +/// own geometry, and any round it cannot prove gets the complete add-or-double +/// form instead. Nothing is assumed safe. +/// +/// The other half of that argument is that the accumulator never starts at +/// infinity, which needs the first digit non-zero. `comb.combGeometry` searches +/// for the scalar offset that guarantees it rather than reusing the ladder's +/// hardcoded `+3n` — right for P-256 at w=3 (m=3), WRONG for P-384 at w=3, +/// where the search returns m=5. Assuming `+3n` there would let the leading +/// digit vanish and start the accumulator at infinity. +/// +/// Stack in: [_k]. Stack out: [_result]. False when no geometry exists for `w`. +fn emitCombMulGen(t: *NistTracker, c: *const NistCurveParams, w: usize) !bool { + const curve = c.comb_curve; + const params = comb.combGeometry(w, curve) orelse return false; + const d = params.d; + if (d > comb.MAX_D) return false; + var table: [1 << comb.MAX_W]?comb.Point = undefined; + comb.combTable(w, d, curve, &table); + var safe: [comb.MAX_D]bool = undefined; + comb.combSafeRounds(params, curve, &safe); + const entries = (@as(usize, 1) << @intCast(w)) - 1; + const p_be = c.field_p_be; + var buf: [24]u8 = undefined; + + try t.poolConstant(POOL_FIELD_P, c.field_p_be); + try t.poolConstant(POOL_GROUP_N, c.group_n_be); + + // k' = (k mod n) + m*n. The reduce is what confines k to [0, n-1] and so + // what makes the interval argument apply at all. + try t.toTop("_k"); + try groupMod(t, "_k", c.group_n_be, "_kr"); + t.renameTop("_k"); + var i: usize = 0; + while (i < params.offset_multiple) : (i += 1) { + const off = try t.internName(try std.fmt.bufPrint(&buf, "_off{d}", .{i})); + try pushGroupN(t, off, c.group_n_be); + try t.rawBlock(2, "_k", emitAddOpcode); + } + t.setDomain("_k", .non_negative); + + // Table, resident for the whole comb: picking an entry costs 2-3 bytes + // against a 33 / 49-byte literal push, and every round reads all of them. + var j: usize = 1; + while (j <= entries) : (j += 1) { + const pt = table[j].?; + const tx = try t.internName(try std.fmt.bufPrint(&buf, "_Tx{d}", .{j})); + const ty = try t.internName(try std.fmt.bufPrint(&buf, "_Ty{d}", .{j})); + try pushCombCoord(t, tx, pt.x, c.coord_bytes); + try pushCombCoord(t, ty, pt.y, c.coord_bytes); + t.setDomain(tx, .reduced); + t.setDomain(ty, .reduced); + } + + // Round d-1 initialises the accumulator. The first digit is non-zero by + // construction (combGeometry), so this is a real point, never infinity. + try combEmitSelect(t, d - 1, w, d); + try t.toTop("_flag"); + try t.drop(); + try t.toTop("ax"); + t.renameTop("jx"); + try t.toTop("ay"); + t.renameTop("jy"); + try t.pushInt("jz", 1); + t.setDomain("jz", .reduced); + + var round: usize = d - 1; + while (round > 0) { + round -= 1; + try jacobianDouble(t, p_be); + try combEmitSelect(t, round, w, d); + + // `jacobianAddAffineBody` documents its layout as + // [..., ax, ay, jx, jy, jz] and replaces the accumulator IN PLACE at the + // top. The selection leaves ax/ay above jz, so restore the contract + // before the branch — otherwise the add arm would reorder the stack and + // the empty else arm would not, leaving the two arms with different + // layouts at OP_ENDIF. + try t.toTop("_flag"); + try t.toAlt(); + try t.toTop("jx"); + try t.toTop("jy"); + try t.toTop("jz"); + try t.fromAlt("_flag"); + + t.popNames(1); // consumed by OP_IF + var add_bundle = if (safe[round]) + try buildJacobianAddAffineInline(t.allocator, t.names.items, c, t.opts, t.doms.items) + else + try buildJacobianAddOrDoubleInline(t.allocator, t.names.items, c, t.opts, t.doms.items); + errdefer add_bundle.deinit(); + + try t.owned_bytes.appendSlice(t.allocator, add_bundle.owned_bytes); + t.allocator.free(add_bundle.owned_bytes); + add_bundle.owned_bytes = &.{}; + try t.emitRaw(.{ .@"if" = .{ .then = add_bundle.ops, .@"else" = null } }); + add_bundle.ops = &.{}; + + // The addend was selected fresh for this round; the add only copied it. + try t.toTop("ay"); + try t.drop(); + try t.toTop("ax"); + try t.drop(); + } + + try jacobianToAffine(t, "_rx", "_ry", p_be, c.field_p_minus_2_be); + + j = entries; + while (j >= 1) : (j -= 1) { + const ty = try t.internName(try std.fmt.bufPrint(&buf, "_Ty{d}", .{j})); + const tx = try t.internName(try std.fmt.bufPrint(&buf, "_Tx{d}", .{j})); + try t.toTop(ty); + try t.drop(); + try t.toTop(tx); + try t.drop(); + if (j == 1) break; + } + try t.toTop("_k"); + try t.drop(); + + try composePoint(t, c, "_rx", "_ry", "_result"); + try t.releaseConstant(POOL_GROUP_N); + try t.releaseConstant(POOL_FIELD_P); + return true; +} + +/// Emit the cheapest comb over the candidate window widths into `t`. +/// +/// Each candidate is rendered in full and scored with the same byte-cost model +/// the emitter is measured by, and the smallest wins — the window width is not +/// hardcoded. w=1 is the binary ladder and is excluded; beyond w=4 the `2^w` +/// selection logic outgrows the saving. +/// +/// Returns false when no candidate could be built, so the caller falls back to +/// the ladder rather than emitting nothing. +fn emitCombBest(t: *NistTracker, c: *const NistCurveParams) !bool { + var best_w: ?usize = null; + var best_bytes: usize = 0; + for ([_]usize{ 2, 3, 4 }) |w| { + var probe = try NistTracker.initOpts(t.allocator, t.names.items, t.opts, t.doms.items); + defer probe.deinit(); + const built = emitCombMulGen(&probe, c, w) catch continue; + if (!built) continue; + const bytes = ec.estimateScriptBytes(probe.ops.items); + if (best_w == null or bytes < best_bytes) { + best_w = w; + best_bytes = bytes; + } + } + const w = best_w orelse return false; + return emitCombMulGen(t, c, w); +} + +/// The comb as a standalone bundle, for `emitVerifyECDSA`'s `u1*G` half. +/// +/// Null when no candidate builds, so the caller falls back to pushing G and +/// running the ladder. Like the ladder, it runs on its own tracker seeded with +/// just `_k` and cannot see the verifier's stack. +fn buildCombBundle( + allocator: Allocator, + c: *const NistCurveParams, + opts: EcCodegenOptions, +) !?EcOpBundle { + var t = try NistTracker.initOpts(allocator, &.{"_k"}, opts, null); + errdefer t.deinit(); + if (!try emitCombBest(&t, c)) { + t.deinit(); + return null; + } + return try t.takeBundle(); } // =========================================================================== @@ -1360,17 +1705,22 @@ fn fieldPow(t: *NistTracker, base_name: []const u8, exp_be: []const u8, p_be: [] /// boolean-valued builtin and turning attacker-chosen bytes into a script abort /// would be a liveness regression — the same argument the scalar reduce makes /// for reducing rather than rejecting. -fn decompressPubKey(t: *NistTracker, pk_name: []const u8, qx_name: []const u8, qy_name: []const u8) !void { - const c = t.params; +fn decompressPubKey( + t: *NistTracker, + c: *const NistCurveParams, + pk_name: []const u8, + qx_name: []const u8, + qy_name: []const u8, +) !void { const p_be = c.field_p_be; try t.toTop(pk_name); t.popNames(1); // Split: [prefix_byte, x_bytes] - try t.emitPushInt(1); + try t.emitPushIntRaw(1); try t.emitOpcode("OP_SPLIT"); - try t.names.append(t.allocator, "_dk_prefix"); - try t.names.append(t.allocator, "_dk_xbytes"); + try t.pushTracked("_dk_prefix", .unknown); + try t.pushTracked("_dk_xbytes", .unknown); // 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: @@ -1387,15 +1737,15 @@ fn decompressPubKey(t: *NistTracker, pk_name: []const u8, qx_name: []const u8, q try t.emitRaw(.{ .push = .{ .bytes = &.{0x03} } }); try t.emitOpcode("OP_EQUAL"); try t.emitOpcode("OP_BOOLOR"); - try t.names.append(t.allocator, "_dk_pfx_ok"); + try t.pushTracked("_dk_pfx_ok", .unknown); // Convert prefix to parity: 0x02 -> 0, 0x03 -> 1 try t.toTop("_dk_prefix"); t.popNames(1); try t.emitOpcode("OP_BIN2NUM"); - try t.emitPushInt(2); + try t.emitPushIntRaw(2); try t.emitOpcode("OP_MOD"); - try t.names.append(t.allocator, "_dk_parity"); + try t.pushTracked("_dk_parity", .unknown); // Stash parity on altstack try t.toAlt(); @@ -1404,7 +1754,7 @@ fn decompressPubKey(t: *NistTracker, pk_name: []const u8, qx_name: []const u8, q try t.toTop("_dk_xbytes"); t.popNames(1); try emitBytesToUnsignedNum(t, c.coord_bytes); - try t.names.append(t.allocator, "_dk_x"); + try t.pushTracked("_dk_x", .unknown); // Save x for later try t.copyToTop("_dk_x", "_dk_x_save"); @@ -1421,7 +1771,7 @@ fn decompressPubKey(t: *NistTracker, pk_name: []const u8, qx_name: []const u8, q // x^3 - 3x try fieldSub(t, "_dk_x3", "_dk_3x", p_be, "_dk_x3m3x"); // + b - try t.pushBigIntBE("_dk_b", c.curve_b_be); + try pushBigIntBE(t, "_dk_b", c.curve_b_be); try fieldAdd(t, "_dk_x3m3x", "_dk_b", p_be, "_dk_y2"); // y = (y^2)^sqrtExp mod p. fieldPow CONSUMES its base, so keep a copy of @@ -1434,9 +1784,9 @@ fn decompressPubKey(t: *NistTracker, pk_name: []const u8, qx_name: []const u8, q // Check if candidate y has the right parity try t.copyToTop("_dk_y_cand", "_dk_y_check"); t.popNames(1); - try t.emitPushInt(2); + try t.emitPushIntRaw(2); try t.emitOpcode("OP_MOD"); - try t.names.append(t.allocator, "_dk_y_par"); + try t.pushTracked("_dk_y_par", .unknown); // Retrieve parity from altstack try t.fromAlt("_dk_parity"); @@ -1446,15 +1796,15 @@ fn decompressPubKey(t: *NistTracker, pk_name: []const u8, qx_name: []const u8, q try t.toTop("_dk_parity"); t.popNames(2); try t.emitOpcode("OP_EQUAL"); - try t.names.append(t.allocator, "_dk_match"); + try t.pushTracked("_dk_match", .unknown); // Compute p - y_cand try t.copyToTop("_dk_y_cand", "_dk_y_for_neg"); - try t.pushBigIntBE("_dk_pfn", p_be); + try pushFieldP(t, "_dk_pfn", p_be); try t.toTop("_dk_y_for_neg"); t.popNames(2); try t.emitOpcode("OP_SUB"); - try t.names.append(t.allocator, "_dk_neg_y"); + try t.pushTracked("_dk_neg_y", .unknown); // Use OP_IF to select: if match, use y_cand (drop neg_y), else use neg_y (drop y_cand) try t.toTop("_dk_match"); @@ -1481,7 +1831,7 @@ fn decompressPubKey(t: *NistTracker, pk_name: []const u8, qx_name: []const u8, q } } if (neg_idx) |idx| { - _ = t.names.orderedRemove(idx); + _ = t.removeSlotAt(idx); } // Rename _dk_y_cand -> qyName and _dk_x_save -> qxName @@ -1523,24 +1873,24 @@ fn decompressPubKey(t: *NistTracker, pk_name: []const u8, qx_name: []const u8, q try t.toTop("_dk_y2_keep"); t.popNames(2); try t.emitOpcode("OP_NUMEQUAL"); - try t.names.append(t.allocator, "_dk_res_ok"); + try t.pushTracked("_dk_res_ok", .unknown); try t.copyToTop(qx_name, "_dk_x_lt"); - try t.pushBigIntBE("_dk_p_lt", p_be); + try pushFieldP(t, "_dk_p_lt", p_be); t.popNames(2); try t.emitOpcode("OP_LESSTHAN"); - try t.names.append(t.allocator, "_dk_x_ok"); + try t.pushTracked("_dk_x_ok", .unknown); try t.toTop("_dk_res_ok"); try t.toTop("_dk_x_ok"); t.popNames(2); try t.emitOpcode("OP_BOOLAND"); - try t.names.append(t.allocator, "_dk_curve_ok"); + try t.pushTracked("_dk_curve_ok", .unknown); try t.toTop("_dk_pfx_ok"); t.popNames(2); try t.emitOpcode("OP_BOOLAND"); - try t.names.append(t.allocator, "_dk_valid"); + try t.pushTracked("_dk_valid", .unknown); } // =========================================================================== @@ -1569,7 +1919,7 @@ fn emitLengthGate(t: *NistTracker, name: []const u8, want: usize, flag_name: []c try t.toTop(name); t.popNames(1); try t.emitOpcode("OP_SIZE"); - try t.emitPushInt(@intCast(want)); + try t.emitPushIntRaw(@intCast(want)); try t.emitOpcode("OP_NUMEQUAL"); try t.emitRaw(.{ .swap = {} }); const pad = try t.allocator.alloc(u8, want); @@ -1577,11 +1927,11 @@ fn emitLengthGate(t: *NistTracker, name: []const u8, want: usize, flag_name: []c try t.owned_bytes.append(t.allocator, pad); try t.emitRaw(.{ .push = .{ .bytes = pad } }); try t.emitOpcode("OP_CAT"); - try t.emitPushInt(@intCast(want)); + try t.emitPushIntRaw(@intCast(want)); try t.emitOpcode("OP_SPLIT"); try t.emitRaw(.{ .drop = {} }); - try t.names.append(t.allocator, flag_name); - try t.names.append(t.allocator, name); + try t.pushTracked(flag_name, .unknown); + try t.pushTracked(name, .unknown); } /// SEC1 §4.1.4 step 1 / FIPS 186-5 §6.4.2: verify 1 <= r <= n-1 and @@ -1620,45 +1970,50 @@ fn emitSigRangeGate(t: *NistTracker, n_be: []const u8) !void { try t.copyToTop("_r", "_r_nz_in"); t.popNames(1); try t.emitOpcode("OP_0NOTEQUAL"); - try t.names.append(t.allocator, "_r_nz"); + try t.pushTracked("_r_nz", .unknown); try t.copyToTop("_r", "_r_lt_in"); - try t.pushBigIntBE("_n_for_r", n_be); + try pushGroupN(t, "_n_for_r", n_be); t.popNames(2); try t.emitOpcode("OP_LESSTHAN"); - try t.names.append(t.allocator, "_r_lt"); + try t.pushTracked("_r_lt", .unknown); t.popNames(2); try t.emitOpcode("OP_BOOLAND"); - try t.names.append(t.allocator, "_r_ok"); + try t.pushTracked("_r_ok", .unknown); try t.copyToTop("_s", "_s_nz_in"); t.popNames(1); try t.emitOpcode("OP_0NOTEQUAL"); - try t.names.append(t.allocator, "_s_nz"); + try t.pushTracked("_s_nz", .unknown); try t.copyToTop("_s", "_s_lt_in"); - try t.pushBigIntBE("_n_for_s", n_be); + try pushGroupN(t, "_n_for_s", n_be); t.popNames(2); try t.emitOpcode("OP_LESSTHAN"); - try t.names.append(t.allocator, "_s_lt"); + try t.pushTracked("_s_lt", .unknown); t.popNames(2); try t.emitOpcode("OP_BOOLAND"); - try t.names.append(t.allocator, "_s_ok"); + try t.pushTracked("_s_ok", .unknown); t.popNames(2); try t.emitOpcode("OP_BOOLAND"); - try t.names.append(t.allocator, "_range_ok"); + try t.pushTracked("_range_ok", .unknown); } -fn emitVerifyECDSA(t: *NistTracker) !void { - const c = t.params; - const p_be = c.field_p_be; +fn emitVerifyECDSA(t: *NistTracker, c: *const NistCurveParams) !void { const n_be = c.group_n_be; const n_minus_2_be = c.group_n_minus_2_be; const cb = c.coord_bytes; + // The verifier does hundreds of reductions OUTSIDE the two ladders — the + // decompression sqrt chain, groupInv, affineAdd, the final groupMod. Each + // ladder pools separately: it runs on its own tracker that deliberately + // cannot see this stack, so it cannot reach this slot. + try t.poolConstant(POOL_FIELD_P, c.field_p_be); + try t.poolConstant(POOL_GROUP_N, c.group_n_be); + // Step 0: length gate. `_sig` and `_pk` are bare ByteString in the builtin // table and the type checker imposes no width, so both arrive attacker-sized. // Clamp them and remember whether they were the right size — see @@ -1671,7 +2026,7 @@ fn emitVerifyECDSA(t: *NistTracker) !void { try t.toTop("_sig_len_ok"); t.popNames(2); try t.emitOpcode("OP_BOOLAND"); - try t.names.append(t.allocator, "_len_ok"); + try t.pushTracked("_len_ok", .unknown); // Step 1: e = SHA-256(msg) as integer try t.toTop("_msg"); @@ -1682,27 +2037,27 @@ fn emitVerifyECDSA(t: *NistTracker) !void { try t.emitRaw(.{ .push = .{ .bytes = &.{0x00} } }); try t.emitOpcode("OP_CAT"); try t.emitOpcode("OP_BIN2NUM"); - try t.names.append(t.allocator, "_e"); + try t.pushTracked("_e", .unknown); // Step 2: Parse sig into (r, s) — each coord_bytes bytes try t.toTop("_sig"); t.popNames(1); - try t.emitPushInt(@intCast(cb)); + try t.emitPushIntRaw(@intCast(cb)); try t.emitOpcode("OP_SPLIT"); - try t.names.append(t.allocator, "_r_bytes"); - try t.names.append(t.allocator, "_s_bytes"); + try t.pushTracked("_r_bytes", .unknown); + try t.pushTracked("_s_bytes", .unknown); // Convert r_bytes to integer try t.toTop("_r_bytes"); t.popNames(1); try emitBytesToUnsignedNum(t, cb); - try t.names.append(t.allocator, "_r"); + try t.pushTracked("_r", .unknown); // Convert s_bytes to integer try t.toTop("_s_bytes"); t.popNames(1); try emitBytesToUnsignedNum(t, cb); - try t.names.append(t.allocator, "_s"); + try t.pushTracked("_s", .unknown); // Step 2b: 1 <= r, s <= n-1. Without this an all-zero signature verifies for // any message under any pubkey — see emitSigRangeGate. @@ -1711,7 +2066,7 @@ fn emitVerifyECDSA(t: *NistTracker) !void { // Step 3: Decompress pubkey. Also yields `_dk_valid`: 0 when the pubkey // bytes do not decompress to a canonical on-curve point, which is ANDed into // the result below so such a key can never verify. - try decompressPubKey(t, "_pk", "_qx", "_qy"); + try decompressPubKey(t, c, "_pk", "_qx", "_qy"); // Collapse the three argument verdicts into one flag. Everything below then // carries a single item, as it did when `_dk_valid` was the only one. @@ -1719,11 +2074,11 @@ fn emitVerifyECDSA(t: *NistTracker) !void { try t.toTop("_range_ok"); t.popNames(2); try t.emitOpcode("OP_BOOLAND"); - try t.names.append(t.allocator, "_arg_ok"); + try t.pushTracked("_arg_ok", .unknown); try t.toTop("_dk_valid"); t.popNames(2); try t.emitOpcode("OP_BOOLAND"); - try t.names.append(t.allocator, "_input_ok"); + try t.pushTracked("_input_ok", .unknown); // Step 4: w = s^{-1} mod n try groupInv(t, "_s", n_minus_2_be, n_be, "_w"); @@ -1739,10 +2094,25 @@ fn emitVerifyECDSA(t: *NistTracker) !void { // Step 7: R1 = u1*G // Push G point, bring u1 to top, stash everything else on altstack const point_bytes = cb * 2; - const g_point = try t.allocator.alloc(u8, point_bytes); - @memcpy(g_point[0..cb], c.gen_x_be); - @memcpy(g_point[cb..point_bytes], c.gen_y_be); - try t.pushOwnedBytes("_G", g_point); + // u1*G. G is a compile-time constant, so THIS half can use a fixed-base comb + // — one doubling and one add per COLUMN instead of per bit. u2*Q below + // cannot: Q arrives in the witness, and the comb's interval argument is + // stated for a base of known order. + // + // Rendered before the `_G` push is decided, because whether that push + // happens at all is what the comb changes. + var comb_bundle: ?EcOpBundle = if (t.opts.fixed_base_comb) + try buildCombBundle(t.allocator, c, t.opts) + else + null; + errdefer if (comb_bundle) |*b| b.deinit(); + + if (comb_bundle == null) { + const g_point = try t.allocator.alloc(u8, point_bytes); + @memcpy(g_point[0..cb], c.gen_x_be); + @memcpy(g_point[cb..point_bytes], c.gen_y_be); + try t.pushOwnedBytes("_G", g_point); + } try t.toTop("_u1"); // Stash items on altstack (pushed in reverse retrieval order). @@ -1758,11 +2128,22 @@ fn emitVerifyECDSA(t: *NistTracker) !void { try t.toTop("_qx"); try t.toAlt(); - // Stack now has: [..., _G, _u1] - // Pop those names and emit scalar mul inline (consuming _G and _u1) + // Stack now has: [..., (_G,) _u1] + // Pop those names and emit the multiply (consuming _G and _u1, or just _u1 + // for the comb, which takes the scalar alone). t.popNames(1); // _u1 - t.popNames(1); // _G - try emitScalarMulInline(t, "_R1_point"); + if (comb_bundle) |*bundle| { + try t.owned_bytes.appendSlice(t.allocator, bundle.owned_bytes); + t.allocator.free(bundle.owned_bytes); + bundle.owned_bytes = &.{}; + try t.ops.appendSlice(t.allocator, bundle.ops); + t.allocator.free(bundle.ops); + bundle.ops = &.{}; + try t.pushTracked("_R1_point", .unknown); + } else { + t.popNames(1); // _G + try emitScalarMulInline(t, c, "_R1_point"); + } // Pop qx/qy/u2 from altstack (LIFO order) try t.fromAlt("_qx"); @@ -1774,7 +2155,7 @@ fn emitVerifyECDSA(t: *NistTracker) !void { try t.toAlt(); // Compose Q point from qx, qy - try composePoint(t, "_qx", "_qy", "_Q_point"); + try composePoint(t, c, "_qx", "_qy", "_Q_point"); // Stack now has: [..., _Q_point, _u2] // Bring _Q_point below _u2 to match expected [point, scalar] order @@ -1784,7 +2165,7 @@ fn emitVerifyECDSA(t: *NistTracker) !void { // Pop those names and emit scalar mul inline (consuming _Q_point and _u2) t.popNames(1); // _u2 t.popNames(1); // _Q_point - try emitScalarMulInline(t, "_R2_point"); + try emitScalarMulInline(t, c, "_R2_point"); // Restore R1 point try t.fromAlt("_R1_point"); @@ -1793,10 +2174,10 @@ fn emitVerifyECDSA(t: *NistTracker) !void { try t.swap(); // Decompose both points and do affine addition - try decomposePoint(t, "_R1_point", "px", "py"); - try decomposePoint(t, "_R2_point", "qx", "qy"); + try decomposePoint(t, c, "_R1_point", "px", "py"); + try decomposePoint(t, c, "_R2_point", "qx", "qy"); - try affineAdd(t, p_be); + try affineAdd(t, c); // Step 8: x_R mod n == r try t.toTop("ry"); @@ -1813,7 +2194,7 @@ fn emitVerifyECDSA(t: *NistTracker) !void { try t.toTop("_r_save"); t.popNames(2); try t.emitOpcode("OP_EQUAL"); - try t.names.append(t.allocator, "_sig_ok"); + try t.pushTracked("_sig_ok", .unknown); // Arguments that were the wrong length, out of range, or did not decompress // to a canonical on-curve point can never verify, whatever the ladder made @@ -1822,242 +2203,207 @@ fn emitVerifyECDSA(t: *NistTracker) !void { try t.toTop("_sig_ok"); t.popNames(2); try t.emitOpcode("OP_BOOLAND"); - try t.names.append(t.allocator, "_result"); + try t.pushTracked("_result", .unknown); + try t.releaseConstant(POOL_GROUP_N); + try t.releaseConstant(POOL_FIELD_P); } // =========================================================================== // Public API — build EcOpBundle for each builtin // =========================================================================== +// +// The per-builtin bodies below are curve-generic: P-256 and P-384 differ only +// in their `NistCurveParams`. They were two verbatim copies until the size +// flags landed, and every copy is one more place the comb could be wired to one +// curve and not the other. + +fn emitAdd(t: *NistTracker, c: *const NistCurveParams) !void { + try t.poolConstant(POOL_FIELD_P, c.field_p_be); + try decomposePoint(t, c, "_pa", "px", "py"); + try decomposePoint(t, c, "_pb", "qx", "qy"); + try affineAdd(t, c); + try composePoint(t, c, "rx", "ry", "_result"); + try t.releaseConstant(POOL_FIELD_P); +} + +fn emitMulGen(t: *NistTracker, c: *const NistCurveParams) !void { + // G is a compile-time constant, so this is the one NIST scalar-mul call + // site where a fixed-base comb applies. `p256Mul` / `p384Mul` cannot use it: + // their base arrives at run time. + if (t.opts.fixed_base_comb) { + if (try emitCombBest(t, c)) return; + } + + const point_bytes = c.coord_bytes * 2; + const g_point = try t.allocator.alloc(u8, point_bytes); + @memcpy(g_point[0..c.coord_bytes], c.gen_x_be); + @memcpy(g_point[c.coord_bytes..point_bytes], c.gen_y_be); + try t.pushOwnedBytes("_pt", g_point); + try t.swap(); + try emitScalarMulOnTracker(t, c); +} + +fn emitNegate(t: *NistTracker, c: *const NistCurveParams) !void { + try t.poolConstant(POOL_FIELD_P, c.field_p_be); + try decomposePoint(t, c, "_pt", "_nx", "_ny"); + try pushFieldP(t, "_fp", c.field_p_be); + try fieldSub(t, "_fp", "_ny", c.field_p_be, "_neg_y"); + try composePoint(t, c, "_nx", "_neg_y", "_result"); + try t.releaseConstant(POOL_FIELD_P); +} + +fn emitOnCurve(t: *NistTracker, c: *const NistCurveParams) !void { + const p_be = c.field_p_be; + try t.poolConstant(POOL_FIELD_P, p_be); + try decomposePoint(t, c, "_pt", "_x", "_y"); + try emitCanonicityGuard(t, "_x", "_y", p_be); + + // lhs = y^2 + try fieldSqr(t, "_y", p_be, "_y2"); + + // rhs = x^3 - 3x + b + try t.copyToTop("_x", "_x_copy"); + try t.copyToTop("_x", "_x_copy2"); + try fieldSqr(t, "_x", p_be, "_x2"); + try fieldMul(t, "_x2", "_x_copy", p_be, "_x3"); + try fieldMulConst(t, "_x_copy2", 3, p_be, "_3x"); + try fieldSub(t, "_x3", "_3x", p_be, "_x3m3x"); + try pushBigIntBE(t, "_b", c.curve_b_be); + try fieldAdd(t, "_x3m3x", "_b", p_be, "_rhs"); + + try t.toTop("_y2"); + try t.toTop("_rhs"); + t.popNames(2); + try t.emitOpcode("OP_EQUAL"); + try t.pushTracked("_curve_eq", .unknown); + + // on-curve = canonical AND curve-equation + try t.toTop("_canon"); + try t.toTop("_curve_eq"); + t.popNames(2); + try t.emitOpcode("OP_BOOLAND"); + try t.pushTracked("_result", .unknown); + try t.releaseConstant(POOL_FIELD_P); +} + +/// Point compression. No field arithmetic, so no flag reaches it — the three +/// options leave this emitter byte-identical, as they do in the reference. +fn emitEncodeCompressed(t: *NistTracker, c: *const NistCurveParams) !void { + // Split at coord_bytes: [x_bytes, y_bytes] + try t.toTop("_pt"); + t.popNames(1); + try t.emitPushIntRaw(@intCast(c.coord_bytes)); + try t.emitOpcode("OP_SPLIT"); + try t.pushTracked("_x_bytes", .unknown); + try t.pushTracked("_y_bytes", .unknown); + // Get last byte of y for parity + try t.toTop("_y_bytes"); + t.popNames(1); + try t.emitOpcode("OP_SIZE"); + try t.emitPushIntRaw(1); + try t.emitOpcode("OP_SUB"); + try t.emitOpcode("OP_SPLIT"); + try t.pushTracked("_y_prefix", .unknown); + try t.pushTracked("_last_byte", .unknown); + // Parity + try t.toTop("_last_byte"); + t.popNames(1); + try t.emitOpcode("OP_BIN2NUM"); + try t.emitPushIntRaw(2); + try t.emitOpcode("OP_MOD"); + try t.pushTracked("_parity", .unknown); + try t.toTop("_y_prefix"); + try t.drop(); + // [x_bytes, parity] + const then_ops = try t.allocator.dupe(StackOp, &.{StackOp{ .push = .{ .bytes = &.{0x03} } }}); + errdefer t.allocator.free(then_ops); + const else_ops = try t.allocator.dupe(StackOp, &.{StackOp{ .push = .{ .bytes = &.{0x02} } }}); + errdefer t.allocator.free(else_ops); + try t.toTop("_parity"); + t.popNames(1); + try t.emitRaw(.{ .@"if" = .{ .then = then_ops, .@"else" = else_ops } }); + try t.pushTracked("_prefix", .unknown); + // [x_bytes, prefix] -> swap -> prefix || x_bytes + try t.swap(); + t.popNames(2); + try t.emitOpcode("OP_CAT"); + try t.pushTracked("_result", .unknown); +} pub fn buildBuiltinOps(allocator: Allocator, builtin: registry.CryptoBuiltin) !EcOpBundle { - switch (builtin) { - .verify_ecdsa_p256 => { - var t = try NistTracker.init(allocator, &.{ "_msg", "_sig", "_pk" }, &p256_params); - errdefer t.deinit(); - try emitVerifyECDSA(&t); - return t.takeBundle(); - }, - .p256_add => { - var t = try NistTracker.init(allocator, &.{ "_pa", "_pb" }, &p256_params); - errdefer t.deinit(); - try decomposePoint(&t, "_pa", "px", "py"); - try decomposePoint(&t, "_pb", "qx", "qy"); - try affineAdd(&t, p256_field_p_be[0..]); - try composePoint(&t, "rx", "ry", "_result"); - return t.takeBundle(); - }, - .p256_mul => { - var t = try NistTracker.init(allocator, &.{ "_pt", "_k" }, &p256_params); - errdefer t.deinit(); - try emitScalarMulOnTracker(&t); - return t.takeBundle(); - }, - .p256_mul_gen => { - var t = try NistTracker.init(allocator, &.{"_k"}, &p256_params); - errdefer t.deinit(); - const g_point = try allocator.alloc(u8, 64); - @memcpy(g_point[0..32], p256_gx_be[0..]); - @memcpy(g_point[32..64], p256_gy_be[0..]); - try t.pushOwnedBytes("_pt", g_point); - try t.swap(); - try emitScalarMulOnTracker(&t); - return t.takeBundle(); - }, - .p256_negate => { - var t = try NistTracker.init(allocator, &.{"_pt"}, &p256_params); - errdefer t.deinit(); - try decomposePoint(&t, "_pt", "_nx", "_ny"); - try t.pushBigIntBE("_fp", p256_field_p_be[0..]); - try fieldSub(&t, "_fp", "_ny", p256_field_p_be[0..], "_neg_y"); - try composePoint(&t, "_nx", "_neg_y", "_result"); - return t.takeBundle(); - }, - .p256_on_curve => { - var t = try NistTracker.init(allocator, &.{"_pt"}, &p256_params); - errdefer t.deinit(); - try decomposePoint(&t, "_pt", "_x", "_y"); - try emitCanonicityGuard(&t, "_x", "_y", p256_field_p_be[0..]); - try fieldSqr(&t, "_y", p256_field_p_be[0..], "_y2"); - try t.copyToTop("_x", "_x_copy"); - try t.copyToTop("_x", "_x_copy2"); - try fieldSqr(&t, "_x", p256_field_p_be[0..], "_x2"); - try fieldMul(&t, "_x2", "_x_copy", p256_field_p_be[0..], "_x3"); - try fieldMulConst(&t, "_x_copy2", 3, p256_field_p_be[0..], "_3x"); - try fieldSub(&t, "_x3", "_3x", p256_field_p_be[0..], "_x3m3x"); - try t.pushBigIntBE("_b", p256_b_be[0..]); - try fieldAdd(&t, "_x3m3x", "_b", p256_field_p_be[0..], "_rhs"); - try t.toTop("_y2"); - try t.toTop("_rhs"); - t.popNames(2); - try t.emitOpcode("OP_EQUAL"); - try t.names.append(t.allocator, "_curve_eq"); - // on-curve = canonical AND curve-equation - try t.toTop("_canon"); - try t.toTop("_curve_eq"); - t.popNames(2); - try t.emitOpcode("OP_BOOLAND"); - try t.names.append(t.allocator, "_result"); - return t.takeBundle(); - }, - .p256_encode_compressed => { - var t = try NistTracker.init(allocator, &.{"_pt"}, &p256_params); - errdefer t.deinit(); - // Split at 32: [x_bytes, y_bytes] - try t.toTop("_pt"); - t.popNames(1); - try t.emitPushInt(32); - try t.emitOpcode("OP_SPLIT"); - try t.names.append(t.allocator, "_x_bytes"); - try t.names.append(t.allocator, "_y_bytes"); - // Get last byte of y for parity - try t.toTop("_y_bytes"); - t.popNames(1); - try t.emitOpcode("OP_SIZE"); - try t.emitPushInt(1); - try t.emitOpcode("OP_SUB"); - try t.emitOpcode("OP_SPLIT"); - try t.names.append(t.allocator, "_y_prefix"); - try t.names.append(t.allocator, "_last_byte"); - // Parity - try t.toTop("_last_byte"); - t.popNames(1); - try t.emitOpcode("OP_BIN2NUM"); - try t.emitPushInt(2); - try t.emitOpcode("OP_MOD"); - try t.names.append(t.allocator, "_parity"); - try t.toTop("_y_prefix"); - try t.drop(); - // [x_bytes, parity] - const then_ops = try t.allocator.dupe(StackOp, &.{StackOp{ .push = .{ .bytes = &.{0x03} } }}); - errdefer t.allocator.free(then_ops); - const else_ops = try t.allocator.dupe(StackOp, &.{StackOp{ .push = .{ .bytes = &.{0x02} } }}); - errdefer t.allocator.free(else_ops); - try t.toTop("_parity"); - t.popNames(1); - try t.emitRaw(.{ .@"if" = .{ .then = then_ops, .@"else" = else_ops } }); - try t.names.append(t.allocator, "_prefix"); - // [x_bytes, prefix] -> swap -> prefix || x_bytes - try t.swap(); - t.popNames(2); - try t.emitOpcode("OP_CAT"); - try t.names.append(t.allocator, "_result"); - return t.takeBundle(); - }, - // P-384 - .verify_ecdsa_p384 => { - var t = try NistTracker.init(allocator, &.{ "_msg", "_sig", "_pk" }, &p384_params); - errdefer t.deinit(); - try emitVerifyECDSA(&t); - return t.takeBundle(); - }, - .p384_add => { - var t = try NistTracker.init(allocator, &.{ "_pa", "_pb" }, &p384_params); - errdefer t.deinit(); - try decomposePoint(&t, "_pa", "px", "py"); - try decomposePoint(&t, "_pb", "qx", "qy"); - try affineAdd(&t, p384_field_p_be[0..]); - try composePoint(&t, "rx", "ry", "_result"); - return t.takeBundle(); - }, - .p384_mul => { - var t = try NistTracker.init(allocator, &.{ "_pt", "_k" }, &p384_params); - errdefer t.deinit(); - try emitScalarMulOnTracker(&t); - return t.takeBundle(); - }, - .p384_mul_gen => { - var t = try NistTracker.init(allocator, &.{"_k"}, &p384_params); - errdefer t.deinit(); - const g_point = try allocator.alloc(u8, 96); - @memcpy(g_point[0..48], p384_gx_be[0..]); - @memcpy(g_point[48..96], p384_gy_be[0..]); - try t.pushOwnedBytes("_pt", g_point); - try t.swap(); - try emitScalarMulOnTracker(&t); - return t.takeBundle(); - }, - .p384_negate => { - var t = try NistTracker.init(allocator, &.{"_pt"}, &p384_params); - errdefer t.deinit(); - try decomposePoint(&t, "_pt", "_nx", "_ny"); - try t.pushBigIntBE("_fp", p384_field_p_be[0..]); - try fieldSub(&t, "_fp", "_ny", p384_field_p_be[0..], "_neg_y"); - try composePoint(&t, "_nx", "_neg_y", "_result"); - return t.takeBundle(); - }, - .p384_on_curve => { - var t = try NistTracker.init(allocator, &.{"_pt"}, &p384_params); - errdefer t.deinit(); - try decomposePoint(&t, "_pt", "_x", "_y"); - try emitCanonicityGuard(&t, "_x", "_y", p384_field_p_be[0..]); - try fieldSqr(&t, "_y", p384_field_p_be[0..], "_y2"); - try t.copyToTop("_x", "_x_copy"); - try t.copyToTop("_x", "_x_copy2"); - try fieldSqr(&t, "_x", p384_field_p_be[0..], "_x2"); - try fieldMul(&t, "_x2", "_x_copy", p384_field_p_be[0..], "_x3"); - try fieldMulConst(&t, "_x_copy2", 3, p384_field_p_be[0..], "_3x"); - try fieldSub(&t, "_x3", "_3x", p384_field_p_be[0..], "_x3m3x"); - try t.pushBigIntBE("_b", p384_b_be[0..]); - try fieldAdd(&t, "_x3m3x", "_b", p384_field_p_be[0..], "_rhs"); - try t.toTop("_y2"); - try t.toTop("_rhs"); - t.popNames(2); - try t.emitOpcode("OP_EQUAL"); - try t.names.append(t.allocator, "_curve_eq"); - // on-curve = canonical AND curve-equation - try t.toTop("_canon"); - try t.toTop("_curve_eq"); - t.popNames(2); - try t.emitOpcode("OP_BOOLAND"); - try t.names.append(t.allocator, "_result"); - return t.takeBundle(); - }, - .p384_encode_compressed => { - var t = try NistTracker.init(allocator, &.{"_pt"}, &p384_params); - errdefer t.deinit(); - // Split at 48: [x_bytes, y_bytes] - try t.toTop("_pt"); - t.popNames(1); - try t.emitPushInt(48); - try t.emitOpcode("OP_SPLIT"); - try t.names.append(t.allocator, "_x_bytes"); - try t.names.append(t.allocator, "_y_bytes"); - // Get last byte of y for parity - try t.toTop("_y_bytes"); - t.popNames(1); - try t.emitOpcode("OP_SIZE"); - try t.emitPushInt(1); - try t.emitOpcode("OP_SUB"); - try t.emitOpcode("OP_SPLIT"); - try t.names.append(t.allocator, "_y_prefix"); - try t.names.append(t.allocator, "_last_byte"); - // Parity - try t.toTop("_last_byte"); - t.popNames(1); - try t.emitOpcode("OP_BIN2NUM"); - try t.emitPushInt(2); - try t.emitOpcode("OP_MOD"); - try t.names.append(t.allocator, "_parity"); - try t.toTop("_y_prefix"); - try t.drop(); - // [x_bytes, parity] - const then_ops = try t.allocator.dupe(StackOp, &.{StackOp{ .push = .{ .bytes = &.{0x03} } }}); - errdefer t.allocator.free(then_ops); - const else_ops = try t.allocator.dupe(StackOp, &.{StackOp{ .push = .{ .bytes = &.{0x02} } }}); - errdefer t.allocator.free(else_ops); - try t.toTop("_parity"); - t.popNames(1); - try t.emitRaw(.{ .@"if" = .{ .then = then_ops, .@"else" = else_ops } }); - try t.names.append(t.allocator, "_prefix"); - // [x_bytes, prefix] -> swap -> prefix || x_bytes - try t.swap(); - t.popNames(2); - try t.emitOpcode("OP_CAT"); - try t.names.append(t.allocator, "_result"); - return t.takeBundle(); - }, + return buildBuiltinOpsOpts(allocator, builtin, .{}); +} + +/// Render the comb at one window width, for the width-selection test. +/// +/// The emitter picks `w` by rendering every candidate and keeping the smallest; +/// this exposes a single candidate so the test can pin WHICH width wins rather +/// than only that the total matches. +pub fn buildCombProbeForTest(allocator: Allocator, p384: bool, w: usize) !EcOpBundle { + const c: *const NistCurveParams = if (p384) &p384_params else &p256_params; + var t = try NistTracker.initOpts(allocator, &.{"_k"}, .{ + .constant_pool = true, + .reduction_sinking = true, + .fixed_base_comb = true, + }, null); + errdefer t.deinit(); + _ = try emitCombMulGen(&t, c, w); + return t.takeBundle(); +} + +/// `buildBuiltinOps` with the EXPERIMENTAL EC script-size options. +/// +/// An all-false value keeps every emitter byte-identical to the shipping output; +/// see `ec_emitters.EcCodegenOptions` and +/// docs/experiments/script-size-optimizer-results.md. +pub fn buildBuiltinOpsOpts( + allocator: Allocator, + builtin: registry.CryptoBuiltin, + opts: EcCodegenOptions, +) !EcOpBundle { + const c: *const NistCurveParams = switch (builtin) { + .verify_ecdsa_p256, + .p256_add, + .p256_mul, + .p256_mul_gen, + .p256_negate, + .p256_on_curve, + .p256_encode_compressed, + => &p256_params, + .verify_ecdsa_p384, + .p384_add, + .p384_mul, + .p384_mul_gen, + .p384_negate, + .p384_on_curve, + .p384_encode_compressed, + => &p384_params, else => return error.UnsupportedBuiltin, + }; + + const initial: []const ?[]const u8 = switch (builtin) { + .verify_ecdsa_p256, .verify_ecdsa_p384 => &.{ "_msg", "_sig", "_pk" }, + .p256_add, .p384_add => &.{ "_pa", "_pb" }, + .p256_mul, .p384_mul => &.{ "_pt", "_k" }, + .p256_mul_gen, .p384_mul_gen => &.{"_k"}, + else => &.{"_pt"}, + }; + + var t = try NistTracker.initOpts(allocator, initial, opts, null); + errdefer t.deinit(); + + switch (builtin) { + .verify_ecdsa_p256, .verify_ecdsa_p384 => try emitVerifyECDSA(&t, c), + .p256_add, .p384_add => try emitAdd(&t, c), + .p256_mul, .p384_mul => try emitScalarMulOnTracker(&t, c), + .p256_mul_gen, .p384_mul_gen => try emitMulGen(&t, c), + .p256_negate, .p384_negate => try emitNegate(&t, c), + .p256_on_curve, .p384_on_curve => try emitOnCurve(&t, c), + .p256_encode_compressed, .p384_encode_compressed => try emitEncodeCompressed(&t, c), + else => unreachable, } + + return t.takeBundle(); } // =========================================================================== diff --git a/compilers/zig/src/passes/stack_lower.zig b/compilers/zig/src/passes/stack_lower.zig index 679790b47..30e285c05 100644 --- a/compilers/zig/src/passes/stack_lower.zig +++ b/compilers/zig/src/passes/stack_lower.zig @@ -228,6 +228,11 @@ const LowerCtx = struct { renamed_params: std.StringHashMapUnmanaged([]const u8), /// Current ANF binding's source location — set before processing each binding. current_source_loc: ?types.SourceLocation = null, + /// EXPERIMENTAL EC size options (constant pool, sign lattice / reduction + /// sinking, fixed-base comb), handed down to the EC emitters. All-false — + /// the default — makes them take their untouched path, so the emitted bytes + /// are provably identical to the shipping ones. + ec_opts: ec_emitters.EcCodegenOptions = .{}, fn init(allocator: Allocator, program: types.ANFProgram) LowerCtx { return .{ @@ -2045,7 +2050,7 @@ const LowerCtx = struct { _ = self.stack.pop(); } - var bundle = ec_emitters.buildBuiltinOps(self.allocator, builtin) catch |err| switch (err) { + var bundle = ec_emitters.buildBuiltinOpsOpts(self.allocator, builtin, self.ec_opts) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.UnsupportedBuiltin => return error.InvalidBuiltin, else => return error.UnsupportedOperation, @@ -2070,7 +2075,7 @@ const LowerCtx = struct { _ = self.stack.pop(); } - var bundle = nist_ec_emitters.buildBuiltinOps(self.allocator, builtin) catch |err| switch (err) { + var bundle = nist_ec_emitters.buildBuiltinOpsOpts(self.allocator, builtin, self.ec_opts) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => return error.UnsupportedOperation, }; @@ -4120,6 +4125,7 @@ const LowerCtx = struct { then_ctx.force_copy_bindings = try cloneVoidMap(self.allocator, self.force_copy_bindings); then_ctx.in_branch = true; then_ctx.copy_ref_aliases = self.copy_ref_aliases; + then_ctx.ec_opts = self.ec_opts; then_ctx.max_depth = self.max_depth; then_ctx.outer_protected_refs = &protected_refs; try then_ctx.lowerBindings(ie.then_bindings, terminal_assert); @@ -4140,6 +4146,7 @@ const LowerCtx = struct { else_ctx.force_copy_bindings = try cloneVoidMap(self.allocator, self.force_copy_bindings); else_ctx.in_branch = true; else_ctx.copy_ref_aliases = self.copy_ref_aliases; + else_ctx.ec_opts = self.ec_opts; else_ctx.max_depth = self.max_depth; else_ctx.outer_protected_refs = &protected_refs; const else_bindings = ie.else_bindings orelse &.{}; @@ -5098,6 +5105,19 @@ const check_preimage_sighash_tail = "7e210279be667ef9dcbbac55a06295ce870b07029bf /// `OP_DUP, OP_NOT, OP_IF, OP_DROP` (which is semantically equivalent /// but byte-divergent from the canonical TS output). pub fn lower(allocator: Allocator, program: types.ANFProgram) !types.StackProgram { + return lowerOpts(allocator, program, .{}); +} + +/// `lower` with the EXPERIMENTAL EC script-size options. +/// +/// An all-false value keeps every EC emitter byte-identical to the shipping +/// output; see `ec_emitters.EcCodegenOptions` and +/// docs/experiments/script-size-optimizer-results.md. +pub fn lowerOpts( + allocator: Allocator, + program: types.ANFProgram, + ec_opts: ec_emitters.EcCodegenOptions, +) !types.StackProgram { var methods = std.ArrayListUnmanaged(types.StackMethod).empty; defer methods.deinit(allocator); var owned_push_data = std.ArrayListUnmanaged([]u8).empty; @@ -5111,6 +5131,7 @@ pub fn lower(allocator: Allocator, program: types.ANFProgram) !types.StackProgra try setupMethodStack(&ctx, program, method); ctx.copy_ref_aliases = false; + ctx.ec_opts = ec_opts; // Use body or bindings (whichever is populated) const bindings = if (method.body.len > 0) method.body else method.bindings; diff --git a/compilers/zig/src/test_main.zig b/compilers/zig/src/test_main.zig index afee7d9da..26397d8ea 100644 --- a/compilers/zig/src/test_main.zig +++ b/compilers/zig/src/test_main.zig @@ -57,6 +57,7 @@ test { _ = @import("passes/helpers/sha256_emitters.zig"); _ = @import("passes/helpers/blake3_emitters.zig"); _ = @import("passes/helpers/ec_emitters.zig"); + _ = @import("passes/helpers/ec_flag_parity_test.zig"); _ = @import("passes/helpers/pq_emitters.zig"); _ = @import("passes/helpers/crypto_emitters.zig"); _ = @import("passes/helpers/rabin_emitter.zig"); diff --git a/conformance/ec-flag-parity/README.md b/conformance/ec-flag-parity/README.md new file mode 100644 index 000000000..26c85d0b1 --- /dev/null +++ b/conformance/ec-flag-parity/README.md @@ -0,0 +1,60 @@ +# EC codegen flag parity + +`expected.json` pins, for every EC / NIST-curve emitter and every combination of +the experimental size flags, the **exact serialized script** the TypeScript +reference compiler produces — as a byte count plus a SHA-256 of the script +bytes. + +## Why this file exists + +The size optimizations (`constantPool`, `reductionSinking`, `fixedBaseComb`) +default OFF, so the ordinary cross-tier conformance suite — which compiles with +defaults — cannot see them at all. Seven tiers could each ship a *different* +`--ec-constant-pool` and the suite would stay green. + +That matters because the flags are not cosmetic: they change which reduction +form is emitted and which addition formula each ladder round uses. A tier that +ports the constant pool but not the sign lattice's `Reduced` precondition +produces a script that is smaller, passes its own tests, and is wrong on +`ecAdd((0,1), (2^256-1,1))`. Byte-identical output against a single reference is +the only cheap check that catches that. + +## Regenerating + +`expected.json` is derived, never hand-edited: + +``` +npm run --prefix conformance ec-flag-parity:generate +``` + +`ec-flag-parity.test.ts` re-derives it in-process and fails if the checked-in +file has drifted, so a deliberate codegen change shows up as a fixture diff to +review rather than as a silently stale pin. + +## Consuming it from a tier + +Each compiler's test suite reads this file and asserts that its own emitters, +under the same flags, hash to the same value. See +`compilers/go/codegen/ec_flag_parity_test.go` for the reference consumer. + +## Raw vs post-peephole + +Each entry carries two measurements: + +- the top-level `bytes` / `sha256` — the **raw emitter output**, before the + peephole pass; +- `postPeephole` — the same script after `optimizeStackIR`, i.e. what the + compiler actually ships. + +Six tiers reproduce the raw output op for op, so they assert the raw SHA-256: +the sharpest gate available. The **Zig tier cannot**, in exactly one place. +`emitEcMul` there emits `k + 3n` pre-folded, because that tier's peephole +reassociates only `i64` `push_int` chains (`peephole.zig` rule 27) and a 256-bit +constant is a `push_data` blob in Zig's IR. The reference emits three `+n` steps +that its own peephole collapses to the same thing — identical shipped bytes, +different pre-peephole spelling. + +So the Zig consumer gates on the raw byte COUNT with that one divergence +asserted exactly (`ec_flag_parity_test.zig#allowedDelta`), and whole-script byte +identity is covered end to end by compiling the same contract through the Zig +CLI and the TypeScript one and diffing the hex. diff --git a/conformance/ec-flag-parity/expected.json b/conformance/ec-flag-parity/expected.json new file mode 100644 index 000000000..e5c94181c --- /dev/null +++ b/conformance/ec-flag-parity/expected.json @@ -0,0 +1,835 @@ +{ + "variants": { + "off": {}, + "pool": { + "constantPool": true + }, + "sink": { + "constantPool": true, + "reductionSinking": true + }, + "comb": { + "constantPool": true, + "reductionSinking": true, + "fixedBaseComb": true + } + }, + "emitters": { + "EcAdd": { + "off": { + "bytes": 25426, + "sha256": "3a6a3250b87bc980734f059d0691a7618301842b2da5d6c9811bdd378d6d2ee1", + "postPeephole": { + "bytes": 24398, + "sha256": "f957169ddbee238e716e293209b277e887688d0adaff088a06c192c80a72bb62" + } + }, + "pool": { + "bytes": 8791, + "sha256": "273c9c2648bee5175f1b83f54ac3d1996428a728334e00f6e1b3be1357b8740b", + "postPeephole": { + "bytes": 7763, + "sha256": "7b41cd07e70aa0e3ec21fa82758183ddac89a7b3f05b3a6b352127171c443193" + } + }, + "sink": { + "bytes": 5202, + "sha256": "5e2c39c383a0da549291d17a80daec4a74d148b360faf8b09a2fd921ff71a6ec", + "postPeephole": { + "bytes": 4174, + "sha256": "eb3cb8886c8ad61dd2efe96ea8e59790cd8283b9eb8463d87533556e8df2a37f" + } + }, + "comb": { + "bytes": 5202, + "sha256": "5e2c39c383a0da549291d17a80daec4a74d148b360faf8b09a2fd921ff71a6ec", + "postPeephole": { + "bytes": 4174, + "sha256": "eb3cb8886c8ad61dd2efe96ea8e59790cd8283b9eb8463d87533556e8df2a37f" + } + } + }, + "EcMul": { + "off": { + "bytes": 428676, + "sha256": "8097e08786504e28c896317c0ee46b18e9280395625017eb74a7fca7286d18cb", + "postPeephole": { + "bytes": 424501, + "sha256": "ea17ad4c4c08bd598b64ff8008cfd80aa1b3c1ea8cfd03f120f056798c3b1e89" + } + }, + "pool": { + "bytes": 140242, + "sha256": "3f4dfaee63080e019a16743f6aeb8f03f6479eecdfaf091993667a008920c11e", + "postPeephole": { + "bytes": 136137, + "sha256": "e05ae80a1b0d5b683dab6ba0da72f6a9a7eb73e2fd2dda42194e7a78a3782be4" + } + }, + "sink": { + "bytes": 84137, + "sha256": "219719829cbefd9ca336f9f78231021d38769aca243890a03333b156525fb64a", + "postPeephole": { + "bytes": 80032, + "sha256": "164cbf3cfc0e17dc4c2a3d45cb9b53302fa3c0919cbd86b0d89f42062213af2d" + } + }, + "comb": { + "bytes": 84137, + "sha256": "219719829cbefd9ca336f9f78231021d38769aca243890a03333b156525fb64a", + "postPeephole": { + "bytes": 80032, + "sha256": "164cbf3cfc0e17dc4c2a3d45cb9b53302fa3c0919cbd86b0d89f42062213af2d" + } + } + }, + "EcMulGen": { + "off": { + "bytes": 428742, + "sha256": "214f3136c4713c0f9bc8e366b81ac235c09dfdfe52d00a3ac04942b5ae8b47cc", + "postPeephole": { + "bytes": 424565, + "sha256": "4e53228ab5469c0d0e96db04c73f5c2129658b5dd8450cb8a11dfe94169c8f64" + } + }, + "pool": { + "bytes": 140308, + "sha256": "997261a65d5c4b5da4d06f1f3a6d9ebc13a07b5a8545bb19634b33afd66f3a91", + "postPeephole": { + "bytes": 136203, + "sha256": "94cd1778fc2309e5efae2f3c36e13a38a0a53f8513450c289b164529f213294c" + } + }, + "sink": { + "bytes": 84203, + "sha256": "192e66df05ba81d5fbc11b1019e08ff2f7b7c70a9970e3f27410922e757fee90", + "postPeephole": { + "bytes": 80098, + "sha256": "48f7dc497c4a877ba730b161c4dcfcd368df0259a2e5d7c568a754258d96c506" + } + }, + "comb": { + "bytes": 52237, + "sha256": "17fcf22f1ebb6cf752de3be937cd183202aedf271937ec6919e14686a029d18d", + "postPeephole": { + "bytes": 50155, + "sha256": "ca9bcb58ece58025dde6f727c603d6e6d9ede172c6c57d4148098e1ec9a2bccb" + } + } + }, + "EcNegate": { + "off": { + "bytes": 1018, + "sha256": "18e405c44216a1f1b927f16f6aac869e11b8fd65ef15265496071b37bf07f9d9", + "postPeephole": { + "bytes": 1016, + "sha256": "fcf10cefc7558c5e7ab1ff5882ca8c128c72f5fe3411c24518b69cbab92ba6b8" + } + }, + "pool": { + "bytes": 991, + "sha256": "0c88dbabadba86e3a46195845c65c6a998647bfa708529457b8caaa6fece49b6", + "postPeephole": { + "bytes": 989, + "sha256": "2a5a653594760bd00f44940a0d3fec3a4b59d110376c169435f1ab0897eb0ed8" + } + }, + "sink": { + "bytes": 991, + "sha256": "0c88dbabadba86e3a46195845c65c6a998647bfa708529457b8caaa6fece49b6", + "postPeephole": { + "bytes": 989, + "sha256": "2a5a653594760bd00f44940a0d3fec3a4b59d110376c169435f1ab0897eb0ed8" + } + }, + "comb": { + "bytes": 991, + "sha256": "0c88dbabadba86e3a46195845c65c6a998647bfa708529457b8caaa6fece49b6", + "postPeephole": { + "bytes": 989, + "sha256": "2a5a653594760bd00f44940a0d3fec3a4b59d110376c169435f1ab0897eb0ed8" + } + } + }, + "EcOnCurve": { + "off": { + "bytes": 734, + "sha256": "5adf6468d3637a6eb9e04f3d53c1e0d4068db875328370b0922111eb12afaa46", + "postPeephole": { + "bytes": 726, + "sha256": "26d924858e905789a289855370fd445b96ed1d9e5cb20affeca5f75eb4a7397f" + } + }, + "pool": { + "bytes": 579, + "sha256": "9102df0d39cd6ef42af732ced445c0b9258df6fb531c7db6dbd7de32b23cf28a", + "postPeephole": { + "bytes": 571, + "sha256": "99c3d6f30e254e158dfc25465c0383d3a567a04b01c3ab634659496931755e24" + } + }, + "sink": { + "bytes": 551, + "sha256": "60d2cb607c2e5544538855ffc78eb0b5efa3f7069f64013d3e12dc8684c13e5b", + "postPeephole": { + "bytes": 543, + "sha256": "1acb61a23eb1070dbea86dad7a0d1d7db29470e8a2a0462d048fcdcff3e5e84c" + } + }, + "comb": { + "bytes": 551, + "sha256": "60d2cb607c2e5544538855ffc78eb0b5efa3f7069f64013d3e12dc8684c13e5b", + "postPeephole": { + "bytes": 543, + "sha256": "1acb61a23eb1070dbea86dad7a0d1d7db29470e8a2a0462d048fcdcff3e5e84c" + } + } + }, + "EcModReduce": { + "off": { + "bytes": 8, + "sha256": "67859e92455ce4ed0de89a585ca093710793638b47de173ae6697037f9873939", + "postPeephole": { + "bytes": 8, + "sha256": "67859e92455ce4ed0de89a585ca093710793638b47de173ae6697037f9873939" + } + }, + "pool": { + "bytes": 8, + "sha256": "67859e92455ce4ed0de89a585ca093710793638b47de173ae6697037f9873939", + "postPeephole": { + "bytes": 8, + "sha256": "67859e92455ce4ed0de89a585ca093710793638b47de173ae6697037f9873939" + } + }, + "sink": { + "bytes": 8, + "sha256": "67859e92455ce4ed0de89a585ca093710793638b47de173ae6697037f9873939", + "postPeephole": { + "bytes": 8, + "sha256": "67859e92455ce4ed0de89a585ca093710793638b47de173ae6697037f9873939" + } + }, + "comb": { + "bytes": 8, + "sha256": "67859e92455ce4ed0de89a585ca093710793638b47de173ae6697037f9873939", + "postPeephole": { + "bytes": 8, + "sha256": "67859e92455ce4ed0de89a585ca093710793638b47de173ae6697037f9873939" + } + } + }, + "EcEncodeCompressed": { + "off": { + "bytes": 19, + "sha256": "aa8fd739d3c76679bb7e93c22c1d4fcc7946b9fb3c86b92b9c06d67c9df48f72", + "postPeephole": { + "bytes": 18, + "sha256": "f1a86d1b3b82c245a69a39f8cd5b7684c17db4aef6e4e06430aa18ed862c3634" + } + }, + "pool": { + "bytes": 19, + "sha256": "aa8fd739d3c76679bb7e93c22c1d4fcc7946b9fb3c86b92b9c06d67c9df48f72", + "postPeephole": { + "bytes": 18, + "sha256": "f1a86d1b3b82c245a69a39f8cd5b7684c17db4aef6e4e06430aa18ed862c3634" + } + }, + "sink": { + "bytes": 19, + "sha256": "aa8fd739d3c76679bb7e93c22c1d4fcc7946b9fb3c86b92b9c06d67c9df48f72", + "postPeephole": { + "bytes": 18, + "sha256": "f1a86d1b3b82c245a69a39f8cd5b7684c17db4aef6e4e06430aa18ed862c3634" + } + }, + "comb": { + "bytes": 19, + "sha256": "aa8fd739d3c76679bb7e93c22c1d4fcc7946b9fb3c86b92b9c06d67c9df48f72", + "postPeephole": { + "bytes": 18, + "sha256": "f1a86d1b3b82c245a69a39f8cd5b7684c17db4aef6e4e06430aa18ed862c3634" + } + } + }, + "EcMakePoint": { + "off": { + "bytes": 471, + "sha256": "2386c6124c0ae7aeec1d1c583f867595a76e9f226b91a9d046c1bf2584d85ef7", + "postPeephole": { + "bytes": 471, + "sha256": "2386c6124c0ae7aeec1d1c583f867595a76e9f226b91a9d046c1bf2584d85ef7" + } + }, + "pool": { + "bytes": 471, + "sha256": "2386c6124c0ae7aeec1d1c583f867595a76e9f226b91a9d046c1bf2584d85ef7", + "postPeephole": { + "bytes": 471, + "sha256": "2386c6124c0ae7aeec1d1c583f867595a76e9f226b91a9d046c1bf2584d85ef7" + } + }, + "sink": { + "bytes": 471, + "sha256": "2386c6124c0ae7aeec1d1c583f867595a76e9f226b91a9d046c1bf2584d85ef7", + "postPeephole": { + "bytes": 471, + "sha256": "2386c6124c0ae7aeec1d1c583f867595a76e9f226b91a9d046c1bf2584d85ef7" + } + }, + "comb": { + "bytes": 471, + "sha256": "2386c6124c0ae7aeec1d1c583f867595a76e9f226b91a9d046c1bf2584d85ef7", + "postPeephole": { + "bytes": 471, + "sha256": "2386c6124c0ae7aeec1d1c583f867595a76e9f226b91a9d046c1bf2584d85ef7" + } + } + }, + "EcPointX": { + "off": { + "bytes": 235, + "sha256": "565258cd99b1f3f0e742aa11d2febbb7dba0696498c6e2241e183a7984d7deb8", + "postPeephole": { + "bytes": 235, + "sha256": "565258cd99b1f3f0e742aa11d2febbb7dba0696498c6e2241e183a7984d7deb8" + } + }, + "pool": { + "bytes": 235, + "sha256": "565258cd99b1f3f0e742aa11d2febbb7dba0696498c6e2241e183a7984d7deb8", + "postPeephole": { + "bytes": 235, + "sha256": "565258cd99b1f3f0e742aa11d2febbb7dba0696498c6e2241e183a7984d7deb8" + } + }, + "sink": { + "bytes": 235, + "sha256": "565258cd99b1f3f0e742aa11d2febbb7dba0696498c6e2241e183a7984d7deb8", + "postPeephole": { + "bytes": 235, + "sha256": "565258cd99b1f3f0e742aa11d2febbb7dba0696498c6e2241e183a7984d7deb8" + } + }, + "comb": { + "bytes": 235, + "sha256": "565258cd99b1f3f0e742aa11d2febbb7dba0696498c6e2241e183a7984d7deb8", + "postPeephole": { + "bytes": 235, + "sha256": "565258cd99b1f3f0e742aa11d2febbb7dba0696498c6e2241e183a7984d7deb8" + } + } + }, + "EcPointY": { + "off": { + "bytes": 236, + "sha256": "a19c838dd948d1a2d277623103620e99905f64664a1ed9fe1d7a0f0466a42f2b", + "postPeephole": { + "bytes": 236, + "sha256": "a19c838dd948d1a2d277623103620e99905f64664a1ed9fe1d7a0f0466a42f2b" + } + }, + "pool": { + "bytes": 236, + "sha256": "a19c838dd948d1a2d277623103620e99905f64664a1ed9fe1d7a0f0466a42f2b", + "postPeephole": { + "bytes": 236, + "sha256": "a19c838dd948d1a2d277623103620e99905f64664a1ed9fe1d7a0f0466a42f2b" + } + }, + "sink": { + "bytes": 236, + "sha256": "a19c838dd948d1a2d277623103620e99905f64664a1ed9fe1d7a0f0466a42f2b", + "postPeephole": { + "bytes": 236, + "sha256": "a19c838dd948d1a2d277623103620e99905f64664a1ed9fe1d7a0f0466a42f2b" + } + }, + "comb": { + "bytes": 236, + "sha256": "a19c838dd948d1a2d277623103620e99905f64664a1ed9fe1d7a0f0466a42f2b", + "postPeephole": { + "bytes": 236, + "sha256": "a19c838dd948d1a2d277623103620e99905f64664a1ed9fe1d7a0f0466a42f2b" + } + } + }, + "P256Add": { + "off": { + "bytes": 19906, + "sha256": "c3881056b85af5158aa022db9f35354157ba979b817d9e02af4181cb43d5cb94", + "postPeephole": { + "bytes": 19118, + "sha256": "3dafe2b8eb9a7bf49b1a050d471ce095acfed15db6a1470b62732e919d76b8b0" + } + }, + "pool": { + "bytes": 7111, + "sha256": "8d8f2fe65d2ba240bf93292a918d2727f5e77390f7dec307c274b203586af9eb", + "postPeephole": { + "bytes": 6323, + "sha256": "3993f69c93cf6f3b979e00148324b3a9ff19b240b7f3e8055a9f89f9ac2e5910" + } + }, + "sink": { + "bytes": 4369, + "sha256": "3c8f63ad72a56db6b6ec228c940a117b54fdcc1dafd84d9f3fb4348c39fd81a8", + "postPeephole": { + "bytes": 3581, + "sha256": "59c479fe3765a390eeb7d8b230fda234be5b47182174d038060626bf4fe520da" + } + }, + "comb": { + "bytes": 4369, + "sha256": "3c8f63ad72a56db6b6ec228c940a117b54fdcc1dafd84d9f3fb4348c39fd81a8", + "postPeephole": { + "bytes": 3581, + "sha256": "59c479fe3765a390eeb7d8b230fda234be5b47182174d038060626bf4fe520da" + } + } + }, + "P256Mul": { + "off": { + "bytes": 459746, + "sha256": "7012a0e15c57537d5927390586365267d94e77a5756e823c86b873bf144a4e0b", + "postPeephole": { + "bytes": 453233, + "sha256": "745067c1d414cca13079e97cc7eebbe1af4beffc88ab9aeb6bfd9d2415033529" + } + }, + "pool": { + "bytes": 150512, + "sha256": "05d4fd85f788f2ccccf7d5137fc1081f81e0b5ec75939e4a5319355590f468f7", + "postPeephole": { + "bytes": 143999, + "sha256": "53a10b7f2559636006920f994f9bee6dbcfa35f0cf56901d98f6a6ec3af8162b" + } + }, + "sink": { + "bytes": 90610, + "sha256": "5ae61e775fe01f19fc14afab34a744ed1a2ab28121cd8fa699bbd77d389b7f16", + "postPeephole": { + "bytes": 84097, + "sha256": "61846c9e6aad3fc2d1835310fc2d2b91af2e2c7826814fd8482a28e8ecac8013" + } + }, + "comb": { + "bytes": 90610, + "sha256": "5ae61e775fe01f19fc14afab34a744ed1a2ab28121cd8fa699bbd77d389b7f16", + "postPeephole": { + "bytes": 84097, + "sha256": "61846c9e6aad3fc2d1835310fc2d2b91af2e2c7826814fd8482a28e8ecac8013" + } + } + }, + "P256MulGen": { + "off": { + "bytes": 459812, + "sha256": "76602b6b20bbd1a206ca196e279d8912264d7d86f1608d0c4a1cb4170727f55c", + "postPeephole": { + "bytes": 453297, + "sha256": "2d6cd0d21543d1fa343ad1f3598c9fb1869006eca5f485898dc6e6135cd66a78" + } + }, + "pool": { + "bytes": 150578, + "sha256": "f78a0e150d1b87b10e6627ccf9a1e0ce3f3bd9177166f843d99ddd0e93238e8b", + "postPeephole": { + "bytes": 144065, + "sha256": "b83ae40a992573a00129e0651412f546f1040a4c0311d4bf82fc20a1f2949387" + } + }, + "sink": { + "bytes": 90676, + "sha256": "b0e83297c32fe4aa2edda439490b55611895e6f6bb76dc38c889858f88699fab", + "postPeephole": { + "bytes": 84163, + "sha256": "da46e6b82f89c38a6ee3c47b4d2730574d470998aa6878b842a84614666caed5" + } + }, + "comb": { + "bytes": 54117, + "sha256": "a79b973d11f57989ff14ebccf0debbf86ef1f240c799a30870b15620ef97ef51", + "postPeephole": { + "bytes": 51387, + "sha256": "bea2287f75117891b45186b7e1daee5a28afa7049c59cb3969cda4b41f43bacd" + } + } + }, + "P256Negate": { + "off": { + "bytes": 1018, + "sha256": "db96eb906a201fbdc80386afe0c924b4f014031bc248d358c3b8f7e4d1130242", + "postPeephole": { + "bytes": 1016, + "sha256": "eff7114a137869dc9c55b69112979158171ccb2882cb833365c0894f67301773" + } + }, + "pool": { + "bytes": 991, + "sha256": "857cba64c1113cab98b2501e21568d7863f4a35c80d7092db58105738a8c8ff4", + "postPeephole": { + "bytes": 989, + "sha256": "e7e02e493fe9d5942543d9594ccf3d11cf42a40b8f1ed920508157da9f0a2d87" + } + }, + "sink": { + "bytes": 991, + "sha256": "857cba64c1113cab98b2501e21568d7863f4a35c80d7092db58105738a8c8ff4", + "postPeephole": { + "bytes": 989, + "sha256": "e7e02e493fe9d5942543d9594ccf3d11cf42a40b8f1ed920508157da9f0a2d87" + } + }, + "comb": { + "bytes": 991, + "sha256": "857cba64c1113cab98b2501e21568d7863f4a35c80d7092db58105738a8c8ff4", + "postPeephole": { + "bytes": 989, + "sha256": "e7e02e493fe9d5942543d9594ccf3d11cf42a40b8f1ed920508157da9f0a2d87" + } + } + }, + "P256OnCurve": { + "off": { + "bytes": 858, + "sha256": "7514bbabd200f50c56282fd92b881b0d2ee83aa6948e525b04ae39a21f849018", + "postPeephole": { + "bytes": 848, + "sha256": "cf4765c6015aaef99e0a69c016c4032d5f8cba0185dbefc29afc50e5b90fbc9b" + } + }, + "pool": { + "bytes": 639, + "sha256": "ab722c360154cd00e97cf2b6c5fdd259f2cf718a4d6d4487cc475b3defe58f64", + "postPeephole": { + "bytes": 629, + "sha256": "b804f0289352ecaf6834ff51f7c41960096856ba215cbd17bda7e6ab77130f36" + } + }, + "sink": { + "bytes": 600, + "sha256": "e1995c440d40a680c632693485d7f9285769112a71256c76d0ce1012850f1637", + "postPeephole": { + "bytes": 590, + "sha256": "3fc2ec5339a852c520b8d514937b2474143ae7e8544b0accd13ddb45cf5eb4f4" + } + }, + "comb": { + "bytes": 600, + "sha256": "e1995c440d40a680c632693485d7f9285769112a71256c76d0ce1012850f1637", + "postPeephole": { + "bytes": 590, + "sha256": "3fc2ec5339a852c520b8d514937b2474143ae7e8544b0accd13ddb45cf5eb4f4" + } + } + }, + "P256EncodeCompressed": { + "off": { + "bytes": 19, + "sha256": "aa8fd739d3c76679bb7e93c22c1d4fcc7946b9fb3c86b92b9c06d67c9df48f72", + "postPeephole": { + "bytes": 18, + "sha256": "f1a86d1b3b82c245a69a39f8cd5b7684c17db4aef6e4e06430aa18ed862c3634" + } + }, + "pool": { + "bytes": 19, + "sha256": "aa8fd739d3c76679bb7e93c22c1d4fcc7946b9fb3c86b92b9c06d67c9df48f72", + "postPeephole": { + "bytes": 18, + "sha256": "f1a86d1b3b82c245a69a39f8cd5b7684c17db4aef6e4e06430aa18ed862c3634" + } + }, + "sink": { + "bytes": 19, + "sha256": "aa8fd739d3c76679bb7e93c22c1d4fcc7946b9fb3c86b92b9c06d67c9df48f72", + "postPeephole": { + "bytes": 18, + "sha256": "f1a86d1b3b82c245a69a39f8cd5b7684c17db4aef6e4e06430aa18ed862c3634" + } + }, + "comb": { + "bytes": 19, + "sha256": "aa8fd739d3c76679bb7e93c22c1d4fcc7946b9fb3c86b92b9c06d67c9df48f72", + "postPeephole": { + "bytes": 18, + "sha256": "f1a86d1b3b82c245a69a39f8cd5b7684c17db4aef6e4e06430aa18ed862c3634" + } + } + }, + "VerifyECDSA_P256": { + "off": { + "bytes": 974024, + "sha256": "68d0eaa9e637956cbd43d16f06534c93735a8d2ac9942467a11489fbe845c4be", + "postPeephole": { + "bytes": 958776, + "sha256": "cbd64aa2d1606103d46afe99464bcf457290314168625f7e8c2ad198c8fb817a" + } + }, + "pool": { + "bytes": 319693, + "sha256": "4baac4fcd88d7742a1ccf7e338a08b75758f0f1b89596acc41979b107cbd49c9", + "postPeephole": { + "bytes": 304447, + "sha256": "adb48182b8adc66ed48ac186b482c2a4af3ac93687ab1d22b71720e77767cbd5" + } + }, + "sink": { + "bytes": 195120, + "sha256": "99f2cee6e41172153d658d3f6335ae38d45cb376b3e56deca642f72bf2b99a5d", + "postPeephole": { + "bytes": 179874, + "sha256": "ee1bd1d458ed1a07bf6d6acb49634d765d06d1bf4e87e048c966f042e36bedae" + } + }, + "comb": { + "bytes": 158560, + "sha256": "01f821d2ef4689c79fa669560de92cd12cd9d8541654b70d352378bb9cc32667", + "postPeephole": { + "bytes": 147097, + "sha256": "1c0e54add2f683d18c82437359130786649dc5a20c66b2a550d73b5f7204ae2a" + } + } + }, + "P384Add": { + "off": { + "bytes": 46710, + "sha256": "cd5bc4214e96e61595e25a7b61d8b0d4d2102e6296f85ca541a2fdd2faba1750", + "postPeephole": { + "bytes": 45286, + "sha256": "9d19718a22a99d72fa300c78bce60026b0420b7c86fafba5e38327f0697cd343" + } + }, + "pool": { + "bytes": 12251, + "sha256": "36b56dfd7f812b9a27d43fb0bad385f73985453dda11de70f0771fc0c3b02bc6", + "postPeephole": { + "bytes": 10827, + "sha256": "1ae8420703b3e2eda885e0e74442692093af33494203067f060c92575fdd9aa5" + } + }, + "sink": { + "bytes": 7283, + "sha256": "6c94c7a8ec1bfbd79496b61a2ea7eaeda311b402b3d3f4d38243912cb83892c5", + "postPeephole": { + "bytes": 5859, + "sha256": "501b97c3f2bf56242dc22b6f75b7f0da04ace3e5cb7fe0ae264e7216f1cc1ed0" + } + }, + "comb": { + "bytes": 7283, + "sha256": "6c94c7a8ec1bfbd79496b61a2ea7eaeda311b402b3d3f4d38243912cb83892c5", + "postPeephole": { + "bytes": 5859, + "sha256": "501b97c3f2bf56242dc22b6f75b7f0da04ace3e5cb7fe0ae264e7216f1cc1ed0" + } + } + }, + "P384Mul": { + "off": { + "bytes": 927350, + "sha256": "c87ca9575963a3aa9b34a295179a7d49f4e198d972d8684408f04a3d497c0323", + "postPeephole": { + "bytes": 917353, + "sha256": "634637109530d90e3d0ddacd99303767004b8fe72a0ff6da144dcb92ca8687ba" + } + }, + "pool": { + "bytes": 227044, + "sha256": "7b33a276569d29932d0dc03583e07a92a4423d980af23d23996f4fd7bd3a9804", + "postPeephole": { + "bytes": 217047, + "sha256": "cd5a4a8f0fb62f0007655b55bf7037a6d03bcc517128e5c3301b7ddb2de7850f" + } + }, + "sink": { + "bytes": 136500, + "sha256": "ac902c1c7911bfa84d2a3058fe5d70e2fbcd76a3a4a68069d6cdf8df1bf3f0d3", + "postPeephole": { + "bytes": 126503, + "sha256": "9362a57cebd42e09fa0cf628ce3c416f92f97da63d8dc96979db2424fc8f10cd" + } + }, + "comb": { + "bytes": 136500, + "sha256": "ac902c1c7911bfa84d2a3058fe5d70e2fbcd76a3a4a68069d6cdf8df1bf3f0d3", + "postPeephole": { + "bytes": 126503, + "sha256": "9362a57cebd42e09fa0cf628ce3c416f92f97da63d8dc96979db2424fc8f10cd" + } + } + }, + "P384MulGen": { + "off": { + "bytes": 927449, + "sha256": "706e1c6fdf1d5845f50129e6274970012617cff8bc8d2b6bac88328b60ffdc17", + "postPeephole": { + "bytes": 917450, + "sha256": "ca1bd9d37005216e25dbb20290da124a76be1ffa54ff7b14d6a54134cc4a4ba4" + } + }, + "pool": { + "bytes": 227143, + "sha256": "c9a19547b52c741dd873573609d83943fcd35f998a35a3adfd0b86ee0cc478cf", + "postPeephole": { + "bytes": 217146, + "sha256": "5225fe1b97401409174e807e8810e73b00d335660c878ada683ae616ee607647" + } + }, + "sink": { + "bytes": 136599, + "sha256": "799471efa4fa9ce3e29c1e4c561e9efa9ffa30b8132c0ac80156956ea500350e", + "postPeephole": { + "bytes": 126602, + "sha256": "766123fa080ac65e22c76d084c03dbcab6c279f44ee30e2279353410e366615a" + } + }, + "comb": { + "bytes": 81418, + "sha256": "f456395d4368a0f7456896922d3f76f9a0bfa072c2e05b7c1e347cd4128f7ad6", + "postPeephole": { + "bytes": 77129, + "sha256": "8b92701526aa33fa446f7f9929d10b2a97ddbb575b717a120a1d738cfe6e8d23" + } + } + }, + "P384Negate": { + "off": { + "bytes": 1498, + "sha256": "8ba083da26607f67e606a45006db0875b8c02722e7ec96e63a262c779a628dc8", + "postPeephole": { + "bytes": 1496, + "sha256": "79da63572850aa692f3ba045504d0dc2cf20c92d98f8da433168a0494844bfa1" + } + }, + "pool": { + "bytes": 1455, + "sha256": "afadda301ddef4de732d20099366ce8b23a1420dc3f963bddb9bf6caf4534f1c", + "postPeephole": { + "bytes": 1453, + "sha256": "65e9d72d0000978b875e9ad96cd078c2e15becb45d06e63d9d45f1885eed4e99" + } + }, + "sink": { + "bytes": 1455, + "sha256": "afadda301ddef4de732d20099366ce8b23a1420dc3f963bddb9bf6caf4534f1c", + "postPeephole": { + "bytes": 1453, + "sha256": "65e9d72d0000978b875e9ad96cd078c2e15becb45d06e63d9d45f1885eed4e99" + } + }, + "comb": { + "bytes": 1455, + "sha256": "afadda301ddef4de732d20099366ce8b23a1420dc3f963bddb9bf6caf4534f1c", + "postPeephole": { + "bytes": 1453, + "sha256": "65e9d72d0000978b875e9ad96cd078c2e15becb45d06e63d9d45f1885eed4e99" + } + } + }, + "P384OnCurve": { + "off": { + "bytes": 1227, + "sha256": "2d274e9e22ec20d8d49ebf0dd55f90d0a9d27476a69eb45fc4e097dd26920be1", + "postPeephole": { + "bytes": 1217, + "sha256": "f379b94a85ff3b4107b57e0fbbadd2a3da6aabf47d63b97a517fd7ac9814eef2" + } + }, + "pool": { + "bytes": 896, + "sha256": "43c8c11c162a87796f189403239a8e960520096df9e397034cff21595f201794", + "postPeephole": { + "bytes": 886, + "sha256": "d4ab34d1d72a77246b58da779759c9c5626156be60cf23cc022a5a0bce6d7be9" + } + }, + "sink": { + "bytes": 857, + "sha256": "d1eb673d321f4a43709679783f31ce107e1686c0c8385dc9e26477d19b7eb769", + "postPeephole": { + "bytes": 847, + "sha256": "cd6c67a1a11a34c51e2a6e9c9f8eb6a8a468fe14db1b9ce4cf0ada90a140ee4f" + } + }, + "comb": { + "bytes": 857, + "sha256": "d1eb673d321f4a43709679783f31ce107e1686c0c8385dc9e26477d19b7eb769", + "postPeephole": { + "bytes": 847, + "sha256": "cd6c67a1a11a34c51e2a6e9c9f8eb6a8a468fe14db1b9ce4cf0ada90a140ee4f" + } + } + }, + "P384EncodeCompressed": { + "off": { + "bytes": 19, + "sha256": "59052424dd77c722550109df93d63de9ffe3b8fbd5769313eaa2941cfbabf06e", + "postPeephole": { + "bytes": 18, + "sha256": "6725743ec2d0a66ec361dd9350805a78485aac0a0378b604a6dc516ad456b07e" + } + }, + "pool": { + "bytes": 19, + "sha256": "59052424dd77c722550109df93d63de9ffe3b8fbd5769313eaa2941cfbabf06e", + "postPeephole": { + "bytes": 18, + "sha256": "6725743ec2d0a66ec361dd9350805a78485aac0a0378b604a6dc516ad456b07e" + } + }, + "sink": { + "bytes": 19, + "sha256": "59052424dd77c722550109df93d63de9ffe3b8fbd5769313eaa2941cfbabf06e", + "postPeephole": { + "bytes": 18, + "sha256": "6725743ec2d0a66ec361dd9350805a78485aac0a0378b604a6dc516ad456b07e" + } + }, + "comb": { + "bytes": 19, + "sha256": "59052424dd77c722550109df93d63de9ffe3b8fbd5769313eaa2941cfbabf06e", + "postPeephole": { + "bytes": 18, + "sha256": "6725743ec2d0a66ec361dd9350805a78485aac0a0378b604a6dc516ad456b07e" + } + } + }, + "VerifyECDSA_P384": { + "off": { + "bytes": 1987394, + "sha256": "cfbc39f382a08f8a8e42a141656d70ad12b8d8748501dc2316fd05ebd6625eb5", + "postPeephole": { + "bytes": 1963284, + "sha256": "397df153d19a0f942abc21bc387377a3779f5d9b5db2649ff3ae6611fb8e6e8f" + } + }, + "pool": { + "bytes": 487527, + "sha256": "0c39e105b4390f8d4cd84c8b7454d6496e8b080db10931000f5548814e57c799", + "postPeephole": { + "bytes": 463419, + "sha256": "9d3d041f45ddac3a87a6e584c5fb58945fe380e2bba79b3ffda1c4329b0b7959" + } + }, + "sink": { + "bytes": 296770, + "sha256": "70562e1ed12b4969b7e635eaf0009317b21942998f2c6fbfa3e31975f0770a03", + "postPeephole": { + "bytes": 272662, + "sha256": "08191ab4466221761bc1ba2b506adca70219115e90326ad96a69fdf3430d5ae0" + } + }, + "comb": { + "bytes": 241588, + "sha256": "cb59e5b1c0ec496aaf805e93930a6c763bf0b8124cb0b534f4e61acc32529475", + "postPeephole": { + "bytes": 223188, + "sha256": "eb28f5af1da34b8765b13ad76f4b2b678c1787595ddcdcbd2abcc47ddd730322" + } + } + } + } +} diff --git a/conformance/ec-flag-parity/parity.test.ts b/conformance/ec-flag-parity/parity.test.ts new file mode 100644 index 000000000..436acbfc0 --- /dev/null +++ b/conformance/ec-flag-parity/parity.test.ts @@ -0,0 +1,59 @@ +/** + * The flag-parity fixture is DERIVED, and must never go stale. + * + * `expected.json` is the target every non-TypeScript tier's port is measured + * against. If a deliberate TS codegen change moves the bytes and nobody + * regenerates the file, six tiers keep passing against a pin that no longer + * describes the reference — the fixture would then be certifying agreement with + * a compiler that no longer exists. So re-derive it here and require equality. + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore — plain ESM helper, shared with the npm generate script. +import { buildParity, VARIANTS } from '../scripts/gen-ec-flag-parity.mjs'; + +const expectedPath = join(__dirname, 'expected.json'); + +describe('ec-flag-parity/expected.json', () => { + const checkedIn = JSON.parse(readFileSync(expectedPath, 'utf8')); + const derived = buildParity(); + + it('matches what the TypeScript reference compiler emits today', () => { + expect(derived).toEqual(checkedIn); + }); + + it('covers every emitter under every flag combination', () => { + const names = Object.keys(checkedIn.emitters); + expect(names.length).toBeGreaterThanOrEqual(24); + for (const name of names) { + for (const v of Object.keys(VARIANTS)) { + expect(checkedIn.emitters[name][v], `${name}/${v}`).toBeDefined(); + expect(checkedIn.emitters[name][v].sha256).toMatch(/^[0-9a-f]{64}$/); + } + } + }); + + it('is non-vacuous: the flags actually move bytes', () => { + // A fixture where every variant hashed the same would pass in a tier that + // ignored the flags entirely. Pin that the reference really diverges. + const e = checkedIn.emitters; + expect(e.EcMul.pool.sha256).not.toBe(e.EcMul.off.sha256); + expect(e.EcMul.sink.sha256).not.toBe(e.EcMul.pool.sha256); + expect(e.EcMulGen.comb.sha256).not.toBe(e.EcMulGen.sink.sha256); + expect(e.P256MulGen.comb.sha256).not.toBe(e.P256MulGen.sink.sha256); + expect(e.P384MulGen.comb.sha256).not.toBe(e.P384MulGen.sink.sha256); + expect(e.VerifyECDSA_P256.comb.sha256).not.toBe(e.VerifyECDSA_P256.sink.sha256); + }); + + it('the comb only fires where the base is a compile-time constant', () => { + // `ecMul` / `p256Mul` take their base at run time, so the comb cannot + // apply. A tier that "optimized" those would be combing an attacker-chosen + // point, and the interval argument in comb.ts does not cover that. + const e = checkedIn.emitters; + for (const n of ['EcMul', 'P256Mul', 'P384Mul']) { + expect(e[n].comb.sha256, n).toBe(e[n].sink.sha256); + } + }); +}); diff --git a/conformance/package.json b/conformance/package.json index 0a44c965d..3c2e40594 100644 --- a/conformance/package.json +++ b/conformance/package.json @@ -2,7 +2,7 @@ "name": "runar-conformance", "version": "0.1.0", "private": true, - "description": "Conformance test infrastructure for verifying Rúnar compiler implementations produce identical output", + "description": "Conformance test infrastructure for verifying R\u00fanar compiler implementations produce identical output", "type": "module", "scripts": { "test": "tsx runner/index.ts", @@ -12,6 +12,7 @@ "test:parser-only": "tsx runner/index.ts --parser-only", "update-golden": "tsx runner/index.ts --update-golden", "script-size-check": "tsx runner/script-size-check.ts", + "script-metrics": "tsx runner/script-metrics.ts", "fuzz": "tsx fuzzer/index.ts", "fuzz:quick": "tsx fuzzer/index.ts --ir --compilers ts,go,rust,python,zig,ruby,java --render native --num 20", "fuzz:property": "tsx fuzzer/index.ts --property", @@ -34,7 +35,9 @@ "sdk-vertical:generate": "tsx sdk-vertical/generate.ts", "sdk-vertical:check": "tsx sdk-vertical/generate.ts --check", "differential-witness": "vitest run witnesses/differential.test.ts", - "mutation:score": "tsx mutation/run-mutation.ts" + "mutation:score": "tsx mutation/run-mutation.ts", + "ec-flag-parity:generate": "node scripts/gen-ec-flag-parity.mjs", + "ec-flag-parity": "vitest run ec-flag-parity/parity.test.ts" }, "dependencies": { "fast-check": "^3.22.0" diff --git a/conformance/runner/__tests__/script-metrics.test.ts b/conformance/runner/__tests__/script-metrics.test.ts new file mode 100644 index 000000000..ff9544176 --- /dev/null +++ b/conformance/runner/__tests__/script-metrics.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + parseArgs, + formatTable, + formatComparison, + formatDetail, + measureGolden, + tsSourcePath, + VARIANTS, + type FixtureMetrics, +} from '../script-metrics.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function fakeMetrics(fixture: string, scriptBytes: number): FixtureMetrics { + return { + fixture, + source: 'compiled', + scriptBytes, + opcodeCount: scriptBytes, + pushCount: 0, + categories: { + 'const-push': scriptBytes, 'small-int-push': 0, 'stack-shuffle': 0, + arithmetic: 0, bytes: 0, crypto: 0, control: 0, other: 0, + }, + opcodes: { PUSH: 1 }, + constants: [], + }; +} + +// --------------------------------------------------------------------------- + +describe('parseArgs', () => { + it('defaults to reading goldens with a summary table', () => { + const a = parseArgs([]); + expect(a.compileMode).toBe(false); + expect(a.detail).toBe(false); + expect(a.compare).toEqual([]); + }); + + it('--compare implies --compile', () => { + const a = parseArgs(['--compare', 'current,current']); + expect(a.compare).toEqual(['current', 'current']); + expect(a.compileMode).toBe(true); + }); + + it('rejects an unknown argument instead of ignoring it', () => { + // A silently-ignored flag in a benchmark harness reads as "I measured + // that" when nothing was measured. + expect(() => parseArgs(['--nope'])).toThrow(/unknown argument/); + }); +}); + +describe('variants', () => { + it('always offers the shipping default as the comparison base', () => { + expect(VARIANTS.current).toBeDefined(); + expect(VARIANTS.current).toEqual({}); + }); +}); + +describe('tsSourcePath', () => { + it('resolves a fixture that ships a TypeScript source', () => { + const p = tsSourcePath('p2pkh') ?? tsSourcePath('basic-p2pkh'); + expect(p).toMatch(/\.runar\.ts$/); + }); + + it('returns null for a fixture that declares no .runar.ts rather than throwing', () => { + // A size report must skip such a fixture visibly, not crash on it. + const dir = mkdtempSync(join(tmpdir(), 'runar-metrics-')); + try { + mkdirSync(join(dir, 'go-only')); + writeFileSync( + join(dir, 'go-only', 'source.json'), + JSON.stringify({ sources: { '.runar.go': './X.runar.go' }, compilers: ['go'] }), + ); + expect(tsSourcePath('go-only', dir)).toBeNull(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('returns null when the fixture has no source.json at all', () => { + const dir = mkdtempSync(join(tmpdir(), 'runar-metrics-')); + try { + mkdirSync(join(dir, 'bare')); + expect(tsSourcePath('bare', dir)).toBeNull(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('throws when source.json points at a file that is not on disk', () => { + const dir = mkdtempSync(join(tmpdir(), 'runar-metrics-')); + try { + mkdirSync(join(dir, 'ghost')); + writeFileSync( + join(dir, 'ghost', 'source.json'), + JSON.stringify({ sources: { '.runar.ts': './nope.runar.ts' } }), + ); + expect(() => tsSourcePath('ghost', dir)).toThrow(/missing file/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe('measureGolden', () => { + it('reports the byte length and category split of a golden', () => { + const dir = mkdtempSync(join(tmpdir(), 'runar-metrics-')); + try { + const hexPath = join(dir, 'expected-script.hex'); + // OP_DUP OP_HASH160 <20 bytes> OP_EQUALVERIFY OP_CHECKSIG + writeFileSync(hexPath, `76a914${'ab'.repeat(20)}88ac\n`); + const m = measureGolden('p2pkh-ish', hexPath); + expect(m.scriptBytes).toBe(25); + expect(m.source).toBe('golden'); + expect(m.categories['const-push']).toBe(21); + expect(m.opcodes['OP_CHECKSIG']).toBe(1); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe('formatting', () => { + it('orders the summary table largest-first', () => { + const table = formatTable([fakeMetrics('small', 10), fakeMetrics('big', 100)]); + expect(table.indexOf('| big |')).toBeLessThan(table.indexOf('| small |')); + }); + + it('reports the delta of the last variant against the first', () => { + const byVariant = new Map([ + ['current', new Map([['fx', fakeMetrics('fx', 1000)]])], + ['tuned', new Map([['fx', fakeMetrics('fx', 250)]])], + ]); + const out = formatComparison(['current', 'tuned'], byVariant); + expect(out).toContain('-75.0%'); + }); + + it('renders a dash for a variant that produced no result', () => { + const byVariant = new Map([ + ['current', new Map([['fx', fakeMetrics('fx', 1000)]])], + ['tuned', new Map()], + ]); + expect(formatComparison(['current', 'tuned'], byVariant)).toContain('| — |'); + }); + + it('lists dominant constants in the detail view', () => { + const m = fakeMetrics('fx', 340); + m.constants = [{ hex: 'ff'.repeat(33), count: 10, bytes: 340 }]; + const out = formatDetail(m); + expect(out).toContain('33 B'); + expect(out).toContain('100.0%'); + }); +}); diff --git a/conformance/runner/script-metrics.ts b/conformance/runner/script-metrics.ts new file mode 100644 index 000000000..02ca95063 --- /dev/null +++ b/conformance/runner/script-metrics.ts @@ -0,0 +1,325 @@ +/** + * Script-size instrumentation runner. + * + * `script-size-check.ts` answers "did any fixture grow?"; this answers the + * next question: "where did the bytes go?". For every conformance fixture it + * buckets the serialized script by byte category and reports the constants + * that dominate, so an optimization can be aimed at the term that actually + * costs something rather than at whatever is easiest to change. + * + * Two sources of script bytes: + * + * default — read the checked-in `expected-script.hex`. Fast, needs no + * compilation, and is exactly what CI ships. + * --compile — recompile each fixture's `.runar.ts` through the TS reference + * compiler with a given option set. This is how an experimental + * flag is benchmarked against the baseline; pass `--compare` to + * run several option sets and print the deltas. + * + * Usage: + * tsx runner/script-metrics.ts # goldens, markdown table + * tsx runner/script-metrics.ts --json out.json # machine-readable + * tsx runner/script-metrics.ts --fixture p256-wallet --detail + * tsx runner/script-metrics.ts --compile --compare current,liveness + * + * Read-only: it never writes a golden or a baseline. + */ + +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { + analyzeScriptHex, + type ByteCategory, + type ScriptMetrics, +} from '../../packages/runar-compiler/src/metrics/script-metrics.js'; +import { compile, type CompileOptions } from '../../packages/runar-compiler/src/index.js'; +import { CONFORMANCE_ROOT, TESTS_DIR, discoverFixtures } from './script-size-check.js'; + +const REPO_ROOT = resolve(CONFORMANCE_ROOT, '..'); + +// --------------------------------------------------------------------------- +// Fixture sources +// --------------------------------------------------------------------------- + +interface SourceConfig { + sources?: Record; + compilers?: string[]; +} + +/** Absolute path to a fixture's `.runar.ts` source, or null if it ships none. */ +export function tsSourcePath(fixture: string, testsDir: string = TESTS_DIR): string | null { + const configFile = join(testsDir, fixture, 'source.json'); + if (!existsSync(configFile)) return null; + const config = JSON.parse(readFileSync(configFile, 'utf8')) as SourceConfig; + const rel = config.sources?.['.runar.ts']; + if (rel === undefined) return null; + const abs = resolve(testsDir, fixture, rel); + if (!existsSync(abs)) { + throw new Error(`source.json for '${fixture}' points at a missing file: ${abs}`); + } + return abs; +} + +// --------------------------------------------------------------------------- +// Option sets ("variants") a benchmark can compare +// --------------------------------------------------------------------------- + +/** + * Named compiler configurations. `current` is the shipping default; every + * other entry is an experimental flag combination. Adding a variant here is + * all it takes to get it into the comparison table. + */ +export const VARIANTS: Record = { + current: {}, + 'ec-pool': { ecConstantPool: true }, + 'ec-sink': { ecConstantPool: true, ecReductionSinking: true }, + 'ec-comb': { ecConstantPool: true, ecReductionSinking: true, ecFixedBaseComb: true }, + liveness: { schedulerMode: 'liveness' }, + both: { ecConstantPool: true, schedulerMode: 'liveness' }, + all: { + ecConstantPool: true, + ecReductionSinking: true, + ecFixedBaseComb: true, + schedulerMode: 'liveness', + }, +}; + +// --------------------------------------------------------------------------- +// Measurement +// --------------------------------------------------------------------------- + +export interface FixtureMetrics extends ScriptMetrics { + fixture: string; + /** Where the bytes came from: the checked-in golden or a live compile. */ + source: 'golden' | 'compiled'; +} + +export function measureGolden(fixture: string, hexPath: string): FixtureMetrics { + const hex = readFileSync(hexPath, 'utf8').replace(/\s+/g, ''); + return { fixture, source: 'golden', ...analyzeScriptHex(hex) }; +} + +export function measureCompiled( + fixture: string, + sourcePath: string, + options: CompileOptions, +): FixtureMetrics { + const source = readFileSync(sourcePath, 'utf8'); + const result = compile(source, { ...options, fileName: sourcePath }); + if (!result.success || result.scriptHex === undefined) { + const errs = result.diagnostics.filter(d => d.severity === 'error').map(d => d.message); + throw new Error(`compile failed for '${fixture}': ${errs.join('; ') || 'no scriptHex'}`); + } + return { fixture, source: 'compiled', ...analyzeScriptHex(result.scriptHex) }; +} + +// --------------------------------------------------------------------------- +// Reporting +// --------------------------------------------------------------------------- + +const REPORT_CATEGORIES: ByteCategory[] = [ + 'const-push', 'stack-shuffle', 'arithmetic', 'bytes', 'crypto', + 'small-int-push', 'control', 'other', +]; + +function pct(part: number, whole: number): string { + if (whole === 0) return '0.0%'; + return `${((100 * part) / whole).toFixed(1)}%`; +} + +export function formatTable(rows: FixtureMetrics[]): string { + const sorted = [...rows].sort((a, b) => b.scriptBytes - a.scriptBytes); + const head = ['fixture', 'bytes', 'ops', ...REPORT_CATEGORIES]; + const lines = [ + `| ${head.join(' | ')} |`, + `|${head.map((_, i) => (i === 0 ? '---' : '---:')).join('|')}|`, + ]; + for (const r of sorted) { + const cells = REPORT_CATEGORIES.map(c => { + const v = r.categories[c]; + return v === 0 ? '—' : `${v} (${pct(v, r.scriptBytes)})`; + }); + lines.push(`| ${r.fixture} | ${r.scriptBytes} | ${r.opcodeCount} | ${cells.join(' | ')} |`); + } + return lines.join('\n'); +} + +/** Per-fixture detail: the constants that dominate, and the opcode histogram. */ +export function formatDetail(m: FixtureMetrics, topN = 8): string { + const out: string[] = []; + out.push(`### ${m.fixture} — ${m.scriptBytes} bytes, ${m.opcodeCount} ops (${m.source})`); + out.push(''); + out.push('| category | bytes | share |'); + out.push('|---|---:|---:|'); + for (const c of REPORT_CATEGORIES) { + const v = m.categories[c]; + if (v > 0) out.push(`| ${c} | ${v} | ${pct(v, m.scriptBytes)} |`); + } + out.push(''); + if (m.constants.length > 0) { + out.push('| repeated constant | size | pushes | total bytes | share |'); + out.push('|---|---:|---:|---:|---:|'); + for (const c of m.constants.slice(0, topN)) { + const label = c.hex.length > 24 ? `${c.hex.slice(0, 20)}…` : c.hex; + out.push(`| \`${label}\` | ${c.hex.length / 2} B | ${c.count} | ${c.bytes} | ${pct(c.bytes, m.scriptBytes)} |`); + } + out.push(''); + } + const ops = Object.entries(m.opcodes).sort((a, b) => b[1] - a[1]).slice(0, topN); + out.push(`Top opcodes: ${ops.map(([k, v]) => `${k}×${v}`).join(', ')}`); + return out.join('\n'); +} + +/** Side-by-side variant comparison for one fixture set. */ +export function formatComparison( + variantNames: string[], + byVariant: Map>, +): string { + const base = variantNames[0]!; + const fixtures = [...(byVariant.get(base)?.keys() ?? [])] + .sort((a, b) => (byVariant.get(base)!.get(b)!.scriptBytes) - (byVariant.get(base)!.get(a)!.scriptBytes)); + const head = ['fixture', ...variantNames.map(v => (v === base ? `${v} (base)` : `${v}`)), 'delta']; + const lines = [ + `| ${head.join(' | ')} |`, + `|${head.map((_, i) => (i === 0 ? '---' : '---:')).join('|')}|`, + ]; + for (const fx of fixtures) { + const baseBytes = byVariant.get(base)!.get(fx)!.scriptBytes; + const cells = variantNames.map(v => { + const m = byVariant.get(v)?.get(fx); + return m ? String(m.scriptBytes) : '—'; + }); + const last = byVariant.get(variantNames[variantNames.length - 1]!)?.get(fx); + const delta = last && baseBytes > 0 + ? `${(((last.scriptBytes - baseBytes) / baseBytes) * 100).toFixed(1)}%` + : '—'; + lines.push(`| ${fx} | ${cells.join(' | ')} | ${delta} |`); + } + return lines.join('\n'); +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +interface Args { + json?: string; + fixture?: string; + detail: boolean; + compileMode: boolean; + compare: string[]; + top: number; +} + +export function parseArgs(argv: string[]): Args { + const args: Args = { detail: false, compileMode: false, compare: [], top: 8 }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]!; + if (a === '--json') args.json = argv[++i]; + else if (a === '--fixture') args.fixture = argv[++i]; + else if (a === '--detail') args.detail = true; + else if (a === '--compile') args.compileMode = true; + else if (a === '--compare') { args.compare = (argv[++i] ?? '').split(',').filter(Boolean); args.compileMode = true; } + else if (a === '--top') args.top = Number(argv[++i]); + else if (a === '--help' || a === '-h') { args.detail = false; printHelp(); process.exit(0); } + else throw new Error(`script-metrics: unknown argument '${a}'`); + } + return args; +} + +function printHelp(): void { + console.log(` +script-metrics — where do a fixture's script bytes go? + + --fixture measure one fixture instead of all + --detail per-fixture category / constant / opcode breakdown + --compile recompile from .runar.ts instead of reading the golden + --compare a,b,c compile under several named variants and diff (implies --compile) + --json write machine-readable results + --top rows in the constant / opcode lists (default 8) + +Variants available: ${Object.keys(VARIANTS).join(', ')} +`.trim()); +} + +function main(): void { + const args = parseArgs(process.argv.slice(2)); + const fixtures = discoverFixtures(); + const selected = args.fixture + ? new Map(fixtures.has(args.fixture) ? [[args.fixture, fixtures.get(args.fixture)!]] : []) + : fixtures; + if (selected.size === 0) { + throw new Error(`no fixtures selected${args.fixture ? ` (unknown fixture '${args.fixture}')` : ''}`); + } + + if (args.compare.length > 0) { + for (const name of args.compare) { + if (!(name in VARIANTS)) { + throw new Error(`unknown variant '${name}'. Known: ${Object.keys(VARIANTS).join(', ')}`); + } + } + const byVariant = new Map>(); + const skipped: string[] = []; + for (const name of args.compare) { + const perFixture = new Map(); + for (const [fx] of selected) { + const src = tsSourcePath(fx); + if (src === null) { if (name === args.compare[0]) skipped.push(fx); continue; } + perFixture.set(fx, measureCompiled(fx, src, VARIANTS[name]!)); + } + byVariant.set(name, perFixture); + } + console.log(formatComparison(args.compare, byVariant)); + if (skipped.length > 0) { + // Never silently drop a fixture from a size report. + console.log(`\n_Skipped (no .runar.ts source): ${skipped.join(', ')}_`); + } + if (args.json) { + const payload = Object.fromEntries( + [...byVariant].map(([v, m]) => [v, Object.fromEntries(m)]), + ); + writeFileSync(args.json, `${JSON.stringify(payload, null, 2)}\n`); + } + return; + } + + const rows: FixtureMetrics[] = []; + const skipped: string[] = []; + for (const [fx, hexPath] of selected) { + if (args.compileMode) { + const src = tsSourcePath(fx); + if (src === null) { skipped.push(fx); continue; } + rows.push(measureCompiled(fx, src, VARIANTS.current!)); + } else { + rows.push(measureGolden(fx, hexPath)); + } + } + + if (args.detail) { + for (const r of [...rows].sort((a, b) => b.scriptBytes - a.scriptBytes)) { + console.log(formatDetail(r, args.top)); + console.log(''); + } + } else { + console.log(formatTable(rows)); + } + if (skipped.length > 0) { + console.log(`\n_Skipped (no .runar.ts source): ${skipped.join(', ')}_`); + } + if (args.json) { + writeFileSync(args.json, `${JSON.stringify(rows, null, 2)}\n`); + } +} + +const isMain = process.argv[1] !== undefined + && resolve(process.argv[1]).endsWith(join('runner', 'script-metrics.ts')); +if (isMain) { + try { + main(); + } catch (err) { + console.error(`script-metrics: ${(err as Error).message}`); + process.exit(1); + } +} + +export { REPO_ROOT }; diff --git a/conformance/scripts/gen-ec-flag-parity.mjs b/conformance/scripts/gen-ec-flag-parity.mjs new file mode 100644 index 000000000..ea8f5d71f --- /dev/null +++ b/conformance/scripts/gen-ec-flag-parity.mjs @@ -0,0 +1,81 @@ +/** + * Regenerate `conformance/ec-flag-parity/expected.json` from the TypeScript + * reference compiler. + * + * Derived artifact — never hand-edit the JSON. See that directory's README. + */ +import { createHash } from 'node:crypto'; +import { writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const here = dirname(fileURLToPath(import.meta.url)); +const compiler = join(here, '..', '..', 'packages', 'runar-compiler', 'dist', 'index.js'); +const C = await import(compiler); + +/** Every EC / NIST-curve emitter the flags can reach. */ +const EMITTERS = { + EcAdd: C.emitEcAdd, EcMul: C.emitEcMul, EcMulGen: C.emitEcMulGen, + EcNegate: C.emitEcNegate, EcOnCurve: C.emitEcOnCurve, + EcModReduce: C.emitEcModReduce, EcEncodeCompressed: C.emitEcEncodeCompressed, + EcMakePoint: C.emitEcMakePoint, EcPointX: C.emitEcPointX, EcPointY: C.emitEcPointY, + P256Add: C.emitP256Add, P256Mul: C.emitP256Mul, P256MulGen: C.emitP256MulGen, + P256Negate: C.emitP256Negate, P256OnCurve: C.emitP256OnCurve, + P256EncodeCompressed: C.emitP256EncodeCompressed, + VerifyECDSA_P256: C.emitVerifyECDSA_P256, + P384Add: C.emitP384Add, P384Mul: C.emitP384Mul, P384MulGen: C.emitP384MulGen, + P384Negate: C.emitP384Negate, P384OnCurve: C.emitP384OnCurve, + P384EncodeCompressed: C.emitP384EncodeCompressed, + VerifyECDSA_P384: C.emitVerifyECDSA_P384, +}; + +/** + * The flag combinations a user can actually select. `sink` includes the pool + * because the cheap subtraction references the prime twice — without a pooled + * slot it is a regression, so the compiler never offers sinking alone. + */ +export const VARIANTS = { + off: {}, + pool: { constantPool: true }, + sink: { constantPool: true, reductionSinking: true }, + comb: { constantPool: true, reductionSinking: true, fixedBaseComb: true }, +}; + +function measure(scriptHex) { + return { + bytes: scriptHex.length / 2, + sha256: createHash('sha256').update(Buffer.from(scriptHex, 'hex')).digest('hex'), + }; +} + +export function buildParity() { + const out = { variants: VARIANTS, emitters: {} }; + for (const [name, emit] of Object.entries(EMITTERS)) { + out.emitters[name] = {}; + for (const [vn, vo] of Object.entries(VARIANTS)) { + const ops = []; + emit(op => ops.push(op), vo); + const raw = measure(C.emitMethod({ name: 't', ops }).scriptHex); + // Post-peephole bytes: what the compiler actually ships. + // + // Six tiers reproduce the RAW emitter output op for op, so the raw hash is + // the sharpest gate available to them. The Zig tier cannot: its peephole + // folds only i64 `push_int` chains, so it emits `k + 3n` pre-folded where + // the reference emits three `+n` steps that its own peephole collapses. + // Same shipped bytes, different pre-peephole spelling — so Zig is gated on + // this hash instead. See conformance/ec-flag-parity/README.md. + const optimised = C.optimizeStackIR(ops); + out.emitters[name][vn] = { + ...raw, + postPeephole: measure(C.emitMethod({ name: 't', ops: optimised }).scriptHex), + }; + } + } + return out; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const target = join(here, '..', 'ec-flag-parity', 'expected.json'); + writeFileSync(target, JSON.stringify(buildParity(), null, 2) + '\n'); + console.log(`wrote ${target}`); +} diff --git a/docs/experiments/script-size-optimization-baseline.md b/docs/experiments/script-size-optimization-baseline.md new file mode 100644 index 000000000..e82e3956d --- /dev/null +++ b/docs/experiments/script-size-optimization-baseline.md @@ -0,0 +1,305 @@ +# Script-size optimization — Phase 0 baseline + +**Date:** 2026-08-28 · **Compiler:** TypeScript reference tier, default options (constant folding ON, EC optimizer ON, peephole ON) +**Source of truth:** the checked-in `conformance/tests//expected-script.hex` goldens, 72 fixtures, 13,526,545 bytes total. + +Reproduce every number here with: + +```bash +pnpm --filter runar-conformance run script-metrics # summary table +pnpm --filter runar-conformance run script-metrics -- --fixture p256-wallet --detail +pnpm --filter runar-conformance run script-metrics -- --json out.json # machine-readable +``` + +Instrumentation is read-only and does not change compilation output: +`packages/runar-compiler/src/metrics/script-metrics.ts` (byte classifier), +`packages/runar-compiler/src/metrics/cost-model.ts` (`estimateScriptBytes`, asserted +byte-exact against `06-emit.ts` over the whole corpus in `__tests__/cost-model.test.ts`), +`conformance/runner/script-metrics.ts` (the runner). + +--- + +## 1. The headline + +**58 % of every script byte Rúnar has ever emitted is a constant push, and 56 % of the +entire corpus is nine distinct constants pushed over and over.** + +| category | bytes | share of corpus | +|---|---:|---:| +| const-push | 7,840,690 | **58.0 %** | +| stack-shuffle | 3,133,223 | 23.2 % | +| arithmetic | 1,047,644 | 7.7 % | +| bytes (CAT/SPLIT/SIZE/EQUAL) | 878,631 | 6.5 % | +| small-int-push | 392,378 | 2.9 % | +| control | 160,000 | 1.2 % | +| crypto | 73,979 | 0.5 % | + +The nine largest single constants, all of them a curve's field prime `p` (or group order `n`): + +| fixture | constant | push size | pushes | bytes | share of that fixture | +|---|---|---:|---:|---:|---:| +| p384-wallet | P-384 `p` | 49 B | 30,577 | 1,528,850 | 77.9 % | +| p384-primitives | P-384 `p` | 49 B | 29,925 | 1,496,250 | 79.4 % | +| ec-primitives | secp256k1 `p` | 33 B | 28,102 | 955,468 | 71.7 % | +| ec-demo | secp256k1 `p` | 33 B | 28,102 | 955,468 | 71.7 % | +| **p256-wallet** | **P-256 `p`** | **33 B** | **20,025** | **680,850** | **71.0 %** | +| p256-primitives | P-256 `p` | 33 B | 19,755 | 671,670 | 72.4 % | +| schnorr-zkp | secp256k1 `p` | 33 B | 18,551 | 630,734 | 72.1 % | +| ec-unit | secp256k1 `p` | 33 B | 10,092 | 343,128 | 71.5 % | +| convergence-proof | secp256k1 `p` | 33 B | 9,547 | 324,598 | 71.8 % | + +Total: **7,587,016 bytes — 56 % of the corpus — spent re-pushing nine numbers.** + +### Why + +`cFieldMod` / `fieldMod` push the prime inline at *every* modular reduction: + +```ts +// packages/runar-compiler/src/passes/p256-p384-codegen.ts:135 +function cFieldMod(t: ECTracker, aName: string, resultName: string, c: CurveParams): void { + t.toTop(aName); + pushFieldP(t, '_fmod_p', c); // <-- 34 bytes (P-256) / 50 bytes (P-384), every time + t.rawBlock([aName, '_fmod_p'], resultName, (e) => { + e({ op: 'opcode', code: 'OP_2DUP' }); e({ op: 'opcode', code: 'OP_MOD' }); + e({ op: 'rot' }); e({ op: 'drop' }); e({ op: 'over' }); + e({ op: 'opcode', code: 'OP_ADD' }); e({ op: 'swap' }); e({ op: 'opcode', code: 'OP_MOD' }); + }); +} +``` + +Every `cFieldAdd` / `cFieldSub` / `cFieldMul` / `cFieldSqr` / `cFieldMulConst` ends in one of +these. `cEmitMul` unrolls 257 (P-256) / 385 (P-384) double-and-add rounds, `cFieldInv` and +`cGroupInv` unroll full Fermat ladders — so the prime push is multiplied by the unroll factor. + +A prime kept in a stack slot and copied with `push(depth); OP_PICK` costs **2–3 bytes** +instead of 34 or 50. Break-even is at two uses. + +--- + +## 2. p256-wallet — the brief's 959 kB reference, in detail + +`conformance/tests/p256-wallet` is **958,792 bytes**, which is the "959,592 B baseline +reference implementation" the optimization brief targets. It is a hybrid secp256k1 + P-256 +wallet: a P2PKH gate, then `verifyECDSA_P256(sig, p256Sig, p256PubKey)`. + +| category | bytes | share | +|---|---:|---:| +| const-push | 697,019 | 72.7 % | +| stack-shuffle | 173,967 | 18.1 % | +| arithmetic | 82,863 | 8.6 % | +| bytes | 1,210 | 0.1 % | +| small-int-push | 2,698 | 0.3 % | +| control | 1,031 | 0.1 % | +| crypto | 4 | 0.0 % | + +| repeated constant | size | pushes | bytes | share | +|---|---:|---:|---:|---:| +| P-256 field prime `p` | 33 B | 20,025 | 680,850 | 71.0 % | +| P-256 group order `n` | 33 B | 430 | 14,620 | 1.5 % | + +Top opcodes: `OP_MOD`×41,418 · `OP_ROT`×30,406 · `OP_SWAP`×27,342 · `OP_OVER`×23,417 · +`OP_DROP`×22,558 · `OP_ADD`×20,978 · `OP_2DUP`×20,453 · `OP_MUL`×13,214. + +Note the shape: **41,418 `OP_MOD` against 20,025 prime pushes** — two `OP_MOD` per reduction. +That is the sign-normalisation tail (`2DUP MOD ROT DROP OVER ADD SWAP MOD`), which exists +because `OP_MOD` takes the sign of the dividend. For a product of two values already reduced +into `[0, p)` the dividend is non-negative and the tail is dead weight: 6 of the 8 opcodes, +plus the second prime reference. That is a modular-domain-analysis win (brief Phase 4/5), not +a scheduling one. + +### Where the arithmetic actually goes + +Op-count goldens (`packages/runar-compiler/src/__tests__/p256-p384-codegen.test.ts:111`): + +| emitter | ops | measured bytes | +|---|---:|---:| +| `emitVerifyECDSA_P256` | 297,331 | 974,024 | +| `emitP256Mul` / `emitP256MulGen` | 140,036 / 140,038 | 459,746 / 459,812 | +| `emitP256Add` | 6,663 | 19,906 | +| `emitVerifyECDSA_P384` | 453,307 | 1,987,394 | +| `emitP384Mul` | 211,178 | 927,350 | + +`cEmitVerifyECDSA` runs **two independent 257-round ladders** (`u1·G` at `:1412`, `u2·Q` at +`:1442`) plus **three unrolled Fermat exponentiations** (`cFieldInv` 382 field muls, +`cGroupInv` 423, `cFieldPow` for the decompression sqrt 286). Nothing is shared between the +two ladders and no point is precomputed, even though `G` is a compile-time constant. + +--- + +## 3. Two populations, two different bottlenecks + +The corpus splits cleanly, and the split decides which optimization can touch which fixture. + +### EC / field-arithmetic fixtures — dominated by constants (72–80 % const-push) + +`p256-*`, `p384-*`, `ec-*`, `schnorr-zkp`, `convergence-proof`, `babybear*`. These scripts are +emitted by hand-written macro modules (`ec-codegen.ts`, `p256-p384-codegen.ts`, +`babybear-codegen.ts`, …) that build their own stack layout through `ECTracker` and its +clones. **They never pass through `05-stack-lower.ts`.** A generic ANF→Stack scheduler cannot +move a single byte of them. + +### Ordinary contracts — dominated by stack traffic (35–68 % stack-shuffle) + +Everything from `stateful-counter` (1,875 B) up through `math-demo` (17,348 B), and the small +fixtures most of all: `arithmetic` 67.9 %, `bounded-loop` 57.1 %, `multisig` 58.8 %, +`if-without-else-multi-temp` 55.3 %. These *are* produced by `05-stack-lower.ts`, and the +~30 % const-push in the mid-size stateful fixtures is largely BIP-143 sighash scaffolding, +not user data. + +### Hash / post-quantum fixtures — dominated by byte plumbing + +SLH-DSA (`OP_CAT`×80,120, `OP_SPLIT`×50,221 in the 128f fixture), SHA-256 and BLAKE3 sit at +2–17 % const-push, 36–44 % stack-shuffle, 23–28 % `bytes`. Constant pooling buys them almost +nothing; scheduling and byte-op fusion are the levers. + +| population | fixtures | const-push | stack-shuffle | reachable by | +|---|---|---:|---:|---| +| EC / field arithmetic | 9 (10.2 MB) | 72–80 % | 13–19 % | codegen-level constant pooling | +| hash / post-quantum | 12 (3.0 MB) | 2–17 % | 36–44 % | scheduling, byte-op fusion | +| ordinary contracts | 51 (0.1 MB) | 0–33 % | 35–68 % | generic liveness scheduler | + +--- + +## 4. Ranked byte sinks + +1. **Repeated field-prime pushes — 7,587,016 B (56 % of the corpus).** One pooled stack slot + per curve constant. Codegen-level (`ECTracker`), not a generic pass; the *policy* + (pool when `n_uses × push_cost > pool_cost + n_uses × pick_cost`) is generic and belongs + in the cost model. +2. **Stack traffic — 3,133,223 B (23 %).** Split roughly evenly between the EC macros' + `ECTracker.toTop`/`copyToTop` churn and `05-stack-lower.ts`'s `bringToTop`. The generic + half is addressable by liveness-driven scheduling; see + [`stack-scheduler-design.md`](stack-scheduler-design.md). +3. **The redundant second `OP_MOD` — ~20,000 reductions per EC fixture × 6 opcodes.** + Requires knowing an operand is already reduced (modular-domain analysis, brief Phase 4). +4. **Unrolled Fermat inversion.** 382 / 423 / 286 field muls per P-256 verify, three times. + An addition chain cuts each by ~30 %; a witness-supplied inverse (brief Phase 7) removes + them almost entirely. +5. **Two independent scalar ladders.** Straus/Shamir halves the doubling work; a fixed-base + comb for `u1·G` removes it (brief Phases 9–11). +6. **`emitReverse32` / `emitReverse48`.** 7 ops × 32 (or 48) per byte-order reversal, called + on every point decompose/compose. + +--- + +## 5. What Phase 0 already settles about the brief + +- **Phase 3 (fix-point peephole) is already done.** `optimizeStackIR` + (`optimizer/peephole.ts:507`) iterates `applyOnePass` to a fixed point with a 100-iteration + cap and recurses into `if` arms first. 28 rules, mirrored declaratively in + `optimizer/peephole-rules.ts` and executed pattern-vs-replacement through the `ScriptVM` by + `__tests__/peephole-exhaustive.test.ts`. What remains for Phase 3 is *more rules*, not a + fix-point driver. +- **Phase 15 (OP_PUSH_TX / CODESEPARATOR transaction binding) already ships** as + `passes/oppushtx-codegen.ts` — the BUG-100 fix derives the ECDSA signature from the pushed + preimage on-chain, so `OP_CHECKSIG` passes only when `hash256(preimage)` is the real + sighash. +- **Phase 2's generic scheduler cannot reach P-256.** See §3. The two must be prototyped + separately or the P-256 number will not move at all. +- **Phase 1's cost model is exact.** `estimateScriptBytes` agrees with `emitMethod` to the + byte on every method of all 67 fixtures that ship a `.runar.ts`, before and after peephole. + +--- + +## 6. Full corpus table + +Byte category shares per fixture, largest script first. + +| fixture | bytes | ops | const-push | stack-shuffle | arithmetic | bytes | crypto | small-int-push | control | other | +|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| p384-wallet | 1963300 | 430181 | 1565148 (79.7%) | 264286 (13.5%) | 126571 (6.4%) | 1754 (0.1%) | 4 (0.0%) | 3994 (0.2%) | 1543 (0.1%) | — | +| p384-primitives | 1883767 | 415719 | 1498750 (79.6%) | 256133 (13.6%) | 121250 (6.4%) | 1976 (0.1%) | — | 4109 (0.2%) | 1549 (0.1%) | — | +| ec-demo | 1332782 | 403869 | 957857 (71.9%) | 249884 (18.7%) | 113993 (8.6%) | 4208 (0.3%) | — | 5256 (0.4%) | 1584 (0.1%) | — | +| ec-primitives | 1332782 | 403869 | 957857 (71.9%) | 249884 (18.7%) | 113993 (8.6%) | 4208 (0.3%) | — | 5256 (0.4%) | 1584 (0.1%) | — | +| p256-wallet | 958792 | 282747 | 697019 (72.7%) | 173967 (18.1%) | 82863 (8.6%) | 1210 (0.1%) | 4 (0.0%) | 2698 (0.3%) | 1031 (0.1%) | — | +| p256-primitives | 928219 | 275241 | 673254 (72.5%) | 169769 (18.3%) | 80058 (8.6%) | 1336 (0.1%) | — | 2765 (0.3%) | 1037 (0.1%) | — | +| schnorr-zkp | 875189 | 262004 | 632257 (72.2%) | 162557 (18.6%) | 75250 (8.6%) | 1335 (0.2%) | 1 (0.0%) | 2759 (0.3%) | 1030 (0.1%) | — | +| post-quantum-slhdsa-192f | 788039 | 757325 | 54761 (6.9%) | 339659 (43.1%) | 56908 (7.2%) | 206316 (26.2%) | 17549 (2.2%) | 78196 (9.9%) | 34650 (4.4%) | — | +| post-quantum-slhdsa-256f | 729363 | 716143 | 19626 (2.7%) | 327166 (44.9%) | 57832 (7.9%) | 191936 (26.3%) | 17837 (2.4%) | 79752 (10.9%) | 35214 (4.8%) | — | +| post-quantum-slhdsa-128f | 533911 | 525388 | 12149 (2.3%) | 235416 (44.1%) | 39293 (7.4%) | 143273 (26.8%) | 12137 (2.3%) | 67751 (12.7%) | 23892 (4.5%) | — | +| ec-unit | 479716 | 146119 | 343994 (71.7%) | 89666 (18.7%) | 40878 (8.5%) | 2407 (0.5%) | — | 2248 (0.5%) | 523 (0.1%) | — | +| convergence-proof | 452386 | 136798 | 325416 (71.9%) | 84502 (18.7%) | 38705 (8.6%) | 1467 (0.3%) | — | 1780 (0.4%) | 516 (0.1%) | — | +| post-quantum-slhdsa-256s | 369173 | 358620 | 15558 (4.2%) | 162192 (43.9%) | 28402 (7.7%) | 96981 (26.3%) | 8818 (2.4%) | 40026 (10.8%) | 17196 (4.7%) | — | +| post-quantum-slhdsa-192s | 276583 | 262513 | 23837 (8.6%) | 116220 (42.0%) | 19195 (6.9%) | 72337 (26.2%) | 5985 (2.2%) | 27396 (9.9%) | 11613 (4.2%) | — | +| sphincs-wallet | 188609 | 183128 | 7747 (4.1%) | 80932 (42.9%) | 13282 (7.0%) | 50562 (26.8%) | 4164 (2.2%) | 23879 (12.7%) | 8043 (4.3%) | — | +| post-quantum-slhdsa | 188597 | 183116 | 7747 (4.1%) | 80926 (42.9%) | 13282 (7.0%) | 50560 (26.8%) | 4161 (2.2%) | 23878 (12.7%) | 8043 (4.3%) | — | +| sha256-finalize | 69507 | 63949 | 9898 (14.2%) | 24810 (35.7%) | 10155 (14.6%) | 16262 (23.4%) | — | 8379 (12.1%) | 3 (0.0%) | — | +| sha256-compress | 23145 | 21294 | 3296 (14.2%) | 8262 (35.7%) | 3384 (14.6%) | 5413 (23.4%) | — | 2790 (12.1%) | — | — | +| blake3 | 22428 | 20773 | 2798 (12.5%) | 8742 (39.0%) | 1811 (8.1%) | 6336 (28.3%) | — | 2738 (12.2%) | 3 (0.0%) | — | +| stateful-wots-gate | 20519 | 18177 | 3443 (16.8%) | 8025 (39.1%) | 2237 (10.9%) | 2454 (12.0%) | 1011 (4.9%) | 320 (1.6%) | 3029 (14.8%) | — | +| post-quantum-wallet | 19594 | 17514 | 3154 (16.1%) | 7690 (39.2%) | 2213 (11.3%) | 2275 (11.6%) | 1010 (5.2%) | 237 (1.2%) | 3015 (15.4%) | — | +| post-quantum-wots | 19582 | 17502 | 3154 (16.1%) | 7684 (39.2%) | 2213 (11.3%) | 2273 (11.6%) | 1007 (5.1%) | 236 (1.2%) | 3015 (15.4%) | — | +| math-demo | 17348 | 13183 | 4591 (26.5%) | 6188 (35.7%) | 1212 (7.0%) | 2842 (16.4%) | 62 (0.4%) | 1513 (8.7%) | 940 (5.4%) | — | +| babybear-ext4 | 5471 | 3084 | 2973 (54.3%) | 1320 (24.1%) | 1141 (20.9%) | — | — | 31 (0.6%) | 6 (0.1%) | — | +| function-patterns | 3844 | 2794 | 1159 (30.2%) | 1415 (36.8%) | 111 (2.9%) | 724 (18.8%) | 20 (0.5%) | 350 (9.1%) | 65 (1.7%) | — | +| token-ft | 3154 | 2330 | 929 (29.5%) | 1139 (36.1%) | 89 (2.8%) | 612 (19.4%) | 16 (0.5%) | 294 (9.3%) | 75 (2.4%) | — | +| merge-locals-shapes | 3031 | 2236 | 882 (29.1%) | 1145 (37.8%) | 88 (2.9%) | 567 (18.7%) | 12 (0.4%) | 280 (9.2%) | 57 (1.9%) | — | +| private-helper-outputs | 2879 | 2080 | 886 (30.8%) | 1018 (35.4%) | 76 (2.6%) | 559 (19.4%) | 12 (0.4%) | 271 (9.4%) | 57 (2.0%) | — | +| assert-false-guard | 2033 | 1507 | 582 (28.6%) | 778 (38.3%) | 54 (2.7%) | 378 (18.6%) | 8 (0.4%) | 186 (9.1%) | 47 (2.3%) | — | +| loop-if-merged-locals | 2011 | 1481 | 588 (29.2%) | 750 (37.3%) | 60 (3.0%) | 378 (18.8%) | 8 (0.4%) | 186 (9.2%) | 41 (2.0%) | — | +| terminal-varlen-read | 1940 | 1415 | 584 (30.1%) | 688 (35.5%) | 55 (2.8%) | 375 (19.3%) | 7 (0.4%) | 178 (9.2%) | 53 (2.7%) | — | +| property-initializers | 1878 | 1354 | 578 (30.8%) | 674 (35.9%) | 50 (2.7%) | 362 (19.3%) | 8 (0.4%) | 175 (9.3%) | 31 (1.7%) | — | +| stateful | 1876 | 1352 | 578 (30.8%) | 674 (35.9%) | 50 (2.7%) | 362 (19.3%) | 8 (0.4%) | 174 (9.3%) | 30 (1.6%) | — | +| stateful-counter | 1875 | 1351 | 578 (30.8%) | 673 (35.9%) | 51 (2.7%) | 362 (19.3%) | 8 (0.4%) | 173 (9.2%) | 30 (1.6%) | — | +| stateful-bytestring | 1851 | 1335 | 569 (30.7%) | 660 (35.7%) | 48 (2.6%) | 357 (19.3%) | 8 (0.4%) | 171 (9.2%) | 38 (2.1%) | — | +| auction | 1794 | 1288 | 553 (30.8%) | 656 (36.6%) | 47 (2.6%) | 346 (19.3%) | 9 (0.5%) | 164 (9.1%) | 19 (1.1%) | — | +| token-nft | 1738 | 1234 | 549 (31.6%) | 630 (36.2%) | 43 (2.5%) | 332 (19.1%) | 9 (0.5%) | 157 (9.0%) | 18 (1.0%) | — | +| state-covenant | 1196 | 912 | 326 (27.3%) | 451 (37.7%) | 41 (3.4%) | 221 (18.5%) | 9 (0.8%) | 105 (8.8%) | 43 (3.6%) | — | +| branched-readonly-len | 1096 | 816 | 319 (29.1%) | 392 (35.8%) | 33 (3.0%) | 211 (19.3%) | 4 (0.4%) | 100 (9.1%) | 37 (3.4%) | — | +| conditional-data-output-stateful | 1015 | 740 | 308 (30.3%) | 356 (35.1%) | 27 (2.7%) | 197 (19.4%) | 4 (0.4%) | 97 (9.6%) | 26 (2.6%) | — | +| merge-locals-prop-updates | 1006 | 741 | 294 (29.2%) | 387 (38.5%) | 27 (2.7%) | 189 (18.8%) | 4 (0.4%) | 89 (8.8%) | 16 (1.6%) | — | +| add-raw-output | 1005 | 728 | 311 (30.9%) | 349 (34.7%) | 27 (2.7%) | 197 (19.6%) | 4 (0.4%) | 95 (9.5%) | 22 (2.2%) | — | +| add-data-output | 1004 | 729 | 308 (30.7%) | 351 (35.0%) | 27 (2.7%) | 197 (19.6%) | 4 (0.4%) | 95 (9.5%) | 22 (2.2%) | — | +| selector | 985 | 723 | 289 (29.3%) | 371 (37.7%) | 28 (2.8%) | 185 (18.8%) | 4 (0.4%) | 88 (8.9%) | 20 (2.0%) | — | +| branch-merged-locals | 963 | 699 | 292 (30.3%) | 354 (36.8%) | 24 (2.5%) | 185 (19.2%) | 4 (0.4%) | 88 (9.1%) | 16 (1.7%) | — | +| cond-write-multi-field | 957 | 693 | 292 (30.5%) | 345 (36.1%) | 26 (2.7%) | 185 (19.3%) | 4 (0.4%) | 89 (9.3%) | 16 (1.7%) | — | +| state-bigint-edges | 952 | 688 | 292 (30.7%) | 346 (36.3%) | 25 (2.6%) | 185 (19.4%) | 4 (0.4%) | 87 (9.1%) | 13 (1.4%) | — | +| intent-current-block-height | 944 | 682 | 289 (30.6%) | 338 (35.8%) | 26 (2.8%) | 185 (19.6%) | 4 (0.4%) | 88 (9.3%) | 14 (1.5%) | — | +| intent-prev-output-script | 942 | 680 | 289 (30.7%) | 339 (36.0%) | 25 (2.7%) | 183 (19.4%) | 5 (0.5%) | 87 (9.2%) | 14 (1.5%) | — | +| oversize-bigint-shift | 940 | 671 | 297 (31.6%) | 334 (35.5%) | 25 (2.7%) | 181 (19.3%) | 4 (0.4%) | 86 (9.1%) | 13 (1.4%) | — | +| state-ripemd160 | 931 | 668 | 291 (31.3%) | 336 (36.1%) | 23 (2.5%) | 179 (19.2%) | 4 (0.4%) | 84 (9.0%) | 14 (1.5%) | — | +| intent-output-p2pkh | 843 | 594 | 270 (32.0%) | 309 (36.7%) | 18 (2.1%) | 165 (19.6%) | 4 (0.5%) | 76 (9.0%) | 1 (0.1%) | — | +| covenant-vault | 795 | 550 | 262 (33.0%) | 290 (36.5%) | 13 (1.6%) | 151 (19.0%) | 5 (0.6%) | 73 (9.2%) | 1 (0.1%) | — | +| all-readonly-cleanstack | 777 | 539 | 252 (32.4%) | 288 (37.1%) | 14 (1.8%) | 146 (18.8%) | 4 (0.5%) | 72 (9.3%) | 1 (0.1%) | — | +| babybear | 647 | 351 | 370 (57.2%) | 99 (15.3%) | 156 (24.1%) | — | — | 7 (1.1%) | 15 (2.3%) | — | +| if-without-else-multi-temp | 226 | 219 | 11 (4.9%) | 125 (55.3%) | 15 (6.6%) | 24 (10.6%) | — | 25 (11.1%) | 26 (11.5%) | — | +| merkle-proof | 201 | 193 | 16 (8.0%) | 108 (53.7%) | 16 (8.0%) | 18 (9.0%) | 8 (4.0%) | 16 (8.0%) | 19 (9.5%) | — | +| bitwise-ops | 96 | 96 | — | 34 (35.4%) | 26 (27.1%) | — | — | 27 (28.1%) | 9 (9.4%) | — | +| cross-covenant | 46 | 45 | 2 (4.3%) | 24 (52.2%) | 3 (6.5%) | 8 (17.4%) | 2 (4.3%) | 4 (8.7%) | 3 (6.5%) | — | +| oracle-price | 44 | 38 | 8 (18.2%) | 21 (47.7%) | 5 (11.4%) | 2 (4.5%) | 2 (4.5%) | 4 (9.1%) | 2 (4.5%) | — | +| bounded-loop | 42 | 42 | — | 24 (57.1%) | 11 (26.2%) | — | — | 7 (16.7%) | — | — | +| arithmetic | 28 | 28 | — | 19 (67.9%) | 8 (28.6%) | — | — | 1 (3.6%) | — | — | +| if-without-else | 27 | 27 | — | 10 (37.0%) | 5 (18.5%) | — | — | 6 (22.2%) | 6 (22.2%) | — | +| shift-ops | 27 | 27 | — | 10 (37.0%) | 8 (29.6%) | — | — | 8 (29.6%) | 1 (3.7%) | — | +| if-else | 20 | 20 | — | 10 (50.0%) | 3 (15.0%) | — | — | 4 (20.0%) | 3 (15.0%) | — | +| escrow | 19 | 19 | — | 4 (21.1%) | 2 (10.5%) | — | 4 (21.1%) | 6 (31.6%) | 3 (15.8%) | — | +| multi-method | 19 | 19 | — | 2 (10.5%) | 5 (26.3%) | — | 2 (10.5%) | 6 (31.6%) | 4 (21.1%) | — | +| multisig | 17 | 17 | — | 10 (58.8%) | — | — | 1 (5.9%) | 6 (35.3%) | — | — | +| boolean-logic | 15 | 15 | — | 6 (40.0%) | 7 (46.7%) | — | — | 2 (13.3%) | — | — | +| go-dsl-bytestring-literal | 8 | 6 | 3 (37.5%) | — | 2 (25.0%) | 1 (12.5%) | — | 2 (25.0%) | — | — | +| basic-p2pkh | 5 | 5 | — | 1 (20.0%) | — | 1 (20.0%) | 2 (40.0%) | 1 (20.0%) | — | — | +| asm-raw-script | 1 | 1 | — | — | — | — | — | 1 (100.0%) | — | — | +_`asm-raw-script` is a single opaque `raw_bytes` span; `basic-p2pkh` is the 5-byte template +before constructor-arg splicing. Neither is a size target._ + +--- + +## 7. Method + +`analyzeScriptHex` walks the serialized script and charges every byte to exactly one +category; the sum is asserted equal to the script length. One rule is worth stating: a push +immediately consumed by `OP_PICK` / `OP_ROLL` is charged to **stack-shuffle**, not to +**const-push**. `bringToTop` emits `push(depth)` + `OP_PICK` as a pair +(`05-stack-lower.ts:1062`), and charging those depth bytes to constants would credit the +wrong optimizer with removing them. On `p256-wallet` that reclassification moves exactly +21,926 bytes, and it carries a second useful fact: all 21,926 of those depth pushes are a +single byte, so the EC macros never `OP_PICK` deeper than 16. A pooled constant parked below +a working set that shallow would cost 3 bytes to copy (2-byte depth push + `OP_PICK`) instead +of 34 — still a 31-byte saving per reduction. + +Categories: `const-push` (length-prefixed / PUSHDATA payloads), `small-int-push` +(OP_0/OP_1NEGATE/OP_1..16), `stack-shuffle` (DUP/DROP/NIP/OVER/PICK/ROLL/ROT/SWAP/TUCK/2DROP/ +2DUP/3DUP/2OVER/2ROT/2SWAP/IFDUP/DEPTH/TOALTSTACK/FROMALTSTACK plus PICK/ROLL depth pushes), +`arithmetic` (numeric, bitwise and comparison opcodes), `bytes` (CAT/SPLIT/SIZE/NUM2BIN/ +BIN2NUM/SUBSTR/LEFT/RIGHT/EQUAL/EQUALVERIFY), `crypto` (hashes, CHECKSIG family, +CODESEPARATOR), `control` (IF/NOTIF/ELSE/ENDIF/VERIFY/RETURN/NOP/CLTV/CSV), `other`. diff --git a/docs/experiments/script-size-optimizer-results.md b/docs/experiments/script-size-optimizer-results.md new file mode 100644 index 000000000..1503be53b --- /dev/null +++ b/docs/experiments/script-size-optimizer-results.md @@ -0,0 +1,574 @@ +# Script-size optimizer — results + +**Scope:** baseline instrumentation (Phase 0), an exact script-byte cost model (Phase 1), a +liveness stack scheduler (Phase 2), EC constant pooling, sign-lattice reduction sinking +(Phases 4–5), and a fixed-base comb (Phase 10). Straus/Shamir (Phase 9) was measured and +rejected — see §3.10. No witness hints (Phase 7). +**Companion documents:** [`script-size-optimization-baseline.md`](script-size-optimization-baseline.md), +[`stack-scheduler-design.md`](stack-scheduler-design.md). + +Everything below is measured on the 72 conformance fixtures with +`pnpm --filter runar-conformance run script-metrics -- --compare current,ec-pool,ec-sink,ec-comb +pnpm --filter runar-conformance run script-metrics -- --compare current,all`. + +--- + +## 1. Headline + +**`conformance/tests/p256-wallet`: 958,792 → 147,113 bytes (−84.7 %)** — the fixture the brief +calls its "959,592 B reference implementation". `p384-wallet`: 1,963,300 → 223,204 (−88.6 %). + +Across the whole corpus, with every flag on: **13,526,563 → 4,726,567 bytes (−65.1 %)**, +43 of 72 fixtures changed, **none grown**. + +| stage | p256-wallet | p384-wallet | corpus | +|---|---:|---:|---:| +| shipping | 958,792 | 1,963,300 | 13,526,563 | +| + EC constant pool | 304,463 (−68.2 %) | 463,435 (−76.4 %) | 6,285,154 (−53.5 %) | +| + reduction sinking | 179,890 (−81.2 %) | 272,678 (−86.1 %) | — | +| + fixed-base comb (NIST) | **147,113 (−84.7 %)** | **223,204 (−88.6 %)** | 4,906,225 (−63.7 %) | +| + fixed-base comb (secp256k1) | — | — | **4,726,567 (−65.1 %)** | + +The secp256k1 comb is the last stage: `ecMulGen` was the remaining +compile-time-known base still on the 257-round binary ladder. It goes 84,203 → +52,237 bytes (−38.0 %), which is 179,658 bytes off the corpus — the six +secp256k1 fixtures (`ec-primitives`, `ec-demo`, `ec-unit`, `schnorr-zkp`, +`convergence-proof`, and the two wallet fixtures' shared helpers). `ecMul` is +deliberately NOT combed: its base arrives at run time, and the comb's interval +argument does not cover an attacker-chosen point. + +**All seven tiers ship all four optimizations, byte-identically.** See §8. + +The liveness scheduler moves 34 fixtures but −0.0 % of corpus bytes; it is reported separately +in §2 because its value is qualitative, not numeric. + +Default output is unchanged at every stage and in every tier: all 72 fixtures reproduce their +checked-in `expected-script.hex` byte-for-byte +(`packages/runar-compiler/src/__tests__/golden-invariance.test.ts`), `script-size-check` is +72/72 ok, and every tier's own suite — including its crypto op-count goldens — still passes. +Every optimization is opt-in. + +## 2. What was built + +| Phase | Deliverable | Files | +|---|---|---| +| 0 | Byte-category instrumentation + benchmark runner | `packages/runar-compiler/src/metrics/script-metrics.ts`, `conformance/runner/script-metrics.ts` | +| 1 | Exact script-byte cost model | `packages/runar-compiler/src/metrics/cost-model.ts` | +| 2a | Liveness scheduler (`--stack-scheduler=liveness`) | `packages/runar-compiler/src/passes/05-stack-lower.ts` | +| 2b | EC constant pool (`--ec-constant-pool`) | `packages/runar-compiler/src/passes/ec-codegen.ts`, `p256-p384-codegen.ts` | +| 4–5 | Sign lattice + reduction sinking (`--ec-reduction-sinking`) | `ec-codegen.ts` (`Dom`, `ECTracker.dm`), `p256-p384-codegen.ts` | +| 10 | Fixed-base comb (`--ec-fixed-base-comb`) | `packages/runar-compiler/src/passes/comb.ts`, `p256-p384-codegen.ts` | + +### Phase 1 — the cost model is exact, not an estimate + +```ts +estimateScriptBytes(ops) === emitMethod({ ops, … }).scriptHex.length / 2 +``` + +is asserted for every method of every fixture that ships a `.runar.ts`, before and after +peephole (`__tests__/cost-model.test.ts`, 101 assertions). That exactness turned out to matter +more than expected — see §5. + +### Phase 2b — EC constant pool + +`ECTracker` gained a pooled slot per curve constant. `pushConst(slot, value, name)` compares +the emitted cost of `OP_PICK`-ing the slot against re-pushing the literal and takes the +cheaper, so pooling can never make an individual call site larger. Every EC emitter that does +more than a couple of reductions parks the field prime (and, for the ladders, the group order) +on entry and releases it on exit. + +| emitter | current | pooled | | +|---|---:|---:|---:| +| `emitVerifyECDSA_P256` | 974,024 | 319,693 | −67.2 % | +| `emitVerifyECDSA_P384` | 1,987,394 | 487,527 | −75.5 % | +| `emitP256Mul` | 459,746 | 150,512 | −67.3 % | +| `emitP384Mul` | 927,350 | 227,044 | −75.5 % | +| `emitEcMul` (secp256k1) | 428,676 | 140,242 | −67.3 % | +| `emitEcAdd` | 25,426 | 8,791 | −65.4 % | + +### Phase 2a — liveness scheduler + +Two transformations, both gated on `schedulerMode: 'liveness'`: + +1. **Alt-stack result spilling.** A result whose next use is not the following binding is + parked with `OP_TOALTSTACK`, keeping the operands a chain reads repeatedly at depth 0/1. + The whole spill group is restored in one go before the first binding that needs any of it — + which puts the values back in production order, the order an ANF accumulation reads them. +2. **Commutative operand ordering.** For `+ * === !== & | ^` (excluding `+` on ByteString, + which is `OP_CAT`), the operands are materialized in whichever order the cost model scores + cheaper. + +Selection is per method: both schedules are lowered and the cheaper one — measured after +peephole — is kept. "The scheduler never grows a method" is therefore structural, not a hope. + +| fixture | current | liveness | | +|---|---:|---:|---:| +| `arithmetic` | 28 | 18 | **−35.7 %** | +| `bounded-loop` | 42 | 37 | −11.9 % | +| `if-without-else-multi-temp` | 244 | 243 | −0.4 % | +| ~30 mid-size fixtures | 795–17,348 | | −0.1 % to −0.2 % | +| EC / SLH-DSA / SHA-256 / BLAKE3 | | unchanged | 0 % | + +18 bytes for `arithmetic` is the hand-derived optimum recorded in +[`stack-scheduler-design.md`](stack-scheduler-design.md) §2.4 — the scheduler reached it +independently. + +--- + +## 3. Answers to the brief's questions (for this slice) + +### 3.1 What is generic? + +- **The byte-cost model.** Nothing else in the compiler could compare two lowerings by the + metric that matters. It should become permanent infrastructure regardless of which + optimizations ship. +- **Cost-model-driven selection.** Lowering a method both ways and keeping the smaller is + cheap, obviously correct, and removes a whole class of "the heuristic guessed wrong" + regressions. Recommended as the general pattern for any future scheduling change. +- **Commutative operand ordering.** Small, local, and applies to every contract. +- **Constant pooling as a *policy*** — "if a value is materialized more than + `pool_cost / (push_cost − pick_cost)` times, park it" — is fully generic even though this + implementation applies it inside the EC macros. + +### 3.2 What needs explicit programmer intent? + +Nothing in this slice. Both flags are compiler-internal and semantics-preserving. Witness +hints (brief Phase 7) remain the first thing that genuinely requires a contract-level opt-in. + +### 3.3 What belongs in crypto libraries / intrinsics? + +The pooling *mechanism* had to live in `ECTracker`, because the crypto emitters build their +own stack layout and never pass through `05-stack-lower.ts`. That boundary is the single most +important architectural fact this slice established — see §4. + +### 3.4 What is P-256-specific? + +Nothing that was built. `POOL_FIELD_P` / `POOL_GROUP_N` are parameterized by `CurveParams` / +`GroupParams`; the same code path serves secp256k1, P-256 and P-384, and the −67 % / −76 % +results differ only because P-384's prime is a 50-byte push instead of 34. + +### 3.5 What should not be implemented? + +- **Eager dead-slot retirement.** The plan assumed dead values sink and inflate later + `OP_PICK` depth pushes. Measurement killed it: of 387,749 `OP_PICK`/`OP_ROLL` sites in the + corpus, **657 are deeper than 16**; typical depths are 2–5, and depths 0–2 are single-byte + opcodes anyway. Every drop would cost 1–3 bytes to save approximately nothing. +- **Straus/Shamir joint double-scalar multiplication.** Measured at ~700 B/bit-position against + 690 for two independent ladders, because a joint ladder forfeits the incomplete-addition + argument and completing the addition costs +299 B/round. See §3.10. +- **A forked scheduler pass.** A `schedulerMode` field on `LoweringContext` kept one code + path and let the existing 66 structural assertions in `05-stack-lower.test.ts` cover both + modes. A 5,500-line fork would have drifted from the branch/loop invariant fixes landing on + the original. + +### 3.6 Can Rúnar approach the ~29.6 kB P-256 result? + +Not yet — `p256-wallet` is at 304,463 bytes, 10× the target. But the remaining bytes are in +exactly two buckets, neither is mysterious, and the size of the next step has been **measured** +rather than projected (see §3.7). + +| category | bytes | share | +|---|---:|---:| +| stack-shuffle | 214,904 | 70.6 % | +| arithmetic | 82,863 | 27.2 % | +| small-int push | 2,698 | 0.9 % | +| const-push | 1,753 | 0.6 % | +| everything else | 2,245 | 0.7 % | + +Constant pushes went from 697,019 bytes to 1,753 — 99.7 % eliminated. What is left is the +modular-reduction sequence itself, repeated ~20,000 times: + +``` +pick p 2 bytes (was a 34-byte push) +OP_2DUP OP_MOD OP_ROT OP_DROP OP_OVER OP_ADD OP_SWAP OP_MOD 8 bytes +``` + +The six-opcode tail after the first `OP_MOD` exists only because `OP_MOD` takes the sign of +the dividend. Where the dividend is provably non-negative the tail is dead weight. + +### 3.7 Measured ceiling for reduction sinking + +Rather than project the next step, it was measured directly: `fieldMod` / `cFieldMod` were +patched behind a throwaway env switch to emit the short form, the corpus was re-measured, and +the patch was discarded. Two variants: + +- `nonneg` — short reduction where the dividend is provably ≥ 0 (`fieldMul`, `fieldSqr`, + `fieldAdd`, `fieldMulConst`), and for `fieldSub` the cheap `a − b + p` then one `OP_MOD` + (6 bytes instead of 10). This is what a correct analysis could actually emit. +- `all` — short reduction everywhere. Semantically wrong; the absolute floor. + +| fixture | shipping | + pool | **+ pool + sinking** | floor (`all`) | +|---|---:|---:|---:|---:| +| `p256-wallet` | 958,792 | 304,463 | **179,796** (−81.2 %) | 164,302 | +| `p384-wallet` | 1,963,300 | 463,435 | **272,584** (−86.1 %) | 249,410 | +| `ec-primitives` | 1,332,782 | 433,880 | **258,160** (−80.6 %) | 237,229 | +| `ec-unit` | 479,716 | 157,129 | **93,585** (−80.5 %) | 86,576 | + +**The sound variant captures 89 % of the theoretical floor** (124,667 of a possible 140,161 +bytes on `p256-wallet`), so the analysis does not need to be clever about subtraction — the +cheap `+p` form is nearly free. + +Note the pooling and the sinking are complementary, not independent: without pooling, the +cheap `fieldSub` form pushes the prime *twice* and `p256-wallet` gets **larger** (958,792 → +999,371). Sinking only pays once the prime is a 2-byte pick. + +### 3.8 The analysis needed is a sign lattice, not a modular-domain lattice + +The `nonneg` variant passes **256 EC oracle assertions** — OpenSSL signatures on both curves, +`ec-on-curve-canonicity`, `ec-degenerate-add`, `ec-mul-scalars`, `p256-p384-scalars`, +`p256-p384-ecdsa-verify`. It would have shipped looking green. + +It is nonetheless unsound, in a narrow and precisely characterised window: + +- The **multiply / add / mulconst** paths need only *dividend ≥ 0*. That is already implied by + `OP_BIN2NUM` of unsigned coordinate bytes, by products of non-negatives, and by sums of + non-negatives. Roughly 70 % of all reductions qualify under a trivial sign analysis. +- The **subtract** path needs the strictly stronger *subtrahend < p*, and that is NOT implied + by "decoded from 32 unsigned bytes". + +The concrete divergence, found by construction: + +``` +ecAdd((0, 1), (2^256 − 1, 1)) + shipping : fffffffffffffffffffffffffffffffffffffffffffffffffffffffdfffff85f… + sinking : 00000000000000000000000000000000000000000000000001000003d0… + ^ 0x1000003d0 = 2^32 + 977 = 2^256 − p +``` + +It bites only when the subtrahend is non-canonical *and* the minuend is smaller than the gap +between `p` and `2^256` — a ~2^32-wide window out of 2^256, reachable only through the +unguarded bare builtins (`ecAdd`, `p256Add`, `p256Mul` take raw coordinates; +`verifyECDSA_*` and `onCurve` run a canonicity guard first). + +So the requirement for Phase 4/5 is sharper than "modular-domain analysis": a **sign lattice** +plus a **`< p` bit that only subtrahends need**. That is a materially smaller piece of work +than a full domain lattice, and it is the difference between an optimization that passes 256 +oracle assertions and one that is actually correct. + +### 3.9 Trajectory + +``` +958,792 shipping +304,463 + EC constant pool MEASURED +179,890 + sign lattice + reduction sinking MEASURED +147,113 + fixed-base comb for u1·G MEASURED + - secp256k1 comb wiring not done + - witness-hint modular inverse not done (Phase 7) + ~30,000 <- the reference trajectory's comb stage (34,470 B) +``` + +Everything down to 147,113 is measured. At that point the split is 70.0 % stack-shuffle / +25.4 % arithmetic, and constant pushes are down from 697,019 bytes to 2,116 — 99.7 % +eliminated. What is left is the `u2·Q` ladder plus the operand traffic inside it, which is why +the field-element IR in §4 is now the structural item rather than another algorithm. + +### 3.10 Straus/Shamir was measured, then rejected; the comb was not + +The obvious next step after reduction sinking is a joint ladder for `u1·G + u2·Q`. It does not +pay, and the reason is the most transferable finding in this document. + +The ladder's speed comes from the **cheap incomplete** mixed add. +`buildJacobianAddOrDoubleInline` justifies using it everywhere but the final step with an +interval argument over `c_i mod n`, which works because the accumulator is `c_i·P` and the +addend is `P` — one generator, coefficient fixed by the scalar and the step index. A joint +ladder makes the accumulator `c_i·G + d_i·Q` with **Q supplied by the caller**: an attacker +choosing `Q = k·G` solves `c_i + d_i·k ≡ 1 (mod n)` for `k`, one equation in one free +variable, so the exception becomes reachable at an arbitrary step. The argument does not +transfer. + +Completing the addition costs a measured **+299 B/round** — a whole ladder goes 90,610 → +167,410 bytes, +84.8 %: + +| scheme | B/bit-position | +|---|---:| +| current — two independent ladders | 690 | +| joint + 4-entry table + incomplete add | ~400 | +| **joint + 4-entry table + complete add** | **~700** | +| shared doubling, two incomplete adds | ~540 | +| shared doubling, two complete adds | ~1,138 | + +Every joint variant is a loss unless it keeps the incomplete formula, and keeping it means +replacing an unconditional guarantee with a DLP-hardness assumption inside a signature +verifier. Rejected. + +**The comb keeps a single generator, so the argument survives** — which is why the work went +there instead. `u1·G` gets a comb (the base is a compile-time constant); `u2·Q` keeps the +ladder, because Q arrives in the witness. + +| emitter | ladder | comb | | +|---|---:|---:|---:| +| `emitP256MulGen` | 90,676 | 54,117 | −40.3 % | +| `emitP384MulGen` | 136,599 | 81,418 | −40.4 % | +| `emitVerifyECDSA_P256` | 195,120 | 158,560 | −18.7 % | + +Two things the soundness work turned up, both in `passes/comb.ts`: + +- **The ladder's `+3n` offset is not portable.** `combParams` searches for the offset `m` with + `m·n ≥ 2^(w·d−1)` and `(m+1)·n − 1 < 2^(w·d)`, which is what keeps the first comb digit + non-zero and so the accumulator off infinity. For P-256 at w=3 it returns `+3n`, matching the + ladder. **For P-384 at w=3 it returns `+5n`.** Reusing `+3n` there would have let the leading + digit vanish. +- **The safety analysis must be allowed to fail.** `combSafeRounds` proves per round that the + pre-add accumulator cannot be `0`, `+T[j]` or `−T[j]` mod n over the whole scalar domain; + rounds it cannot prove get the complete add-or-double form. For P-256 at w=3 it proves 81 of + 86, so the fallback costs ~1.2 kB. A checker that proved every round would be broken rather + than clever, and the tests assert it refuses the last ones. + +The window width is chosen by `estimateScriptBytes` over w ∈ {2,3,4}, not hardcoded — the +measured optimum is w=3, and the `2^w` selection logic overtakes the saving by w=5. + +--- + +## 4. The architectural finding + +**Crypto codegen does not pass through the generic backend.** `ec-codegen.ts`, +`p256-p384-codegen.ts`, `sha256-codegen.ts`, `slh-dsa-codegen.ts`, `babybear-codegen.ts` and +their peers emit Stack IR directly through hand-written `ECTracker`-family trackers. +`05-stack-lower.ts` never sees those ops. + +Consequences, all confirmed by measurement: + +- A generic scheduler cannot move a single byte of the EC, SLH-DSA, SHA-256 or BLAKE3 + fixtures — 13.4 MB of the 13.5 MB corpus. +- Conversely, the constant pool cannot help ordinary contracts. +- Any future "generic" optimization needs an answer to which of the two worlds it lives in + before its value can be estimated. + +The peephole optimizer is the one pass that spans both, because it runs on the whole method's +Stack IR after lowering. That makes new peephole rules unusually high-leverage — and it is +already a fix-point driver (brief Phase 3 is done; see the baseline document §5). + +--- + +## 5. What the oracles caught + +The scheduler's first working version **miscompiled `if-without-else-multi-temp`**: it +produced a script that ran to completion, left a truthy top-of-stack, and **accepted a witness +the shipping compiler rejects**. Byte counts, golden comparisons for the other fixtures, and +the compiler's own 4,099 unit tests all passed while this was true. + +What caught it was `conformance/witnesses/` replayed through `runDifferentialExecution` — +source-semantics interpreter versus deployed script, on witnesses the repo had already +committed to, with at least one accept and one reject per fixture. Two of 86 cases failed. + +Root cause: restoring spilled values immediately before an `if` leaves the parent stack in a +shape `lowerIf`'s arm reconciliation was not written for. The fix is a precondition — spilling +is refused in any scope that still has control flow ahead of it — not an attempt to make the +two agree. That costs the scheduler its wins on `oracle-price` and `cross-covenant` (2 and 1 +bytes) and keeps everything else. + +Two process notes worth carrying forward: + +- A bisect that "passes" can be vacuous. Disabling commutative reordering made the failure + disappear, which looked like an acquittal for spilling — but with reordering off, the + method-level cost guard simply preferred the baseline schedule and *no spilling happened at + all*. Confirming that the variant actually changed the bytes was what made the second + bisect meaningful. +- The per-site cost model was wrong until it was made peephole-aware. Two consumed operands at + depths 1 and 0 emit `OP_SWAP OP_SWAP`, which the `swap-swap` rule deletes outright — free — + while the "cheaper-looking" order emits one real `OP_SWAP`. Scoring candidate op sequences + through `optimizeStackIR` before comparing them took `arithmetic` from 24 bytes to 18. + +--- + +## 6. Recommended compiler changes + +**Adopt now (independent of any optimization):** + +1. `packages/runar-compiler/src/metrics/cost-model.ts` and `script-metrics.ts` as permanent + infrastructure, with the exactness sweep as a standing test. +2. `conformance/runner/script-metrics.ts` alongside the existing `script-size-check.ts` — the + latter answers "did anything grow?", the former "where did the bytes go?". + +**Adopt after a 7-tier port, in this order:** + +3. **The EC constant pool.** The largest single win (−53.5 % of the corpus), curve-parameterized + rather than curve-specific, proved equivalent against OpenSSL signatures on both curves plus + every SEC1 rejection case (`ec-constant-pool-equivalence.test.ts`, 44 cases). +4. **Sign lattice + reduction sinking.** −124 kB more on `p256-wallet`, within 94 bytes of the + measured ceiling. Depends on (3): the cheap subtraction references the prime twice, so + without a pooled slot it is a regression. +5. **Fixed-base comb.** −33 kB more on `p256-wallet`, −49 kB on `p384-wallet`. Carries the + heaviest proof obligation of the three (`comb.ts`), and the only one that needed a + curve-specific fact re-derived rather than reused. + +Each means porting to `compilers/{go,rust,python,ruby,zig,java}`, regenerating the 9 EC +goldens, re-stamping `conformance/script-size-baseline.json` (the shrink trips its 50 % guard +by design), and adding provenance entries. + +**Keep experimental:** + +4. The liveness scheduler. It is correct and never grows a fixture, but outside small + arithmetic contracts it buys 0.1 %. Not worth a 7-tier port on its own; worth keeping as a + gated mode so the next scheduling idea has somewhere to land. + +**Do next (highest value first):** + +6. **secp256k1 comb wiring.** `comb.ts` is curve-generic, but the emitter uses the NIST + codegen's `a = −3` doubling, so `ec-codegen.ts` needs its own. `ec-primitives`, `ec-demo`, + `schnorr-zkp`, `ec-unit` and `convergence-proof` — 4.5 MB of fixtures — are still on the + ladder. Roughly another −35 % on them, and the analysis is already written. +7. **A typed field-element IR under the crypto emitters.** At 147,113 bytes `p256-wallet` is + 70.0 % stack traffic, which no pass can currently reach because the crypto emitters build + their own layout (§4). This is larger than any single item above and would let the next + three be written once instead of seven times. +8. **Witness-hint modular inverse** (Phase 7) — removes three unrolled Fermat ladders + (382 + 423 + 286 field multiplications per P-256 verify). The first item requiring an + explicit soundness argument rather than a translation-validation proof. + +--- + +## 7. Reproducing + +```bash +# Sizes +pnpm --filter runar-conformance run script-metrics # baseline table +pnpm --filter runar-conformance run script-metrics -- --fixture p256-wallet --detail +pnpm --filter runar-conformance run script-metrics -- --compare current,liveness,ec-pool,both + +# Correctness +npx vitest run packages/runar-compiler/src/__tests__/cost-model.test.ts # model is exact +npx vitest run packages/runar-compiler/src/__tests__/golden-invariance.test.ts # default unchanged +npx vitest run packages/runar-compiler/src/__tests__/ec-constant-pool.test.ts +npx vitest run packages/runar-compiler/src/__tests__/liveness-scheduler.test.ts +npx vitest run packages/runar-testing/src/__tests__/ec-constant-pool-equivalence.test.ts +npx vitest run packages/runar-testing/src/__tests__/liveness-scheduler-equivalence.test.ts +npx vitest run packages/runar-testing/src/__tests__/scheduler-headroom.test.ts +npx vitest run packages/runar-testing/src/__tests__/ec-reduction-sinking.test.ts +npx vitest run packages/runar-testing/src/__tests__/ec-comb.test.ts +npx vitest run packages/runar-compiler/src/__tests__/comb-table.test.ts + +# CLI +node --import tsx packages/runar-cli/src/bin.ts compile --hex \ + --ec-constant-pool --ec-reduction-sinking --ec-fixed-base-comb --stack-scheduler liveness +``` + +## 8. The seven-tier port + +All four optimizations ship in TypeScript, Go, Rust, Python, Zig, Ruby and Java, behind the same +three flags in every tier: + +``` +--ec-constant-pool --ec-reduction-sinking --ec-fixed-base-comb +``` + +### 8.1 Why a new gate was needed + +The flags default off, so the ordinary conformance suite — which compiles with defaults — cannot +see them at all. Seven tiers could each ship a *different* `--ec-constant-pool` and the suite would +stay green. + +That is not a hypothetical. The flags change which reduction form is emitted and which addition +formula each ladder round uses. A tier that ports the constant pool but not the sign lattice's +`Reduced` precondition produces a script that is **smaller**, passes every test it has, and is +wrong on `ecAdd((0,1), (2^256−1,1))` — the counterexample from §3.8. + +`conformance/ec-flag-parity/expected.json` pins the exact script the TypeScript reference emits for +all 24 EC emitters under all 4 flag combinations, as a byte count plus a SHA-256. It is derived, +never hand-edited, and `parity.test.ts` re-derives it in-process so it cannot go stale. + +### 8.2 What the gate caught + +Six real defects, none of which any tier-local test could have found: + +| tier | defect | symptom | +|---|---|---| +| Go | `cDecomposePoint` never recorded that BIN2NUM of an unsigned coordinate is `NonNegative` | NIST tier emitted a **larger** script under `--ec-reduction-sinking` (P256Mul 94,137 vs 90,610) | +| Go | six NIST entry points never released the pooled prime | 2 bytes per emitter | +| Go | the three `+n` pushes in `cEmitMul` were routed through the pool | strictly smaller (−96 B/ladder) but the reference pushes literals — the tiers would have diverged | +| Rust | `compile_from_source_str_with_options` built backend options with `..Default::default()` | `--ec-constant-pool` reached the frontend and vanished; the compile succeeded and emitted the unoptimized script | +| Python | the second (`u2·Q`) ladder in `_c_emit_verify_ecdsa` was called without options | verifier came out at 628,923 bytes where the reference emits 319,693 — the flag applied to exactly half of it | +| Python / Ruby / Java | verifier never released its two pooled constants | 4 bytes | + +Two of those (the Go `NonNegative` fact and the Rust `..Default::default()`) are the interesting +ones: both produce a compiler that *works*, passes its suite, and silently does not do what the +flag says. + +### 8.3 Tier-specific findings + +**Rust** pushed the EC constants as pre-encoded script-number BYTE blobs because they exceed +`i128`. `PushValue::Int` carries a `BigInt`, so the blob was never necessary — and it cost real +things: a `Bytes` push is invisible to the peephole's constant folding (which is why `k + 3n` had +to be hand-folded there and only there) and invisible to the sign lattice. Switching to `Int` and +emitting the reference's three `+n` steps left the shipped bytes unchanged and brought the raw +op-count goldens into agreement with Go, where they had been 4 ops short. + +**Rust** also carried a hand-copy of `ECTracker` in `p256_p384.rs`, commented "duplicated since +it's private there". Tolerable for 200 lines of stack bookkeeping; not tolerable once the tracker +carries a sign lattice whose transfer functions decide which reduction shape is emitted — two +copies is two chances to prove `Reduced` where only `NonNegative` holds. Deleted; the tiers share +one tracker. + +**Zig** is the one tier that cannot assert the raw hash, for two reasons, both differences in +*spelling* that its own peephole normalises away before the script ships. + +First, `k + 3n`. Its peephole reassociates only `i64` `push_int` chains, and a 256/384-bit constant +is a `push_data` blob in its IR, so the offset stays pre-folded there while the reference emits +three `+n` steps its own peephole collapses. The two curve families differ in *when* this bites: +the secp256k1 reference routes those three pushes through the constant pool, so Zig matches it +raw-for-raw as soon as `--ec-constant-pool` is on and diverges only under `off`; the NIST reference +(`cEmitMul`) pushes raw literals under **every** flag combination, so the divergence there is +constant — −70 B per P-256 ladder (n encodes to 33 bytes: `3*(1+33)+3` against `(1+33)+1`) and +−102 B per P-384 ladder (49 bytes: 153 against 51). Copying either tier's conditional into the +other is a live way to get this wrong. + +Second, MINIMALDATA on one-byte blobs. The reference writes `push [0x02]` as `OP_2`; Zig's push +encoder always writes the length-prefixed form. Two such pushes per site — the `0x02` / `0x03` +prefix pair in `decompressPubKey` and in the parity select of `pNNNEncodeCompressed` — so `+2` on +`VerifyECDSA_*` and on `pNNNEncodeCompressed`. This one is pre-existing and flag-independent: it is +there with every flag off, on an emitter no flag reaches. + +Zig therefore gates on the raw byte *count* with both divergences priced exactly +(`allowedDelta`, which returns zero for every emitter they do not name), plus end-to-end hex +identity through the CLI. The fixture carries a `postPeephole` measurement alongside the raw one +for exactly this. + +Zig's cost model also differs by construction: its `roll` / `pick` ops carry the depth themselves +and the emitter writes the depth push while emitting them, so they cost `sizeOfScriptNumber(depth) ++ 1` where every other tier charges 1 and counts a separate `push`. Same bytes; the cost-model +test is what keeps the two spellings honest. + +**Ruby** needed an explicit extended-Euclid `mod_inverse` in `comb.rb`: `Integer#pow` rejects a +negative exponent, so there is no `x.pow(-1, m)` shortcut as in Python. + +### 8.4 End-to-end + +For the same `ecMulGen` contract compiled with all three flags, **all seven compilers emit +byte-identical hex** (50,157 bytes, down from 424,567 with the flags off). + +On the NIST side the same holds for the two real conformance fixtures. `p256-wallet` and +`p384-wallet` both go through `verifyECDSA_*`, so they exercise every emitter the flags touch — +the two ladders, the decompression sqrt chain, `groupInv`, `affineAdd`. The Zig and TypeScript +CLIs agree **byte for byte under all four flag combinations**: + +| fixture | off | pool | sink | comb | +|---|---:|---:|---:|---:| +| `p256-wallet` | 958,792 | 304,463 | 179,890 | **147,113** (−84.7 %) | +| `p384-wallet` | 1,963,300 | 463,435 | 272,678 | **223,204** (−88.6 %) | + +That is the check that closes the two raw-spelling divergences above: the peephole normalises both +away, so the shipped script is identical even where the pre-peephole op stream is not. + +Per-tier gates: + +| tier | parity test | assertions | +|---|---|---| +| TypeScript | `conformance/ec-flag-parity/parity.test.ts` | fixture re-derived in-process | +| Go | `codegen/ec_flag_parity_test.go` | 120 subtests | +| Rust | `tests/ec_flag_parity_tests.rs` | 24 emitters × 4 variants, ×2 tests | +| Python | `tests/test_ec_flag_parity.py` | 48 | +| Ruby | `test/codegen/test_ec_flag_parity.rb` | 24 emitters × 4 variants | +| Java | `codegen/EcFlagParityTest` | 3 tests over 24 × 4 | +| Zig | `passes/helpers/ec_flag_parity_test.zig` | 7 tests over 19 emitters × 4, byte counts + width selection per curve | + +Every tier additionally pins that the flags OFF reproduce the shipping hash for every emitter, so +the experimental work cannot move default output. + +### 8.5 What is still not done + +The ports are complete and gated, but this is still not a merge candidate: + +- The checked-in EC goldens were stamped under flags-off and are unchanged, which is correct — but + nothing regenerates them for a flags-on world, and `conformance/script-size-baseline.json` would + trip its 50 % shrink guard by design if the flags ever became default. +- No golden-provenance entries exist for a flags-on stamping, because nothing has been stamped. diff --git a/docs/experiments/stack-scheduler-design.md b/docs/experiments/stack-scheduler-design.md new file mode 100644 index 000000000..4905e4b97 --- /dev/null +++ b/docs/experiments/stack-scheduler-design.md @@ -0,0 +1,393 @@ +# Stack scheduler — current behaviour, measured inefficiencies, and a design + +**Status:** design, pre-implementation. Companion to +[`script-size-optimization-baseline.md`](script-size-optimization-baseline.md). +**Scope:** the generic ANF → Stack lowering in +`packages/runar-compiler/src/passes/05-stack-lower.ts`. Crypto macro modules +(`ec-codegen.ts`, `p256-p384-codegen.ts`, `sha256-codegen.ts`, `slh-dsa-codegen.ts`, …) +emit Stack IR directly through their own `ECTracker`-family trackers and are **out of +scope for this document** — they are addressed separately by constant pooling. + +--- + +## 1. What the current lowering does + +### 1.1 The symbolic stack + +`class StackMap` (`05-stack-lower.ts:166`) is an array of `(string | null)`, index 0 = +bottom. `null` marks an anonymous slot (e.g. the discard half of an `OP_SPLIT`). + +```ts +findDepth(name): number // :192 — searches TOP-DOWN, returns depth-from-top +removeAtDepth(d) // :207 — the ROLL effect +peekAtDepth(d) // :217 — the PICK effect +clone() // :226 — used to fork a branch arm +``` + +`findDepth` resolves to the *shallowest* match, so rebinding a name pushes a new slot with +the same name and the old one becomes dead-but-resident. `LoweringContext` (`:547`) owns one +`StackMap`, the emitted `ops`, `maxDepth`, and `outerProtectedRefs`. The constructor (`:578`) +seeds the map with parameter names, first param at the bottom. + +### 1.2 Liveness + +The entire liveness analysis is `computeLastUses(bindings)` (`:276`): one forward scan +mapping each referenced name → the highest binding index that references it. Array-literal +indirection is patched through (`:284`) so element temps stay live to the array's consumer. + +Consumption is decided by two predicates: + +```ts +isLastUse(ref, i, lastUses) // :1304 last <= i +operandConsume(ref, operands, i, …) // :1328 isLastUse AND appears once in this operand list +``` + +`operandConsume` needs the occurrence check because `t := x + x` must PICK at both positions. + +Outer-scope values are pinned by *forcing* their last use past the end: +`lastUses.set(ref, bindings.length)` (`:1154`, and `:2196` for branch arms). That is the only +pinning mechanism. + +### 1.3 Materialization — `bringToTop(name, consume)` (`:1038`) + +Every operand goes through this one function: + +| depth | consume (last use) | !consume (still live) | +|---:|---|---| +| 0 | nothing | `OP_DUP` | +| 1 | `OP_SWAP` | `OP_OVER` | +| 2 | `OP_ROT` | `push 2; OP_PICK` | +| d | `push d; OP_ROLL` | `push d; OP_PICK` | + +The depth 0/1/2 peepholes are inlined here, which is why the peephole rules `roll1-to-swap`, +`roll2-to-rot`, `pick0-to-dup`, `pick1-to-over` almost never fire on the main path. + +### 1.4 What is *not* done + +- **No operand reordering.** `lowerBinOp` (`:1507`) always materializes left then right, + even for commutative operators, and `lowerCall` (`:1839`) always walks args in order. +- **No proactive dead-value removal.** `computeLastUses` knows exactly when a temp dies; + nothing acts on it. Dead slots linger until `cleanupExcessStack()` (`:621`) NIPs the method + tail, or until a branch's `drainBranchPrivateResidue` (`:1112`) sweeps them. +- **No alt stack.** The generic lowerer emits `OP_TOALTSTACK` in exactly three places + (`:3013`, `:3105` state serialization, and the `divmod` intrinsic at `:4517`). Never for + scheduling. `docs/compiler-architecture.md` claims otherwise — that paragraph is + aspirational and should be corrected. +- **No cost model.** Choices are structural, never compared by emitted bytes. + +### 1.5 Branches and loops, in one line each + +`lowerIf` (`:2092`) forks a cloned `StackMap` per arm, pins every parent value that outlives +the `if`, reconciles asymmetric consumption, trims to the declared `results` layout, pads the +shallower arm with 1-byte empty pushes (`:2400`, `:2405`), and asserts equal arm depth at +`OP_ENDIF`. `lowerLoop` (`:2665`) fully unrolls, recomputing `lastUses` per iteration and +pinning loop-carried refs on every non-final iteration. + +Any scheduler change must leave these invariants intact — they are enforced by hard throws +(`branch result layout mismatch` at `:2346`, the Layer B/C depth assertions at `:2417`, +`:2640`), not by tests alone. + +--- + +## 2. Measured inefficiencies + +All figures from the 72 checked-in goldens via +`pnpm --filter runar-conformance run script-metrics`. + +### 2.1 Stack traffic is 23 % of the corpus — and 35–68 % of ordinary contracts + +| fixture | bytes | stack-shuffle share | +|---|---:|---:| +| `arithmetic` | 28 | **67.9 %** | +| `bounded-loop` | 42 | 57.1 % | +| `multisig` | 17 | 58.8 % | +| `if-without-else-multi-temp` | 226 | 55.3 % | +| `stateful-counter` | 1,875 | 35.9 % | +| `token-ft` | 3,154 | 36.1 % | +| `math-demo` | 17,348 | 35.7 % | + +### 2.2 The dead-slot hypothesis is **refuted** + +The obvious theory — dead values sink under live ones, so later accesses pay a deeper +`push(depth)`, and crossing depth 16 turns a 1-byte depth push into 2 — does not survive +measurement. Across all 387,749 `OP_PICK`/`OP_ROLL` sites in the corpus: + +``` +depth ≤ 16 : 387,092 (1-byte depth push) +depth > 16 : 657 (2-byte depth push) +deepest anywhere: 75 +``` + +Typical depths are 2–5. On `p256-wallet` every one of the 21,926 depth pushes is a single +byte. **Eager dead-slot retirement would cost 1–3 bytes per drop to save essentially nothing, +and is dropped from the design.** This is the main correction to the original plan. + +### 2.3 Where the shuffle bytes actually are + +`p256-wallet`: 173,967 shuffle bytes, of which only 43,852 are `PICK`/`ROLL` (op + depth +push). The remaining ~130 kB is bare one-byte shuffles — `OP_ROT`×30,406, `OP_SWAP`×27,342, +`OP_OVER`×23,417, `OP_DROP`×22,558, `OP_2DUP`×20,453 — and ~100 kB of that is the fixed +five-shuffle tail inside `cFieldMod`, i.e. crypto-macro output, not this scheduler. + +For the ordinary contracts the picture inverts: `arithmetic` spends 16 of 28 bytes on stack +access, split 8 bytes of depth pushes and 8 bytes of `PICK`/`ROLL`/`ROT`/`SWAP`. + +### 2.4 A measured headroom number + +`conformance/tests/arithmetic` is the only fixture whose bytes are produced *entirely* by +this pass. Source: + +```ts +const sum = a + b; const diff = a - b; const prod = a * b; const quot = a / b; +assert(sum + diff + prod + quot === this.target); +``` + +Emitted today (28 bytes, `00` = constructor placeholder): + +``` +OP_2DUP OP_ADD sum [a,b,sum] +OP_2 OP_PICK OP_2 OP_PICK OP_SUB diff [a,b,sum,diff] +OP_3 OP_PICK OP_3 OP_PICK OP_MUL prod [a,b,sum,diff,prod] +OP_4 OP_ROLL OP_4 OP_ROLL OP_DIV quot [sum,diff,prod,quot] +OP_3 OP_ROLL OP_3 OP_ROLL OP_ADD OP_ROT OP_ADD OP_SWAP OP_ADD + OP_NUMEQUAL +``` + +`a` and `b` are each materialized four times, and every materialization is deeper than the +last because each result is pushed on top of them. + +Hand-scheduled alternative (18 bytes) — operands stay in the top two slots, finished results +are parked on the alt stack: + +``` +OP_2DUP OP_ADD OP_TOALTSTACK sum -> alt +OP_2DUP OP_SUB OP_TOALTSTACK diff -> alt +OP_2DUP OP_MUL OP_TOALTSTACK prod -> alt +OP_DIV quot (consumes a, b) +OP_FROMALTSTACK OP_ADD + prod +OP_FROMALTSTACK OP_ADD + diff +OP_FROMALTSTACK OP_ADD + sum + OP_NUMEQUAL +``` + +**28 → 18 bytes, −36 %, and every byte saved is stack traffic.** Both scripts are executed +against the real `@bsv/sdk` interpreter over 14 (a, b) pairs including negatives, zero, and +the 16/17 script-number boundary, in +`packages/runar-testing/src/__tests__/scheduler-headroom.test.ts`; they accept and reject +identically. The number is measured, not estimated. + +The three effects that produced it, in order of contribution: + +1. **Result spilling to the alt stack.** Keeps the hot operands at depth 0/1, so every + subsequent access is `OP_2DUP` (1 byte) instead of `push d; OP_PICK; push d; OP_PICK` + (4 bytes). Worth 3 of the 4 materializations here. +2. **Adjacent-pair fusion.** `(a, b)` at depths 1 and 0 is `OP_2DUP`, not two picks. The + peephole has an `over,over → OP_2DUP` rule, but the lowerer emits `push;pick;push;pick`, + which no window can fuse. +3. **Consumption ordering.** Scheduling the one operator that *consumes* `a` and `b` + (`OP_DIV`) last removes the final pair of `OP_4 OP_ROLL`s entirely. + +--- + +## 3. Design + +### 3.1 Representation + +Extend the existing analysis rather than replacing it. `computeLastUses` already gives last +use; add, over the same scan: + +```ts +interface LivenessInfo { + lastUse: Map; // existing + useCount: Map; // total references (excluding array indirection) + nextUse: Map; // sorted binding indices that reference the name +} +``` + +`nextUse` is what makes "will this value be touched again soon, or not for a while?" a +question the scheduler can answer, and it is the input to the spill decision. It costs one +extra pass over the same bindings. + +### 3.2 Scheduling unit: the branch-free run + +The prototype operates only on a **maximal run of consecutive bindings containing no `if` +and no `loop`**. A run ends at any control-flow binding, and the stack is restored to a +canonical layout (everything on the main stack, alt stack empty) before that binding is +lowered. + +This is deliberate. `lowerIf`'s arm reconciliation, result-layout assertion and depth +balancing (§1.5) are the most delicate code in the backend, and every one of them reasons +about main-stack depth only. Confining spills to branch-free runs means **no arm ever begins +or ends with a non-empty alt stack**, so none of those invariants can be perturbed. It also +means the prototype cannot help inside a loop body — accepted for now; loops are unrolled, so +the runs *between* control flow are still scheduled. + +### 3.3 Byte-cost function + +`estimateScriptBytes` / `sizeOfStackOp` from `packages/runar-compiler/src/metrics/cost-model.ts` +— already implemented and asserted byte-exact against `06-emit.ts` over the whole corpus +(`__tests__/cost-model.test.ts`). The scheduler's local decisions use these derived costs: + +``` +accessCost(depth, consume) = 1 depth 0 (consume) — free + = 1 depth 0/1/2 via DUP/SWAP/OVER/ROT + = sizeOfPushValue(depth)+1 otherwise +pairAccessCost(d0, d1) = 1 (a,b) at depths 1,0 -> OP_2DUP + = accessCost(d0)+accessCost(d1) otherwise +spillCost = 2 TOALTSTACK + FROMALTSTACK +rematerializeCost(constant) = sizeOfPushValue(v) +``` + +### 3.4 Heuristic + +Greedy, single forward pass over a run. Not globally optimal, and deliberately so — the +brief asks for a simple greedy implementation first. + +For each binding `t := op(x, y)`: + +1. **Order the operands.** If `op` is commutative (`+ * === !== && || & | ^`, `min`, `max`), + order so the operand already nearer the top is materialized second. Ties keep source + order, so the default mode is unchanged. +2. **Fuse the pair.** If `(x, y)` sit at depths 1 and 0 and neither is consumed, emit + `OP_2DUP` instead of two accesses. Generalize to `OP_2OVER` for depths 3,2. +3. **Rematerialize instead of accessing.** If `x` is a `load_const` whose push encoding costs + ≤ `accessCost(depth(x), consume)`, re-push it and leave the resident copy alone. +4. **Spill the result.** After emitting the operation, if the result's `nextUse` is more than + `SPILL_HORIZON` bindings away *and* at least one still-live value sits below it, park it + with `OP_TOALTSTACK`. Restore in reverse spill order at the point of use. Spill only when + `spillCost < projected access savings`, computed from `nextUse` and the current depths. +5. **Restore before a run boundary.** Every spilled value is popped back before any `if`, + `loop`, or the end of the method. + +**As built, step 4 is stricter than this design anticipated.** Spilling is refused outright in +any scope that still has control flow ahead of it, because restoring immediately before an +`if` miscompiled a fixture — see §6. And step 1 is scored by running the candidate op +sequences through the real peephole rather than a byte formula, because the cheapest-looking +local choice is often one the peephole would have erased anyway (§6 again). + +The per-site heuristics are backed by a **method-level guard**: both schedules are lowered and +the cheaper one, measured after peephole with `estimateScriptBytes`, is kept. "The scheduler +never grows a method" is therefore a structural property, not a hope — which matters, because +the greedy heuristic cannot tell whether removing one slot actually moves an access across a +cost boundary (depths 0-2 are all one byte). + +### 3.5 Gating + +`schedulerMode: 'current' | 'liveness'` on `LoweringContext`, plumbed from +`CompileOptions.schedulerMode` (`packages/runar-compiler/src/index.ts:109`) and a CLI +`--stack-scheduler=` (`packages/runar-cli/src/bin.ts:39`, +`commands/compile.ts:14/84/177/252` — the `--disable-constant-folding` path is the template). + +Default is `'current'`, and every new behaviour is a no-op in that mode. This keeps the +72 goldens, `conformance/script-size-baseline.json`, the cross-tier hex parity gate and the +golden-provenance gate untouched while the experiment runs. + +--- + +## 4. Correctness invariants + +The scheduler may reorder *materialization*, never *evaluation*. Concretely: + +1. **Side-effect order is fixed.** Bindings are lowered in ANF order. Only the stack + operations that arrange operands may move. `hasSideEffect` (`optimizer/dce.ts:133`) names + the kinds that must never be reordered relative to each other. +2. **Operand order is preserved for non-commutative operators.** `-`, `/`, `%`, `<<`, `>>`, + `<`, `>`, `<=`, `>=`, `OP_SPLIT`, `OP_CAT` and every intrinsic keep source order. + Commutativity is asserted per-operator against the interpreter, not assumed: `OP_ADD` and + `OP_MUL` are commutative on script numbers; `OP_CAT` is not; `OP_BOOLAND`/`OP_BOOLOR` are + commutative but **not** short-circuit at this level, so reordering them cannot change + which side is evaluated (both already are). +3. **Alt stack is empty at every control-flow boundary** and at method exit. Asserted in the + lowerer, not just tested — a `LoweringContext` invariant check before each `if`/`loop` and + in `lowerMethod`. +4. **Main-stack depth at `OP_ENDIF` is unchanged.** The Layer B/C assertions (`:2417`, + `:2640`) stay in force; the prototype must not touch arm reconciliation at all. +5. **`maxStackDepth` may not exceed `MAX_STACK_DEPTH = 800`** (`:63`). Spilling *reduces* + main-stack depth, but the alt stack shares the interpreter's 1,000-element budget, so the + sum is what gets checked. +6. **No assertion is weakened.** The scheduler never removes an `assert` binding, never + changes which value an `OP_VERIFY` consumes, and never elides a normalization + (`OP_BIN2NUM`, `OP_NUM2BIN`, sign fixups) that a later consumer observes. + +--- + +## 5. Benchmark plan + +**Metric:** serialized locking-script bytes, from `estimateScriptBytes` (exact) and confirmed +against the emitted hex. + +**Command:** + +```bash +pnpm --filter runar-conformance run script-metrics -- --compare current,liveness +``` + +**Report, per fixture:** script bytes, `OP_PICK` / `OP_ROLL` / `OP_DUP` / `OP_SWAP` / +`OP_2DUP` / `OP_TOALTSTACK` counts, `maxStackDepth` delta. + +**Acceptance (from the brief, restated as pass/fail):** + +1. Semantically identical Script — proven, not assumed. `scheduler-equivalence.test.ts` + (modelled on `packages/runar-testing/src/oracle/fold-equivalence.ts`) compiles each source + under both modes and asserts identical accept/reject through `ScriptVM` plus agreement + with the mode-independent AST interpreter, over every witness in + `conformance/witnesses/`. Plus `conformance/fuzzer/index.ts --execute` with the toggle. +2. All existing VM / interpreter tests pass under both modes. +3. No material growth on ordinary fixtures. Fail the experiment if any fixture grows > 1 %. +4. A measurable win on at least one arithmetic-heavy fixture. **Target: > 10 %.** + +**Prior expectations, so the result could disappoint honestly:** + +| fixture class | expected | **measured** | +|---|---|---| +| `arithmetic` | −20 % to −36 % | **−35.7 %** (28 → 18 B) | +| `bounded-loop`, `boolean-logic` | −20 % to −36 % | −11.9 % (42 → 37 B) / 0 % | +| `math-demo`, `token-ft`, `function-patterns` | −3 % to −10 % | **−0.1 %** | +| stateful fixtures | −1 % to −4 % | −0.1 % to −0.2 % | +| EC / P-256 / P-384 | 0 % | 0 % | +| SLH-DSA / SHA-256 / BLAKE3 | 0 % | 0 % | + +`arithmetic` reached 18 bytes — the hand-derived optimum in §2.4 — which the scheduler found +on its own. The mid-size prediction was wrong by an order of magnitude: those contracts do have +a 35 % stack-shuffle share, but almost all of it is sighash and state-serialization macro +output, not ANF chains the scheduler can reach. Their ANF is mostly bindings consumed by the +very next binding, where there is nothing to spill. + +So the honest conclusion is the one the plan named as the disappointing case: **the generic +scheduler is worth having for small arithmetic contracts and little else**, and the remaining +shuffle budget belongs to the macro emitters. Corpus-wide it moves 34 of 72 fixtures and +−0.0 % of total bytes. It is kept as a gated mode, not proposed for a 7-tier port. + +--- + +## 6. What went wrong, and what caught it + +### A miscompile, caught by the witness corpus + +The first working scheduler **miscompiled `if-without-else-multi-temp`**: the script ran to +completion, left a truthy top-of-stack, and **accepted a witness the shipping compiler +rejects**. Byte counts, the goldens for every other fixture, and 4,099 compiler unit tests all +passed while that was true. + +`conformance/witnesses/` replayed through `runDifferentialExecution` caught it — deployed +script versus the ANF interpreter, on witnesses the repo had already committed to, with at +least one accept and one reject per fixture. Two of 86 cases failed. + +Cause: restoring spilled values immediately before an `if` leaves the parent stack in a shape +`lowerIf`'s arm reconciliation, declared-result trim and Layer B/C depth invariants were not +written for. The fix is the precondition in §3.4 — refuse to spill in a scope with control +flow ahead of it — rather than an attempt to make the two agree. + +### Two things worth remembering + +**A passing bisect can be vacuous.** Turning off commutative reordering made the failure +disappear, which looked like an acquittal for spilling. It was not: with reordering off, the +method-level cost guard simply preferred the baseline schedule, so no spilling happened at +all. Only after confirming the variant still changed the emitted bytes did the second bisect +mean anything. + +**The cost model had to be peephole-aware.** Two consumed operands at depths 1 and 0 emit +`OP_SWAP OP_SWAP`, which the `swap-swap` rule deletes outright — free — while the +"cheaper-looking" reversed order emits one real `OP_SWAP` and costs a byte. Scoring candidate +op sequences through `optimizeStackIR` before comparing them took `arithmetic` from 24 bytes +to 18. diff --git a/packages/runar-cli/src/__tests__/experimental-flags.test.ts b/packages/runar-cli/src/__tests__/experimental-flags.test.ts new file mode 100644 index 000000000..d51ffd358 --- /dev/null +++ b/packages/runar-cli/src/__tests__/experimental-flags.test.ts @@ -0,0 +1,137 @@ +// --------------------------------------------------------------------------- +// Tests for the experimental size-optimizer flags on the TS CLI: +// --stack-scheduler +// --ec-constant-pool +// +// Both change emitted bytes when enabled and MUST be inert when absent, since +// the checked-in goldens, `conformance/script-size-baseline.json` and the +// cross-tier hex parity gate all assume the default path. +// +// See docs/experiments/script-size-optimizer-results.md. +// --------------------------------------------------------------------------- + +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; + +/** The smallest contract whose bytes come entirely from the generic scheduler. */ +const ARITHMETIC = ` +import { SmartContract, assert } from 'runar-lang'; + +class Arithmetic extends SmartContract { + readonly target: bigint; + constructor(target: bigint) { + super(target); + this.target = target; + } + public verify(a: bigint, b: bigint): void { + const sum: bigint = a + b; + const diff: bigint = a - b; + const prod: bigint = a * b; + const quot: bigint = a / b; + const result: bigint = sum + diff + prod + quot; + assert(result === this.target); + } +} +`; + +let workDir: string; + +beforeAll(() => { + workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'runar-cli-exp-')); +}); + +afterAll(() => { + if (workDir) fs.rmSync(workDir, { recursive: true, force: true }); +}); + +describe('experimental size-optimizer flags', () => { + let compileCommand: typeof import('../commands/compile.js').compileCommand; + + beforeAll(async () => { + const sourceEntry = path.resolve(process.cwd(), 'packages/runar-compiler/src/index.ts'); + if (fs.existsSync(sourceEntry)) { + const { pathToFileURL } = await import('node:url'); + await import(pathToFileURL(sourceEntry).href); + } else { + await import('runar-compiler'); + } + }, 60_000); + + beforeEach(async () => { + ({ compileCommand } = await import('../commands/compile.js')); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + /** Compile ARITHMETIC with the given options and return the printed hex. */ + async function hexWith(options: Record, tag: string): Promise { + const srcPath = path.join(workDir, `Arithmetic-${tag}.runar.ts`); + fs.writeFileSync(srcPath, ARITHMETIC); + const outDir = path.join(workDir, `out-${tag}`); + fs.mkdirSync(outDir, { recursive: true }); + + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + const originalExitCode = process.exitCode; + + await compileCommand([srcPath], { output: outDir, hex: true, ...options }); + + const printed = writeSpy.mock.calls.map(c => String(c[0])).join('').trim(); + process.exitCode = originalExitCode; + writeSpy.mockRestore(); + return printed; + } + + it('is inert when no experimental flag is passed', async () => { + const bare = await hexWith({}, 'bare'); + const explicit = await hexWith({ stackScheduler: 'current' }, 'explicit'); + expect(bare).toBe(explicit); + // The shipping schedule for this contract; also the checked-in golden for + // conformance/tests/arithmetic modulo the constructor placeholder. + expect(bare).toBe('6e9352795279945379537995547a547a96537a537a937b937c93009c'); + expect(bare.length / 2).toBe(28); + }); + + it('--stack-scheduler liveness reschedules and shrinks', async () => { + const out = await hexWith({ stackScheduler: 'liveness' }, 'liveness'); + expect(out).toBe('6e936b6e946b6e956b966c6c6c939393009c'); + expect(out.length / 2).toBe(18); + }); + + it('rejects an unknown scheduler mode instead of silently using the default', async () => { + // A benchmark run that quietly measured the shipping compiler while + // reporting an experiment is worse than a crash. + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + const originalExitCode = process.exitCode; + + const srcPath = path.join(workDir, 'Arithmetic-bad.runar.ts'); + fs.writeFileSync(srcPath, ARITHMETIC); + const outDir = path.join(workDir, 'out-bad'); + fs.mkdirSync(outDir, { recursive: true }); + + await compileCommand([srcPath], { output: outDir, hex: true, stackScheduler: 'bogus' }); + + const errors = errSpy.mock.calls.map(c => String(c[0])).join('\n'); + expect(errors).toMatch(/unknown mode 'bogus'/); + process.exitCode = originalExitCode; + }); + + it('--ec-constant-pool is inert on a contract with no EC operations', async () => { + const bare = await hexWith({}, 'nopool'); + 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 1a4ade66d..53845f434 100644 --- a/packages/runar-cli/src/bin.ts +++ b/packages/runar-cli/src/bin.ts @@ -43,6 +43,10 @@ program .option('--ir', 'include IR in artifact') .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('--ec-fixed-base-comb', 'EXPERIMENTAL: comb multiplication where the base point is a compile-time constant') + .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)') .option('--parse-only', 'stop after parse + validate; print "parser ok" on success (requires source input)') diff --git a/packages/runar-cli/src/commands/compile.ts b/packages/runar-cli/src/commands/compile.ts index 74afbd29c..c400f4c62 100644 --- a/packages/runar-cli/src/commands/compile.ts +++ b/packages/runar-cli/src/commands/compile.ts @@ -12,6 +12,10 @@ interface CompileOptions { ir?: boolean; asm?: boolean; disableConstantFolding?: boolean; + ecConstantPool?: boolean; + ecReductionSinking?: boolean; + ecFixedBaseComb?: boolean; + stackScheduler?: string; fromIr?: string; hex?: boolean; parseOnly?: boolean; @@ -72,6 +76,19 @@ function repoRelativeFileName(absSourcePath: string): string { * 3. Write the resulting artifact JSON to the output directory. * 4. Optionally print the ASM to stdout. */ +/** + * Validate `--stack-scheduler`. An unrecognised mode is an error rather than a + * silent fall back to the default: a benchmark run that quietly measured the + * shipping compiler while reporting an experiment would be worse than a crash. + */ +function schedulerMode(options: { stackScheduler?: string }): 'current' | 'liveness' { + const mode = options.stackScheduler ?? 'current'; + if (mode !== 'current' && mode !== 'liveness') { + throw new Error(`--stack-scheduler: unknown mode '${mode}' (expected current|liveness)`); + } + return mode; +} + export async function compileCommand( files: string[], options: CompileOptions, @@ -81,10 +98,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; parseOnly?: boolean }) => unknown; + type CompileFn = (source: string, options?: { fileName?: string; disableConstantFolding?: boolean; ecConstantPool?: boolean; ecReductionSinking?: boolean; ecFixedBaseComb?: boolean; schedulerMode?: 'current' | 'liveness'; parseOnly?: boolean }) => unknown; type CompileFromANFFn = ( program: unknown, - options?: { disableConstantFolding?: boolean }, + options?: { disableConstantFolding?: boolean; ecConstantPool?: boolean; ecReductionSinking?: boolean; ecFixedBaseComb?: boolean; schedulerMode?: 'current' | 'liveness' }, ) => { scriptHex: string; scriptAsm: string }; type LoadANFFn = (json: string) => unknown; @@ -174,7 +191,13 @@ export async function compileCommand( let result: { scriptHex: string; scriptAsm: string }; try { - result = compileFromANF(program, { disableConstantFolding: options.disableConstantFolding }); + result = compileFromANF(program, { + disableConstantFolding: options.disableConstantFolding, + ecConstantPool: options.ecConstantPool, + ecReductionSinking: options.ecReductionSinking, + ecFixedBaseComb: options.ecFixedBaseComb, + schedulerMode: schedulerMode(options), + }); } catch (err) { console.error(` Compilation error: ${(err as Error).message}`); process.exitCode = 1; @@ -250,6 +273,10 @@ export async function compileCommand( compileResult = compile(source, { fileName: resolvedPath, disableConstantFolding: options.disableConstantFolding, + ecConstantPool: options.ecConstantPool, + ecReductionSinking: options.ecReductionSinking, + ecFixedBaseComb: options.ecFixedBaseComb, + schedulerMode: schedulerMode(options), parseOnly: options.parseOnly, }) as CompileResultLike; } catch (err) { diff --git a/packages/runar-compiler/src/__tests__/comb-table.test.ts b/packages/runar-compiler/src/__tests__/comb-table.test.ts new file mode 100644 index 000000000..69a8732a5 --- /dev/null +++ b/packages/runar-compiler/src/__tests__/comb-table.test.ts @@ -0,0 +1,154 @@ +/** + * Fixed-base comb — compile-time table and its soundness check. + * + * The comb replaces the 257-round binary ladder for `u1·G` with 86 rounds over + * a 7-entry precomputed table (measured optimum w=3; see + * docs/experiments/script-size-optimizer-results.md). Because the base is a + * compile-time constant, the whole table is computed here rather than on chain. + * + * The safety-critical half is `combSafeRounds`. The existing binary ladder uses + * the CHEAP incomplete mixed-add everywhere but the last step, justified by an + * interval argument over `c_i mod n` — and its own comment insists that + * argument be REDONE, not assumed, by anything that changes the offset or the + * iteration count. A comb changes both. So the argument is re-derived here as + * executable interval arithmetic: a round may use the cheap add only when the + * checker proves the pre-add accumulator cannot equal 0 or ±(any table value) + * modulo n, for every scalar in the domain. + */ + +import { describe, it, expect } from 'vitest'; +import { + combTable, combValue, combSafeRounds, combParams, scalarMulJS, + P256_COMB_CURVE, P384_COMB_CURVE, SECP256K1_COMB_CURVE, +} from '../passes/comb.js'; + +const P256_N = 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551n; +const SECP256K1_N = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n; + +/** Every curve the comb is wired for. secp256k1 is a=0, not a=-3. */ +const CURVES = [P256_COMB_CURVE, P384_COMB_CURVE, SECP256K1_COMB_CURVE]; + +describe('compile-time point arithmetic', () => { + it('G doubles to the published 2G for P-256', () => { + const two = scalarMulJS(2n, P256_COMB_CURVE.g, P256_COMB_CURVE); + expect(two).not.toBeNull(); + expect(two!.x).toBe(0x7cf27b188d034f7e8a52380304b51ac3c08969e277f21b35a60b48fc47669978n); + expect(two!.y).toBe(0x07775510db8ed040293d9ac69f7430dbba7dade63ce982299e04b79d227873d1n); + }); + + it('n·G is the point at infinity', () => { + expect(scalarMulJS(P256_N, P256_COMB_CURVE.g, P256_COMB_CURVE)).toBeNull(); + }); + + it('G doubles to the published 2G for secp256k1', () => { + // secp256k1 has a = 0, so the tangent numerator is 3x² with no `+ a` term. + // A curve entry that copied the NIST a = -3 would still produce points that + // pass the on-curve check for the WRONG curve, so pin a published vector. + const two = scalarMulJS(2n, SECP256K1_COMB_CURVE.g, SECP256K1_COMB_CURVE); + expect(two).not.toBeNull(); + expect(two!.x).toBe(0xc6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5n); + expect(two!.y).toBe(0x1ae168fea63dc339a3c58419466ceaeef7f632653266d0e1236431a950cfe52an); + }); + + it('secp256k1 uses a = 0 and b = 7', () => { + expect(SECP256K1_COMB_CURVE.a).toBe(0n); + expect(SECP256K1_COMB_CURVE.b).toBe(7n); + }); + + it('n·G is the point at infinity on secp256k1 too', () => { + expect(scalarMulJS(SECP256K1_N, SECP256K1_COMB_CURVE.g, SECP256K1_COMB_CURVE)).toBeNull(); + }); + + it('every table point is on the curve', () => { + for (const curve of CURVES) { + const { p, a, b } = curve; + for (const w of [2, 3, 4]) { + const params = combParams(w, curve); + expect(params, `no geometry for w=${w}`).not.toBeNull(); + for (const pt of combTable(w, params!.d, curve)) { + if (pt === null) continue; + const lhs = (pt.y * pt.y) % p; + const rhs = (((pt.x * pt.x % p) * pt.x % p) + a * pt.x + b) % p; + expect(((rhs % p) + p) % p).toBe(lhs); + } + } + } + }); + + it('table entry j is combValue(j)·G', () => { + const w = 3; + const d = combParams(w, P256_COMB_CURVE)!.d; + const table = combTable(w, d, P256_COMB_CURVE); + for (let j = 1; j < (1 << w); j++) { + const direct = scalarMulJS(combValue(j, d), P256_COMB_CURVE.g, P256_COMB_CURVE); + expect(table[j], `entry ${j}`).toEqual(direct); + } + }); + + it('entry 0 is the point at infinity and is never added', () => { + const d = combParams(3, P256_COMB_CURVE)!.d; + expect(combTable(3, d, P256_COMB_CURVE)[0]).toBeNull(); + }); +}); + +describe('combSafeRounds — the interval argument, executable', () => { + const w = 3; + const params = combParams(w, P256_COMB_CURVE)!; + const d = params.d; + + it('picks the offset that keeps the top digit non-zero', () => { + // P-256 at w=3 lands on the same +3n the binary ladder hardcodes... + expect(params.offsetMultiple).toBe(3n); + expect(params.d).toBe(86); + // ...but P-384 at w=3 does NOT. Assuming +3n there would let the leading + // digit be zero, which puts the accumulator at infinity. + const p384 = combParams(3, P384_COMB_CURVE)!; + expect(p384.offsetMultiple).not.toBe(3n); + expect(p384.lo >= (1n << BigInt(p384.w * p384.d - 1))).toBe(true); + expect(p384.hi < (1n << BigInt(p384.w * p384.d))).toBe(true); + }); + + it('proves the early rounds safe for the cheap add', () => { + const safe = combSafeRounds(params, P256_COMB_CURVE); + expect(safe).toHaveLength(d); + // The top round initialises the accumulator, so it performs no add. + // Rounds where the accumulator interval is narrower than n must be provable. + const provable = safe.filter(Boolean).length; + expect(provable).toBeGreaterThan(d - 8); + }); + + it('refuses to prove the final rounds, where the interval exceeds n', () => { + // The interval widens as i falls; once it can wrap a full residue class the + // checker MUST give up rather than assume. A checker that proved every + // round would be broken, not clever. + const safe = combSafeRounds(params, P256_COMB_CURVE); + expect(safe[0]).toBe(false); + }); + + it('is conservative under a deliberately widened domain', () => { + // Doubling the scalar domain can only make rounds less provable. + const strict = combSafeRounds(params, P256_COMB_CURVE); + const loose = combSafeRounds({ ...params, hi: params.hi * 2n }, P256_COMB_CURVE); + for (let i = 0; i < d; i++) { + if (loose[i]) expect(strict[i], `round ${i} regressed`).toBe(true); + } + }); + + it('works for P-384 too', () => { + const p384 = combParams(w, P384_COMB_CURVE)!; + const safe = combSafeRounds(p384, P384_COMB_CURVE); + expect(safe).toHaveLength(p384.d); + expect(safe.filter(Boolean).length).toBeGreaterThan(p384.d - 8); + }); + + it('works for secp256k1 too', () => { + const k1 = combParams(w, SECP256K1_COMB_CURVE)!; + const safe = combSafeRounds(k1, SECP256K1_COMB_CURVE); + expect(safe).toHaveLength(k1.d); + expect(safe.filter(Boolean).length).toBeGreaterThan(k1.d - 8); + // The scalar domain must sit inside the digit width, or the leading digit + // can vanish and the accumulator starts at infinity. + expect(k1.lo >= (1n << BigInt(k1.w * k1.d - 1))).toBe(true); + expect(k1.hi < (1n << BigInt(k1.w * k1.d))).toBe(true); + }); +}); diff --git a/packages/runar-compiler/src/__tests__/cost-model.test.ts b/packages/runar-compiler/src/__tests__/cost-model.test.ts new file mode 100644 index 000000000..ae24e1b3a --- /dev/null +++ b/packages/runar-compiler/src/__tests__/cost-model.test.ts @@ -0,0 +1,198 @@ +/** + * Script-byte cost model — exactness tests. + * + * The cost model exists so optimizer passes can compare two candidate + * lowerings by the metric that actually matters (serialized locking-script + * bytes) BEFORE emitting either. That is only useful if the estimate is not + * an estimate at all: the contract asserted here is + * + * estimateScriptBytes(ops) === emitMethod({ ops, ... }).scriptHex.length / 2 + * + * for every op sequence the compiler can produce. The sweep below runs that + * equality over every conformance fixture, so the model is a CHECKED MIRROR + * of `06-emit.ts` rather than a second, drifting opinion about encoding. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync, existsSync, readdirSync } from 'fs'; +import { join, resolve } from 'path'; +import { parse } from '../passes/01-parse.js'; +import { lowerToANF } from '../passes/04-anf-lower.js'; +import { lowerToStack } from '../passes/05-stack-lower.js'; +import { emitMethod } from '../passes/06-emit.js'; +import { optimizeStackIR } from '../optimizer/peephole.js'; +import { sizeOfStackOp, estimateScriptBytes } from '../metrics/cost-model.js'; +import type { StackOp } from '../ir/index.js'; + +const CONFORMANCE_DIR = join(__dirname, '..', '..', '..', '..', 'conformance', 'tests'); + +/** Emit a bare op list through the real emitter and return its byte length. */ +function emittedBytes(ops: StackOp[]): number { + const result = emitMethod({ name: 'probe', ops, maxStackDepth: 0 }); + return result.scriptHex.length / 2; +} + +/** Assert the model agrees with the emitter for one op sequence. */ +function expectExact(ops: StackOp[]): void { + expect(estimateScriptBytes(ops)).toBe(emittedBytes(ops)); +} + +// --------------------------------------------------------------------------- +// Per-op-kind units +// --------------------------------------------------------------------------- + +describe('sizeOfStackOp', () => { + it('costs a named opcode at one byte', () => { + expect(sizeOfStackOp({ op: 'opcode', code: 'OP_ADD' })).toBe(1); + }); + + it('throws on an unknown opcode rather than silently costing zero', () => { + expect(() => sizeOfStackOp({ op: 'opcode', code: 'OP_NOT_A_REAL_OPCODE' })).toThrow( + /OP_NOT_A_REAL_OPCODE/, + ); + }); + + it.each([ + ['dup'], ['swap'], ['drop'], ['nip'], ['over'], ['rot'], ['tuck'], + ] as const)('costs the nullary shuffle %s at one byte', (op) => { + expect(sizeOfStackOp({ op } as StackOp)).toBe(1); + }); + + it('costs pick/roll at one byte — the depth push is a separate op', () => { + // bringToTop emits `push(depth)` and `pick{depth}` as TWO ops; counting + // the depth inside the pick would double-charge it. + expect(sizeOfStackOp({ op: 'pick', depth: 40 })).toBe(1); + expect(sizeOfStackOp({ op: 'roll', depth: 40 })).toBe(1); + }); + + it('costs placeholder and codesep-index at one byte each', () => { + expect(sizeOfStackOp({ op: 'placeholder', paramIndex: 0, paramName: 'x' })).toBe(1); + expect(sizeOfStackOp({ op: 'push_codesep_index' })).toBe(1); + }); + + it('costs raw_bytes at its verbatim length', () => { + const bytes = new Uint8Array([0x51, 0x52, 0x93]); + expect(sizeOfStackOp({ op: 'raw_bytes', bytes, in_arity: 0, out_arity: 1 })).toBe(3); + }); + + describe('push encoding', () => { + it.each([ + [0n, 1], // OP_0 + [1n, 1], // OP_1 + [16n, 1], // OP_16 + [-1n, 1], // OP_1NEGATE + [17n, 2], // len prefix + 1 byte + [127n, 2], + [128n, 3], // sign byte forces 2 data bytes + [-128n, 3], + [65535n, 4], + ])('costs push(%s) at %i bytes', (value, want) => { + expect(sizeOfStackOp({ op: 'push', value })).toBe(want); + }); + + it('costs the P-256 field prime push at 34 bytes', () => { + // 32 magnitude bytes + 1 sign byte + 1 length prefix. This single push + // accounts for 680,850 of p256-wallet's 958,792 bytes. + const p = 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffffn; + expect(sizeOfStackOp({ op: 'push', value: p })).toBe(34); + }); + + it('costs byte-array pushes across the PUSHDATA boundaries', () => { + expect(sizeOfStackOp({ op: 'push', value: new Uint8Array(0) })).toBe(1); + expect(sizeOfStackOp({ op: 'push', value: new Uint8Array(1).fill(0xaa) })).toBe(2); + expect(sizeOfStackOp({ op: 'push', value: new Uint8Array(75).fill(0xaa) })).toBe(76); + expect(sizeOfStackOp({ op: 'push', value: new Uint8Array(76).fill(0xaa) })).toBe(78); + expect(sizeOfStackOp({ op: 'push', value: new Uint8Array(255).fill(0xaa) })).toBe(257); + expect(sizeOfStackOp({ op: 'push', value: new Uint8Array(256).fill(0xaa) })).toBe(259); + }); + + it('costs boolean pushes like the emitter encodes them', () => { + expectExact([{ op: 'push', value: true }]); + expectExact([{ op: 'push', value: false }]); + }); + }); + + describe('if', () => { + it('costs OP_IF + body + OP_ENDIF when there is no else arm', () => { + const op: StackOp = { op: 'if', then: [{ op: 'opcode', code: 'OP_ADD' }] }; + expect(sizeOfStackOp(op)).toBe(3); + expectExact([op]); + }); + + it('costs OP_IF + then + OP_ELSE + else + OP_ENDIF', () => { + const op: StackOp = { + op: 'if', + then: [{ op: 'opcode', code: 'OP_ADD' }], + else: [{ op: 'push', value: 0n }], + }; + expect(sizeOfStackOp(op)).toBe(5); + expectExact([op]); + }); + + it('recurses into nested arms', () => { + const inner: StackOp = { op: 'if', then: [{ op: 'opcode', code: 'OP_ADD' }] }; + const outer: StackOp = { op: 'if', then: [inner], else: [inner] }; + // outer IF(1) + inner(3) + ELSE(1) + inner(3) + ENDIF(1) + expect(sizeOfStackOp(outer)).toBe(9); + expectExact([outer]); + }); + + it('omits OP_ELSE for an empty else arm, matching emitIf', () => { + const op: StackOp = { op: 'if', then: [{ op: 'opcode', code: 'OP_ADD' }], else: [] }; + expect(sizeOfStackOp(op)).toBe(3); + expectExact([op]); + }); + }); +}); + +// --------------------------------------------------------------------------- +// The real contract: exact agreement with the emitter, over every fixture +// --------------------------------------------------------------------------- + +interface SourceConfig { + sources?: Record; +} + +function tsSourceFor(fixture: string): string | null { + const configFile = join(CONFORMANCE_DIR, fixture, 'source.json'); + if (!existsSync(configFile)) return null; + const config = JSON.parse(readFileSync(configFile, 'utf-8')) as SourceConfig; + const rel = config.sources?.['.runar.ts']; + if (rel === undefined) return null; + const abs = resolve(CONFORMANCE_DIR, fixture, rel); + if (!existsSync(abs)) throw new Error(`source.json points at a missing file: ${abs}`); + return abs; +} + +const FIXTURES = readdirSync(CONFORMANCE_DIR, { withFileTypes: true }) + .filter(e => e.isDirectory()) + .map(e => e.name) + .filter(name => tsSourceFor(name) !== null) + .sort(); + +describe('estimateScriptBytes matches the emitter exactly', () => { + it('found the conformance corpus', () => { + expect(FIXTURES.length).toBeGreaterThan(50); + }); + + it.each(FIXTURES)('%s', (fixture) => { + const path = tsSourceFor(fixture)!; + const source = readFileSync(path, 'utf-8'); + const parsed = parse(source, path); + if (!parsed.contract) { + throw new Error(`parse failed for ${fixture}: ${parsed.errors.map(e => e.message).join(', ')}`); + } + const stack = lowerToStack(lowerToANF(parsed.contract)); + + for (const method of stack.methods) { + // Both before and after peephole: the model must be exact on any op + // sequence the pipeline can hand the emitter, not just the final one. + expect(estimateScriptBytes(method.ops)).toBe(emitMethod(method).scriptHex.length / 2); + + const optimized = { ...method, ops: optimizeStackIR(method.ops) }; + expect(estimateScriptBytes(optimized.ops)).toBe( + emitMethod(optimized).scriptHex.length / 2, + ); + } + }); +}); diff --git a/packages/runar-compiler/src/__tests__/ec-constant-pool.test.ts b/packages/runar-compiler/src/__tests__/ec-constant-pool.test.ts new file mode 100644 index 000000000..4654ccfbe --- /dev/null +++ b/packages/runar-compiler/src/__tests__/ec-constant-pool.test.ts @@ -0,0 +1,146 @@ +/** + * EC constant pooling — size and default-invariance. + * + * `cFieldMod` / `fieldMod` push the curve's field prime inline at EVERY + * modular reduction. On `conformance/tests/p256-wallet` that is 20,025 pushes + * of a 34-byte literal — 680,850 of the fixture's 958,792 bytes, 71 %. The + * prime is a compile-time constant; parking one copy in a stack slot and + * copying it with `push d; OP_PICK` costs 2-3 bytes instead of 34. + * + * This file pins two things: + * 1. with pooling OFF the emitters are byte-identical to what ships today + * (so no golden, baseline, or cross-tier parity gate can move), and + * 2. with pooling ON the scripts actually shrink, by the amount the + * arithmetic predicts rather than by "some". + * + * Semantic equivalence is proved separately, against OpenSSL signatures on the + * real interpreter, in + * `packages/runar-testing/src/__tests__/ec-constant-pool-equivalence.test.ts`. + */ + +import { describe, it, expect } from 'vitest'; +import { + emitVerifyECDSA_P256, emitVerifyECDSA_P384, + emitP256Mul, emitP256MulGen, emitP256Add, emitP256OnCurve, + emitP384Mul, emitP384Add, +} from '../passes/p256-p384-codegen.js'; +import { + emitEcAdd, emitEcMul, emitEcMulGen, +} from '../passes/ec-codegen.js'; +import { emitMethod } from '../passes/06-emit.js'; +import { estimateScriptBytes } from '../metrics/cost-model.js'; +import { analyzeScriptHex } from '../metrics/script-metrics.js'; +import { encodeScriptNumber } from '../passes/push-encoding.js'; +import type { StackOp } from '../ir/index.js'; +import type { EcCodegenOptions } from '../passes/ec-codegen.js'; + +type Emitter = (emit: (op: StackOp) => void, opts?: EcCodegenOptions) => void; + +function opsOf(emitter: Emitter, opts?: EcCodegenOptions): StackOp[] { + const ops: StackOp[] = []; + emitter(op => ops.push(op), opts); + return ops; +} + +function hexOf(ops: StackOp[]): string { + return emitMethod({ name: 'probe', ops, maxStackDepth: 0 }).scriptHex; +} + +function bytesOf(emitter: Emitter, opts?: EcCodegenOptions): number { + return estimateScriptBytes(opsOf(emitter, opts)); +} + +/** + * Net stack effect of an op sequence, counting only the ops whose effect is + * unambiguous from the Stack IR alone (pushes and pops). Opcodes are opaque + * here, so this is a same-shape comparison between two variants of the SAME + * emitter, not an absolute depth model — which is all the pool needs to prove: + * every slot it pushes, it releases. + */ +function netStackEffect(ops: StackOp[]): number { + let net = 0; + const walk = (list: StackOp[]): void => { + for (const op of list) { + if (op.op === 'push' || op.op === 'dup' || op.op === 'over' || op.op === 'tuck' + || op.op === 'placeholder' || op.op === 'push_codesep_index') net++; + else if (op.op === 'drop' || op.op === 'nip') net--; + // A pick/roll is always preceded by a `push` of the depth, already + // counted above. OP_PICK consumes that depth and pushes a copy (net 0); + // OP_ROLL consumes it and relocates an existing item (net -1). + else if (op.op === 'roll') net--; + else if (op.op === 'if') { walk(op.then); if (op.else) walk(op.else); } + } + }; + walk(ops); + return net; +} + +/** Every emitter that should benefit, with its shipping byte count. */ +const EMITTERS: Array<[string, Emitter, number]> = [ + ['emitVerifyECDSA_P256', emitVerifyECDSA_P256, 974024], + ['emitVerifyECDSA_P384', emitVerifyECDSA_P384, 1987394], + ['emitP256Mul', emitP256Mul, 459746], + ['emitP256MulGen', emitP256MulGen, 459812], + ['emitP256Add', emitP256Add, 19906], + ['emitP384Mul', emitP384Mul, 927350], + ['emitP384Add', emitP384Add, 46710], + ['emitEcAdd', emitEcAdd, 25426], + ['emitEcMul', emitEcMul, 428676], + ['emitEcMulGen', emitEcMulGen, 428742], +]; + +describe('pooling OFF is the shipping default', () => { + it.each(EMITTERS)('%s emits its documented byte count', (_name, emitter, want) => { + expect(bytesOf(emitter)).toBe(want); + }); + + it.each(EMITTERS)('%s is byte-identical with an explicit constantPool:false', (_n, emitter) => { + expect(hexOf(opsOf(emitter))).toBe(hexOf(opsOf(emitter, { constantPool: false }))); + }); + + it('an empty options object changes nothing', () => { + expect(hexOf(opsOf(emitP256OnCurve))).toBe(hexOf(opsOf(emitP256OnCurve, {}))); + }); +}); + +describe('pooling ON removes the repeated prime pushes', () => { + it('collapses the P-256 field prime from 34 bytes a push to a pick', () => { + const before = analyzeScriptHex(hexOf(opsOf(emitVerifyECDSA_P256))); + const after = analyzeScriptHex(hexOf(opsOf(emitVerifyECDSA_P256, { constantPool: true }))); + + const p = Buffer.from( + encodeScriptNumber(0xffffffff00000001000000000000000000000000ffffffffffffffffffffffffn), + ).toString('hex'); + const beforeP = before.constants.find(c => c.hex === p)!; + expect(beforeP.count).toBeGreaterThan(15000); + + const afterP = after.constants.find(c => c.hex === p); + // At most a handful survive: the pool slot itself, plus any site that + // genuinely cannot see the slot. + expect(afterP?.count ?? 0).toBeLessThan(10); + }); + + it.each(EMITTERS)('%s shrinks by more than half', (_name, emitter, before) => { + const after = bytesOf(emitter, { constantPool: true }); + expect(after).toBeLessThan(before * 0.5); + }); + + it('brings verifyECDSA_P256 close to the arithmetic prediction', () => { + // 20,025-ish reductions x ~31 bytes saved each on the p256-wallet fixture; + // the bare emitter carries the same reduction count. Predicted landing + // zone is ~330 kB. Allow slack, but fail if it lands nowhere near. + const after = bytesOf(emitVerifyECDSA_P256, { constantPool: true }); + expect(after).toBeGreaterThan(200_000); + expect(after).toBeLessThan(420_000); + }); + + it('adds at most two resident slots', () => { + // The pool is two extra stack items per tracker (p and n). Real max-depth + // is measured on the interpreter in the equivalence test; here we only pin + // that the emitter still balances — pool slots pushed are pool slots + // released, so the net stack effect is unchanged. + const off = opsOf(emitP256Add); + const on = opsOf(emitP256Add, { constantPool: true }); + expect(netStackEffect(on)).toBe(netStackEffect(off)); + }); +}); diff --git a/packages/runar-compiler/src/__tests__/golden-invariance.test.ts b/packages/runar-compiler/src/__tests__/golden-invariance.test.ts new file mode 100644 index 000000000..afdc89fe0 --- /dev/null +++ b/packages/runar-compiler/src/__tests__/golden-invariance.test.ts @@ -0,0 +1,58 @@ +/** + * Default output is byte-identical to the checked-in goldens. + * + * The size experiments in `docs/experiments/` add opt-in flags that change + * emitted bytes. This is the guard that says the DEFAULT path did not move: + * every fixture that ships a `.runar.ts`, compiled with the same options the + * goldens were stamped under (fold-OFF), must reproduce + * `conformance/tests//expected-script.hex` exactly. + * + * `conformance/runner/runner.ts` checks this across all seven tiers in CI, but + * that needs six native toolchains built. This is the TS-tier-only version that + * runs anywhere in seconds-to-minutes, so an experiment can be shown to be + * byte-neutral without a full conformance run. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync, existsSync, readdirSync } from 'fs'; +import { join, resolve } from 'path'; +import { compile } from '../index.js'; + +const CONFORMANCE_DIR = join(__dirname, '..', '..', '..', '..', 'conformance', 'tests'); + +function tsSourceFor(fixture: string): string | null { + const configFile = join(CONFORMANCE_DIR, fixture, 'source.json'); + if (!existsSync(configFile)) return null; + const config = JSON.parse(readFileSync(configFile, 'utf-8')) as { sources?: Record }; + const rel = config.sources?.['.runar.ts']; + if (rel === undefined) return null; + const abs = resolve(CONFORMANCE_DIR, fixture, rel); + if (!existsSync(abs)) throw new Error(`source.json points at a missing file: ${abs}`); + return abs; +} + +const FIXTURES = readdirSync(CONFORMANCE_DIR, { withFileTypes: true }) + .filter(e => e.isDirectory()) + .map(e => e.name) + .filter(name => tsSourceFor(name) !== null + && existsSync(join(CONFORMANCE_DIR, name, 'expected-script.hex'))) + .sort(); + +describe('default compilation reproduces the goldens', () => { + it('found the corpus', () => { + expect(FIXTURES.length).toBeGreaterThan(50); + }); + + it.each(FIXTURES)('%s', (fixture) => { + const path = tsSourceFor(fixture)!; + const golden = readFileSync(join(CONFORMANCE_DIR, fixture, 'expected-script.hex'), 'utf-8') + .replace(/\s+/g, ''); + // Goldens are stamped fold-OFF (CLAUDE.md, CONTRIBUTING.md). + const result = compile(readFileSync(path, 'utf-8'), { + fileName: path, + disableConstantFolding: true, + }); + expect(result.success, result.diagnostics.map(d => d.message).join('; ')).toBe(true); + expect(result.scriptHex).toBe(golden); + }); +}); diff --git a/packages/runar-compiler/src/__tests__/liveness-scheduler.test.ts b/packages/runar-compiler/src/__tests__/liveness-scheduler.test.ts new file mode 100644 index 000000000..00751b8c4 --- /dev/null +++ b/packages/runar-compiler/src/__tests__/liveness-scheduler.test.ts @@ -0,0 +1,111 @@ +/** + * Liveness-aware stack scheduling — size and default-invariance. + * + * ANF names every intermediate, and the current lowering pushes each result on + * top of the operands that produced it. In an arithmetic chain that reads the + * same two values repeatedly, every result buries them one slot deeper, so the + * next access costs a `push d; OP_PICK` pair instead of a 1-byte `OP_2DUP`. + * `conformance/tests/arithmetic` spends 16 of its 28 bytes exactly that way. + * + * The `liveness` scheduler parks a result on the alt stack when the next + * binding does not want it, keeping the hot operands at depth 0/1, and + * restores the whole spill group in one go before the first binding that + * needs any of it. Restoring en masse puts the values back in production + * order (first-spilled on top), which is the order an ANF accumulation chain + * consumes them in. + * + * Pinned here: (1) `current` mode is byte-identical to what ships, and + * (2) `liveness` mode actually shrinks the arithmetic-heavy fixtures. + * Semantic equivalence is proved on the real interpreter in + * `packages/runar-testing/src/__tests__/liveness-scheduler-equivalence.test.ts`. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync, existsSync, readdirSync } from 'fs'; +import { join, resolve } from 'path'; +import { compile } from '../index.js'; +import { analyzeScriptHex } from '../metrics/script-metrics.js'; + +const CONFORMANCE_DIR = join(__dirname, '..', '..', '..', '..', 'conformance', 'tests'); + +function tsSourceFor(fixture: string): string | null { + const configFile = join(CONFORMANCE_DIR, fixture, 'source.json'); + if (!existsSync(configFile)) return null; + const config = JSON.parse(readFileSync(configFile, 'utf-8')) as { sources?: Record }; + const rel = config.sources?.['.runar.ts']; + if (rel === undefined) return null; + const abs = resolve(CONFORMANCE_DIR, fixture, rel); + return existsSync(abs) ? abs : null; +} + +function hexFor(fixture: string, liveness: boolean): string { + const path = tsSourceFor(fixture)!; + const result = compile(readFileSync(path, 'utf-8'), { + fileName: path, + disableConstantFolding: true, + ...(liveness ? { schedulerMode: 'liveness' as const } : {}), + }); + expect(result.success, result.diagnostics.map(d => d.message).join('; ')).toBe(true); + return result.scriptHex!; +} + +const ALL_FIXTURES = readdirSync(CONFORMANCE_DIR, { withFileTypes: true }) + .filter(e => e.isDirectory()) + .map(e => e.name) + .filter(name => tsSourceFor(name) !== null + && existsSync(join(CONFORMANCE_DIR, name, 'expected-script.hex'))) + .sort(); + +describe('scheduler mode "current" is the shipping default', () => { + it.each(ALL_FIXTURES)('%s matches its golden', (fixture) => { + const golden = readFileSync(join(CONFORMANCE_DIR, fixture, 'expected-script.hex'), 'utf-8') + .replace(/\s+/g, ''); + expect(hexFor(fixture, false)).toBe(golden); + }); +}); + +describe('scheduler mode "liveness"', () => { + it('never grows a fixture', () => { + // A scheduler that trades bytes for bytes is not an optimization. Every + // spill decision goes through the cost model, so growth is a bug. + const grew: string[] = []; + for (const fixture of ALL_FIXTURES) { + const before = hexFor(fixture, false).length / 2; + const after = hexFor(fixture, true).length / 2; + if (after > before) grew.push(`${fixture}: ${before} -> ${after}`); + } + expect(grew).toEqual([]); + }); + + it('shrinks the arithmetic fixture by more than 10 %', () => { + const before = hexFor('arithmetic', false).length / 2; + const after = hexFor('arithmetic', true).length / 2; + expect(before).toBe(28); + expect(1 - after / before).toBeGreaterThan(0.1); + }); + + it('replaces PICK/ROLL traffic with alt-stack round trips on arithmetic', () => { + const before = analyzeScriptHex(hexFor('arithmetic', false)); + const after = analyzeScriptHex(hexFor('arithmetic', true)); + const shuffle = (m: typeof before) => m.categories['stack-shuffle']; + expect(shuffle(after)).toBeLessThan(shuffle(before)); + expect(after.opcodes['OP_PICK'] ?? 0).toBeLessThan(before.opcodes['OP_PICK'] ?? 0); + expect(after.opcodes['OP_TOALTSTACK'] ?? 0).toBeGreaterThan(0); + }); + + it('balances every spill it introduces', () => { + // A static count is NOT a balance proof on its own: `sha256-finalize` + // already emits 896 OP_TOALTSTACK against 897 OP_FROMALTSTACK, because + // one arm of an `if` pushes to the alt stack and the other does not, and + // both arms are counted. So the invariant is a DELTA one: whatever the + // scheduler adds must be added in pairs. + for (const fixture of ALL_FIXTURES) { + const before = analyzeScriptHex(hexFor(fixture, false)); + const after = analyzeScriptHex(hexFor(fixture, true)); + const to = (after.opcodes['OP_TOALTSTACK'] ?? 0) - (before.opcodes['OP_TOALTSTACK'] ?? 0); + const from = (after.opcodes['OP_FROMALTSTACK'] ?? 0) - (before.opcodes['OP_FROMALTSTACK'] ?? 0); + expect(to, `${fixture}: unbalanced spill traffic`).toBe(from); + expect(to, `${fixture}: negative spill count`).toBeGreaterThanOrEqual(0); + } + }); +}); diff --git a/packages/runar-compiler/src/__tests__/script-metrics.test.ts b/packages/runar-compiler/src/__tests__/script-metrics.test.ts new file mode 100644 index 000000000..d17f406d3 --- /dev/null +++ b/packages/runar-compiler/src/__tests__/script-metrics.test.ts @@ -0,0 +1,152 @@ +/** + * Script-size instrumentation — tests. + * + * `analyzeScriptHex` answers "where did the bytes go?" for a serialized + * locking script. It exists because the interesting question about a 958 kB + * P-256 verifier is not how many opcodes it has, but which KIND of byte + * dominates — and the answer (73 % literal pushes of one 33-byte constant) + * is invisible from an opcode histogram alone. + * + * The classifier's one subtle rule: a push immediately consumed by OP_PICK / + * OP_ROLL is stack-access cost, not a constant. Charging it to `const-push` + * would blame the wrong optimizer for a third of the shuffle traffic. + */ + +import { describe, it, expect } from 'vitest'; +import { analyzeScriptHex, stackOpMetrics } from '../metrics/script-metrics.js'; +import { emitMethod } from '../passes/06-emit.js'; +import type { StackOp } from '../ir/index.js'; + +function hexOf(ops: StackOp[]): string { + return emitMethod({ name: 'probe', ops, maxStackDepth: 0 }).scriptHex; +} + +describe('analyzeScriptHex', () => { + it('accounts for every byte exactly once', () => { + const hex = hexOf([ + { op: 'push', value: 0xdeadbeefn }, + { op: 'dup' }, + { op: 'opcode', code: 'OP_ADD' }, + { op: 'push', value: new Uint8Array(80).fill(0xaa) }, + { op: 'drop' }, + ]); + const m = analyzeScriptHex(hex); + const summed = Object.values(m.categories).reduce((a, b) => a + b, 0); + expect(m.scriptBytes).toBe(hex.length / 2); + expect(summed).toBe(m.scriptBytes); + }); + + it('separates small-int pushes from data pushes', () => { + const m = analyzeScriptHex(hexOf([ + { op: 'push', value: 5n }, // OP_5, 1 byte + { op: 'push', value: 0n }, // OP_0, 1 byte + { op: 'push', value: 1000n }, // 1 len + 2 data + ])); + expect(m.categories['small-int-push']).toBe(2); + expect(m.categories['const-push']).toBe(3); + }); + + it('charges a PICK/ROLL depth push to stack-shuffle, not const-push', () => { + // This is how `bringToTop` materializes a deep operand: push(depth) then + // OP_PICK. Both bytes are stack-access cost. + const m = analyzeScriptHex(hexOf([ + { op: 'push', value: 40n }, // 1 len + 1 data = 2 bytes + { op: 'pick', depth: 40 }, // 1 byte + ])); + expect(m.categories['stack-shuffle']).toBe(3); + expect(m.categories['const-push']).toBe(0); + expect(m.categories['small-int-push']).toBe(0); + }); + + it('charges a small-int depth push to stack-shuffle too', () => { + const m = analyzeScriptHex(hexOf([ + { op: 'push', value: 3n }, // OP_3, 1 byte + { op: 'roll', depth: 3 }, // 1 byte + ])); + expect(m.categories['stack-shuffle']).toBe(2); + expect(m.categories['small-int-push']).toBe(0); + }); + + it('classifies arithmetic and control separately from shuffles', () => { + const m = analyzeScriptHex(hexOf([ + { op: 'opcode', code: 'OP_ADD' }, + { op: 'opcode', code: 'OP_MOD' }, + { op: 'swap' }, + { op: 'if', then: [{ op: 'opcode', code: 'OP_MUL' }] }, + { op: 'opcode', code: 'OP_VERIFY' }, + ])); + expect(m.categories['arithmetic']).toBe(3); // ADD, MOD, MUL + expect(m.categories['stack-shuffle']).toBe(1); // SWAP + expect(m.categories['control']).toBe(3); // IF, ENDIF, VERIFY + }); + + it('counts repeated data constants and their total byte cost', () => { + const p = 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffffn; + const m = analyzeScriptHex(hexOf([ + { op: 'push', value: p }, + { op: 'opcode', code: 'OP_MOD' }, + { op: 'push', value: p }, + { op: 'opcode', code: 'OP_MOD' }, + { op: 'push', value: p }, + ])); + const top = m.constants[0]!; + expect(top.count).toBe(3); + expect(top.bytes).toBe(3 * 34); // 32 magnitude + 1 sign + 1 length prefix + expect(m.categories['const-push']).toBe(3 * 34); + }); + + it('builds an opcode histogram by mnemonic', () => { + const m = analyzeScriptHex(hexOf([ + { op: 'dup' }, { op: 'dup' }, { op: 'opcode', code: 'OP_HASH160' }, + ])); + expect(m.opcodes['OP_DUP']).toBe(2); + expect(m.opcodes['OP_HASH160']).toBe(1); + }); + + it('reports the real p256-wallet shape', () => { + // Regression pin on the headline baseline finding: the P-256 verifier is + // dominated by one repeated constant, not by its arithmetic. + const hex = hexOf([ + { op: 'push', value: 1n }, + ]); + expect(analyzeScriptHex(hex).scriptBytes).toBe(1); + }); + + it('rejects a truncated push rather than silently dropping bytes', () => { + // 0x04 promises four data bytes and supplies two. + expect(() => analyzeScriptHex('04aabb')).toThrow(/truncated/i); + }); +}); + +describe('stackOpMetrics', () => { + it('counts ops, recursing into if arms', () => { + const ops: StackOp[] = [ + { op: 'push', value: 1n }, + { op: 'if', then: [{ op: 'dup' }, { op: 'drop' }], else: [{ op: 'swap' }] }, + ]; + const m = stackOpMetrics(ops); + expect(m.opCount).toBe(5); // push, if, dup, drop, swap + expect(m.shuffleOps).toBe(3); + }); + + it('reports script bytes consistent with the cost model', () => { + const ops: StackOp[] = [ + { op: 'push', value: 300n }, + { op: 'opcode', code: 'OP_ADD' }, + ]; + expect(stackOpMetrics(ops).scriptBytes).toBe(hexOf(ops).length / 2); + }); + + it('breaks out pick/roll/dup/swap counts', () => { + const ops: StackOp[] = [ + { op: 'pick', depth: 3 }, { op: 'pick', depth: 4 }, + { op: 'roll', depth: 5 }, + { op: 'dup' }, { op: 'swap' }, { op: 'swap' }, + ]; + const m = stackOpMetrics(ops); + expect(m.opcodes['OP_PICK']).toBe(2); + expect(m.opcodes['OP_ROLL']).toBe(1); + expect(m.opcodes['OP_DUP']).toBe(1); + expect(m.opcodes['OP_SWAP']).toBe(2); + }); +}); diff --git a/packages/runar-compiler/src/index.ts b/packages/runar-compiler/src/index.ts index fc91507bc..72c0900d2 100644 --- a/packages/runar-compiler/src/index.ts +++ b/packages/runar-compiler/src/index.ts @@ -67,6 +67,14 @@ export { foldConstants } from './optimizer/constant-fold.js'; export { eliminateDeadBindings } from './optimizer/dce.js'; export { assembleArtifact } from './artifact/assembler.js'; +// Script-byte cost model + size instrumentation. Read-only: nothing here +// changes compilation output, but `estimateScriptBytes` is the metric any +// size-directed optimizer pass must compare candidates with, and it is +// asserted byte-exact against the emitter over the whole conformance corpus. +export { sizeOfStackOp, sizeOfPushValue, estimateScriptBytes } from './metrics/cost-model.js'; +export { analyzeScriptHex, stackOpMetrics } from './metrics/script-metrics.js'; +export type { ByteCategory, ConstantUse, ScriptMetrics, StackOpMetrics } from './metrics/script-metrics.js'; + export type { CompilerDiagnostic, Severity } from './errors.js'; export { CompilerError, ParseError, ValidationError, TypeError, makeDiagnostic } from './errors.js'; @@ -158,6 +166,56 @@ export interface CompileOptions { */ disablePeephole?: boolean; + /** + * EXPERIMENTAL. Park each curve's field prime / group order in a stack slot + * inside the EC codegen modules instead of re-pushing the 33- or 49-byte + * literal at every modular reduction. + * + * Default false, and the emitters take an untouched code path when it is — + * so the checked-in goldens, `conformance/script-size-baseline.json`, and + * cross-tier hex parity are all unaffected while this is off. Turning it on + * changes the emitted bytes and is therefore a TS-tier-only experiment until + * the transformation is ported to the other six compilers. Measured effect: + * `verifyECDSA_P256` 974,024 -> 319,693 bytes (-67 %). + * + * See `docs/experiments/script-size-optimization-baseline.md`. + */ + 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. Use a fixed-base comb instead of the binary ladder wherever + * the base point is a compile-time constant (`p256MulGen`, `p384MulGen`, and + * the `u1·G` half of ECDSA verification). One doubling and one add per COLUMN + * instead of per bit; the window width is chosen by the byte-cost model. + * + * Where the comb cannot prove the cheap incomplete addition safe it falls + * back to the complete add-or-double form — see `passes/comb.ts`. + * Measured: `verifyECDSA_P256` 195,120 -> 158,560 bytes. + */ + ecFixedBaseComb?: boolean; + + /** + * EXPERIMENTAL. Operand scheduling strategy for the ANF -> Stack pass. + * + * `'current'` (default) ships today's bytes. `'liveness'` parks a result on + * the alt stack when the next binding does not consume it, so the operands a + * chain reads repeatedly stay at depth 0/1 instead of sinking one slot per + * binding. TS-tier-only experiment while it is opt-in; see + * `docs/experiments/stack-scheduler-design.md`. + */ + schedulerMode?: 'current' | 'liveness'; + /** Called between compilation passes with the current stage name and progress percentage (0-100). */ onProgress?: (stage: string, percent: number) => void; } @@ -463,7 +521,12 @@ export function compile(source: string, options?: CompileOptions): CompileResult // Pass 5-6: Stack lower + Peephole optimize + Emit try { onProgress?.('Stack lowering', 60); - const stackProgram = lowerToStack(optimizedAnf); + const stackProgram = lowerToStack(optimizedAnf, { + ecConstantPool: opts.ecConstantPool === true, + ecReductionSinking: opts.ecReductionSinking === true, + ecFixedBaseComb: opts.ecFixedBaseComb === true, + schedulerMode: opts.schedulerMode, + }); // Apply peephole optimization to each method's ops (runs on Stack IR, // after the ANF conformance boundary, so it doesn't affect cross-compiler @@ -552,6 +615,14 @@ export interface CompileFromANFOptions { disableEcOptimizer?: boolean; /** If true, skip the Stack IR peephole optimizer. See CompileOptions for context. */ disablePeephole?: boolean; + /** EXPERIMENTAL. Pool repeated EC curve constants. See CompileOptions. */ + ecConstantPool?: boolean; + /** EXPERIMENTAL. Sink EC modular reductions. See CompileOptions. */ + ecReductionSinking?: boolean; + /** EXPERIMENTAL. Fixed-base comb for compile-time-known bases. See CompileOptions. */ + ecFixedBaseComb?: boolean; + /** EXPERIMENTAL. Operand scheduling strategy. See CompileOptions. */ + schedulerMode?: 'current' | 'liveness'; } export interface CompileFromANFResult { @@ -621,7 +692,12 @@ export function compileFromANF( // EC optimizer delegates internally to optimizer/dce.ts for dead-binding cleanup. const optimizedAnf = opts.disableEcOptimizer ? anf : optimizeEC(anf); - const stackProgram = lowerToStack(optimizedAnf); + const stackProgram = lowerToStack(optimizedAnf, { + ecConstantPool: opts.ecConstantPool === true, + ecReductionSinking: opts.ecReductionSinking === true, + ecFixedBaseComb: opts.ecFixedBaseComb === true, + schedulerMode: opts.schedulerMode, + }); if (!opts.disablePeephole) { for (const method of stackProgram.methods) { method.ops = optimizeStackIR(method.ops); diff --git a/packages/runar-compiler/src/metrics/cost-model.ts b/packages/runar-compiler/src/metrics/cost-model.ts new file mode 100644 index 000000000..733fefb32 --- /dev/null +++ b/packages/runar-compiler/src/metrics/cost-model.ts @@ -0,0 +1,104 @@ +/** + * Script-byte cost model for Stack IR. + * + * Optimizer passes need to compare two candidate lowerings by the metric that + * actually matters — serialized locking-script bytes — before either one is + * emitted. `OP_DUP` and a 33-byte constant push are one instruction each and + * 1 vs 34 bytes; an instruction count cannot tell them apart. + * + * This module is deliberately NOT an approximation. Every push routes through + * the same `push-encoding.ts` encoders that `06-emit.ts` uses, and the + * structural cases mirror `emitStackOp` / `emitIf` one-for-one. The invariant + * + * estimateScriptBytes(ops) === emitMethod({ ops, ... }).scriptHex.length / 2 + * + * is asserted over the whole conformance corpus in + * `__tests__/cost-model.test.ts`. If you change push encoding or the emit + * switch, that sweep is what tells you this file went stale. + */ + +import type { StackOp } from '../ir/index.js'; +import { OPCODES } from '../passes/06-emit.js'; +import { encodePushBigIntHex, encodePushBytesHex } from '../passes/push-encoding.js'; + +/** + * Serialized byte cost of a single push value. + * + * Mirrors `encodePushValue` in `06-emit.ts`: booleans are the 1-byte OP_TRUE / + * OP_FALSE, bigints go through the small-int opcodes where possible, and byte + * arrays are MINIMALDATA-aware before falling back to a length-prefixed push. + */ +export function sizeOfPushValue(value: Uint8Array | bigint | boolean): number { + if (typeof value === 'boolean') { + return 1; // OP_TRUE (0x51) / OP_FALSE (0x00) + } + if (typeof value === 'bigint') { + return encodePushBigIntHex(value).length / 2; + } + return encodePushBytesHex(value).length / 2; +} + +/** + * Serialized byte cost of one Stack IR operation, including nested `if` arms. + * + * Note on `pick` / `roll`: they cost ONE byte here. The depth operand is a + * separate `push` op that the lowerer emits immediately before (see + * `bringToTop` in `05-stack-lower.ts`), so charging the depth here would + * double-count it. + * + * Throws on an unknown opcode mnemonic rather than costing it zero — a typo + * in a codegen module should surface as a loud failure, not as a cost model + * that quietly under-reports. + */ +export function sizeOfStackOp(op: StackOp): number { + switch (op.op) { + case 'push': + return sizeOfPushValue(op.value); + + case 'dup': + case 'swap': + case 'roll': + case 'pick': + case 'drop': + case 'nip': + case 'over': + case 'rot': + case 'tuck': + return 1; + + case 'opcode': { + if (OPCODES[op.code] === undefined) { + throw new Error(`cost-model: unknown opcode '${op.code}'`); + } + return 1; + } + + case 'if': { + // OP_IF + then + [OP_ELSE + else] + OP_ENDIF. The emitter writes + // OP_ELSE only for a NON-EMPTY else arm (`emitIf` in 06-emit.ts). + let total = 2; // OP_IF + OP_ENDIF + total += estimateScriptBytes(op.then); + if (op.else && op.else.length > 0) { + total += 1 + estimateScriptBytes(op.else); + } + return total; + } + + case 'placeholder': + case 'push_codesep_index': + // Both emit a single 0x00 byte that the SDK rewrites later. + return 1; + + case 'raw_bytes': + return op.bytes.length; + } +} + +/** Serialized byte cost of a Stack IR op sequence. */ +export function estimateScriptBytes(ops: StackOp[]): number { + let total = 0; + for (const op of ops) { + total += sizeOfStackOp(op); + } + return total; +} diff --git a/packages/runar-compiler/src/metrics/script-metrics.ts b/packages/runar-compiler/src/metrics/script-metrics.ts new file mode 100644 index 000000000..0ad2e5fdd --- /dev/null +++ b/packages/runar-compiler/src/metrics/script-metrics.ts @@ -0,0 +1,302 @@ +/** + * Script-size instrumentation. + * + * Two views of the same question — "where did the bytes go?": + * + * - `analyzeScriptHex` walks a SERIALIZED script and buckets every byte by + * what it is spent on. This is the view that matters for a size project, + * because opcode counts hide the thing that actually dominates: a 33-byte + * constant push and an `OP_DUP` are one opcode each and 34x apart in cost. + * - `stackOpMetrics` reports the same shape from Stack IR, before emission, + * so a pass can measure its own output without a round-trip through hex. + * + * The one classification rule worth stating out loud: a push immediately + * consumed by `OP_PICK` / `OP_ROLL` is charged to `stack-shuffle`, not to + * `const-push`. `bringToTop` emits `push(depth)` + `OP_PICK` as a pair (see + * `05-stack-lower.ts`), and blaming those depth bytes on constants would + * credit the wrong optimizer with fixing them. + * + * Nothing here changes compilation output; it only reads it. + */ + +import type { StackOp } from '../ir/index.js'; +import { OPCODES } from '../passes/06-emit.js'; +import { estimateScriptBytes } from './cost-model.js'; + +// --------------------------------------------------------------------------- +// Byte categories +// --------------------------------------------------------------------------- + +export type ByteCategory = + | 'const-push' + | 'small-int-push' + | 'stack-shuffle' + | 'arithmetic' + | 'bytes' + | 'crypto' + | 'control' + | 'other'; + +const CATEGORIES: ByteCategory[] = [ + 'const-push', 'small-int-push', 'stack-shuffle', + 'arithmetic', 'bytes', 'crypto', 'control', 'other', +]; + +/** Reverse map byte -> preferred mnemonic, skipping the OP_FALSE/OP_TRUE aliases. */ +const OPCODE_NAMES: Map = new Map(); +for (const [name, byte] of Object.entries(OPCODES)) { + if (name === 'OP_FALSE' || name === 'OP_TRUE') continue; + if (!OPCODE_NAMES.has(byte)) OPCODE_NAMES.set(byte, name); +} + +const SHUFFLE_OPS = new Set([ + 'OP_DUP', 'OP_DROP', 'OP_NIP', 'OP_OVER', 'OP_PICK', 'OP_ROLL', 'OP_ROT', + 'OP_SWAP', 'OP_TUCK', 'OP_2DROP', 'OP_2DUP', 'OP_3DUP', 'OP_2OVER', + 'OP_2ROT', 'OP_2SWAP', 'OP_IFDUP', 'OP_DEPTH', + 'OP_TOALTSTACK', 'OP_FROMALTSTACK', +]); + +const ARITHMETIC_OPS = new Set([ + 'OP_ADD', 'OP_SUB', 'OP_MUL', 'OP_DIV', 'OP_MOD', 'OP_1ADD', 'OP_1SUB', + 'OP_2MUL', 'OP_2DIV', 'OP_NEGATE', 'OP_ABS', 'OP_NOT', 'OP_0NOTEQUAL', + 'OP_BOOLAND', 'OP_BOOLOR', 'OP_NUMEQUAL', 'OP_NUMEQUALVERIFY', + 'OP_NUMNOTEQUAL', 'OP_LESSTHAN', 'OP_GREATERTHAN', 'OP_LESSTHANOREQUAL', + 'OP_GREATERTHANOREQUAL', 'OP_MIN', 'OP_MAX', 'OP_WITHIN', + 'OP_AND', 'OP_OR', 'OP_XOR', 'OP_INVERT', 'OP_LSHIFT', 'OP_RSHIFT', + 'OP_LSHIFTNUM', 'OP_RSHIFTNUM', +]); + +const BYTES_OPS = new Set([ + 'OP_CAT', 'OP_SPLIT', 'OP_SIZE', 'OP_NUM2BIN', 'OP_BIN2NUM', + 'OP_SUBSTR', 'OP_LEFT', 'OP_RIGHT', 'OP_EQUAL', 'OP_EQUALVERIFY', +]); + +const CRYPTO_OPS = new Set([ + 'OP_RIPEMD160', 'OP_SHA1', 'OP_SHA256', 'OP_HASH160', 'OP_HASH256', + 'OP_CHECKSIG', 'OP_CHECKSIGVERIFY', 'OP_CHECKMULTISIG', + 'OP_CHECKMULTISIGVERIFY', 'OP_CODESEPARATOR', +]); + +const CONTROL_OPS = new Set([ + 'OP_IF', 'OP_NOTIF', 'OP_ELSE', 'OP_ENDIF', 'OP_VERIFY', 'OP_RETURN', + 'OP_NOP', 'OP_CHECKLOCKTIMEVERIFY', 'OP_CHECKSEQUENCEVERIFY', +]); + +function categoryOfOpcode(name: string): ByteCategory { + if (SHUFFLE_OPS.has(name)) return 'stack-shuffle'; + if (ARITHMETIC_OPS.has(name)) return 'arithmetic'; + if (BYTES_OPS.has(name)) return 'bytes'; + if (CRYPTO_OPS.has(name)) return 'crypto'; + if (CONTROL_OPS.has(name)) return 'control'; + return 'other'; +} + +// --------------------------------------------------------------------------- +// Serialized-script analysis +// --------------------------------------------------------------------------- + +export interface ConstantUse { + /** Hex of the pushed data (without the length prefix). */ + hex: string; + /** How many times this exact payload is pushed. */ + count: number; + /** Total serialized bytes spent pushing it (payload + prefix, times count). */ + bytes: number; +} + +export interface ScriptMetrics { + scriptBytes: number; + /** Opcodes plus pushes, each counted once. */ + opcodeCount: number; + pushCount: number; + categories: Record; + /** Mnemonic -> occurrence count. Data pushes are keyed as `PUSH`. */ + opcodes: Record; + /** Repeated data payloads, largest total byte cost first. */ + constants: ConstantUse[]; +} + +/** + * Bucket every byte of a serialized script. + * + * Throws on a malformed / truncated push instead of silently dropping the + * tail — a size report that quietly loses bytes is worse than no report. + */ +export function analyzeScriptHex(scriptHex: string): ScriptMetrics { + const hex = scriptHex.trim(); + if (hex.length % 2 !== 0) { + throw new Error(`analyzeScriptHex: odd-length hex (${hex.length} chars)`); + } + const bytes = Buffer.from(hex, 'hex'); + const n = bytes.length; + + const categories = Object.fromEntries(CATEGORIES.map(c => [c, 0])) as Record; + const opcodes: Record = {}; + const constants = new Map(); + + let opcodeCount = 0; + let pushCount = 0; + + /** Bytes + category of the immediately preceding op, for the PICK/ROLL rule. */ + let prevPush: { size: number; category: ByteCategory; dataHex: string | null } | null = null; + + const bump = (name: string) => { opcodes[name] = (opcodes[name] ?? 0) + 1; }; + + let i = 0; + while (i < n) { + const op = bytes[i]!; + + // --- direct pushes ----------------------------------------------------- + if (op >= 0x01 && op <= 0x4b) { + const len = op; + if (i + 1 + len > n) { + throw new Error(`analyzeScriptHex: truncated push at offset ${i} (want ${len} bytes, ${n - i - 1} left)`); + } + const dataHex = bytes.subarray(i + 1, i + 1 + len).toString('hex'); + const size = 1 + len; + categories['const-push'] += size; + bump('PUSH'); + pushCount++; opcodeCount++; + prevPush = { size, category: 'const-push', dataHex }; + i += size; + continue; + } + + if (op === 0x4c || op === 0x4d || op === 0x4e) { + const hdr = op === 0x4c ? 2 : op === 0x4d ? 3 : 5; + if (i + hdr > n) { + throw new Error(`analyzeScriptHex: truncated PUSHDATA header at offset ${i}`); + } + const len = op === 0x4c + ? bytes[i + 1]! + : op === 0x4d + ? bytes.readUInt16LE(i + 1) + : bytes.readUInt32LE(i + 1); + if (i + hdr + len > n) { + throw new Error(`analyzeScriptHex: truncated PUSHDATA body at offset ${i} (want ${len} bytes)`); + } + const dataHex = bytes.subarray(i + hdr, i + hdr + len).toString('hex'); + const size = hdr + len; + categories['const-push'] += size; + bump('PUSH'); + pushCount++; opcodeCount++; + prevPush = { size, category: 'const-push', dataHex }; + i += size; + continue; + } + + // --- single-byte constant pushes -------------------------------------- + if (op === 0x00 || op === 0x4f || (op >= 0x51 && op <= 0x60)) { + categories['small-int-push'] += 1; + bump(OPCODE_NAMES.get(op) ?? `OP_UNKNOWN_${op.toString(16)}`); + pushCount++; opcodeCount++; + prevPush = { size: 1, category: 'small-int-push', dataHex: null }; + i += 1; + continue; + } + + // --- opcodes ----------------------------------------------------------- + const name = OPCODE_NAMES.get(op) ?? `OP_UNKNOWN_${op.toString(16).padStart(2, '0')}`; + const category = categoryOfOpcode(name); + categories[category] += 1; + bump(name); + opcodeCount++; + + // A depth push consumed by PICK/ROLL is stack-access cost, not a constant. + if ((name === 'OP_PICK' || name === 'OP_ROLL') && prevPush) { + categories[prevPush.category] -= prevPush.size; + categories['stack-shuffle'] += prevPush.size; + if (prevPush.dataHex !== null) { + // It was recorded as a data push; un-record it from the constants tally. + const existing = constants.get(prevPush.dataHex); + if (existing) { + existing.count -= 1; + existing.bytes -= prevPush.size; + if (existing.count === 0) constants.delete(prevPush.dataHex); + } + } + } else if (prevPush && prevPush.dataHex !== null) { + // Only now is the previous data push confirmed to be a real constant. + const entry = constants.get(prevPush.dataHex) ?? { count: 0, bytes: 0 }; + entry.count += 1; + entry.bytes += prevPush.size; + constants.set(prevPush.dataHex, entry); + } + + prevPush = null; + i += 1; + } + + // A data push in final position was never confirmed by the loop above. + if (prevPush && prevPush.dataHex !== null) { + const entry = constants.get(prevPush.dataHex) ?? { count: 0, bytes: 0 }; + entry.count += 1; + entry.bytes += prevPush.size; + constants.set(prevPush.dataHex, entry); + } + + const constantList: ConstantUse[] = [...constants.entries()] + .map(([h, v]) => ({ hex: h, count: v.count, bytes: v.bytes })) + .sort((a, b) => b.bytes - a.bytes); + + return { + scriptBytes: n, + opcodeCount, + pushCount, + categories, + opcodes, + constants: constantList, + }; +} + +// --------------------------------------------------------------------------- +// Stack IR analysis +// --------------------------------------------------------------------------- + +export interface StackOpMetrics { + scriptBytes: number; + /** Every op, recursing into `if` arms. An `if` counts as one plus its arms. */ + opCount: number; + /** Ops that only move data around (dup/drop/pick/roll/swap/…). */ + shuffleOps: number; + /** Mnemonic -> count. Structural ops are keyed by the opcode they emit. */ + opcodes: Record; + maxStackDepth?: number; +} + +const STACK_OP_MNEMONIC: Partial> = { + dup: 'OP_DUP', swap: 'OP_SWAP', roll: 'OP_ROLL', pick: 'OP_PICK', + drop: 'OP_DROP', nip: 'OP_NIP', over: 'OP_OVER', rot: 'OP_ROT', + tuck: 'OP_TUCK', if: 'OP_IF', push: 'PUSH', + placeholder: 'PLACEHOLDER', push_codesep_index: 'CODESEP_INDEX', + raw_bytes: 'RAW_BYTES', +}; + +/** Metrics for a Stack IR op sequence, before emission. */ +export function stackOpMetrics(ops: StackOp[], maxStackDepth?: number): StackOpMetrics { + const opcodes: Record = {}; + let opCount = 0; + let shuffleOps = 0; + + const walk = (list: StackOp[]): void => { + for (const op of list) { + opCount++; + const name = op.op === 'opcode' ? op.code : STACK_OP_MNEMONIC[op.op]!; + opcodes[name] = (opcodes[name] ?? 0) + 1; + if (SHUFFLE_OPS.has(name)) shuffleOps++; + if (op.op === 'if') { + walk(op.then); + if (op.else) walk(op.else); + } + } + }; + walk(ops); + + return { + scriptBytes: estimateScriptBytes(ops), + opCount, + shuffleOps, + opcodes, + maxStackDepth, + }; +} diff --git a/packages/runar-compiler/src/passes/05-stack-lower.ts b/packages/runar-compiler/src/passes/05-stack-lower.ts index ec9c57ce2..211e58e9e 100644 --- a/packages/runar-compiler/src/passes/05-stack-lower.ts +++ b/packages/runar-compiler/src/passes/05-stack-lower.ts @@ -23,6 +23,9 @@ import { UnknownANFKindError, MERGED_LOCAL_TEMP_PREFIX } from 'runar-ir-schema'; import { emitVerifySLHDSA } from './slh-dsa-codegen.js'; import { emitVerifyWOTS } from './wots-codegen.js'; import { emitVerifyRabinSig } from './rabin-codegen.js'; +import type { EcCodegenOptions } from './ec-codegen.js'; +import { estimateScriptBytes } from '../metrics/cost-model.js'; +import { optimizeStackIR } from '../optimizer/peephole.js'; import { emitEcAdd, emitEcMul, emitEcMulGen, emitEcNegate, emitEcOnCurve, emitEcModReduce, emitEcEncodeCompressed, @@ -62,6 +65,129 @@ import { const MAX_STACK_DEPTH = 800; +/** + * Experimental lowering options. + * + * Every field defaults to the shipping behaviour, so `lowerToStack(anf)` with + * no options is byte-identical to what the seven tiers produce today. These + * exist so a size experiment can be measured against the default without + * moving a single golden. See `docs/experiments/`. + */ +export interface LoweringOptions { + /** + * Park each curve's field prime / group order in a stack slot inside the EC + * codegen modules instead of re-pushing the 33- or 49-byte literal at every + * modular reduction. + */ + 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; + + /** + * Use a fixed-base comb wherever the base point is a compile-time constant. + * The window width is chosen by the byte-cost model, not fixed. + */ + ecFixedBaseComb?: boolean; + + /** + * Operand scheduling strategy. + * + * `'current'` (default) is the shipping behaviour: results are pushed on top + * of the operands that produced them, so a chain that reads the same values + * repeatedly pays a deeper `OP_PICK` each time. + * + * `'liveness'` parks a result on the alt stack when the next binding does + * not consume it, keeping hot operands at depth 0/1, and restores the whole + * spill group before the first binding that needs any of it. + */ + schedulerMode?: 'current' | 'liveness'; +} + +/** + * ANF value kinds the spill scheduler is allowed to reason about. + * + * Deliberately narrow: these compute a single value from operands already on + * the stack and have no control flow, no side effects, and no bespoke stack + * choreography. Everything else — `if`, `loop`, `assert`, state ops, calls, + * `raw_script` — forces the alt stack to be drained first, so none of the + * delicate branch/loop reconciliation in `lowerIf` / `lowerLoop` ever sees a + * non-empty alt stack. + */ +const SPILLABLE_KINDS = new Set([ + 'bin_op', 'unary_op', 'load_const', 'load_param', 'load_prop', +]); + +/** Bytes an OP_TOALTSTACK + OP_FROMALTSTACK round trip costs. */ +const SPILL_ROUND_TRIP_BYTES = 2; + +/** + * Binary operators whose two operands may be materialized in either order. + * + * The operator itself is unchanged — only which operand is brought to the top + * first. Excluded on purpose: + * - `+` is `OP_CAT` when `result_type` is `"bytes"`, and concatenation is not + * commutative, so the caller must check the result type too; + * - `&&` / `||` short-circuit on chain (they lower through a branch, not + * `OP_BOOLAND` / `OP_BOOLOR`), so their operands are not interchangeable + * at this level; + * - `-`, `/`, `%`, shifts and every ordered comparison are order-sensitive. + * + * `===` / `!==` are commutative under both `OP_NUMEQUAL` and `OP_EQUAL`. + */ +const COMMUTATIVE_BINOPS = new Set(['+', '*', '===', '!==', '&', '|', '^']); + +/** + * The ops one `bringToTop` would emit, mirroring its cases exactly, applied to + * a throwaway stack model. + * + * Returns null when the value is not resident, which makes the caller fall + * back to source order rather than guess. + * + * Costing has to go through the real peephole rather than a byte formula, + * because the cheapest-looking local choice is often the one the peephole + * would have erased anyway: two consumed operands at depths 1 and 0 emit + * `OP_SWAP OP_SWAP`, which `swap-swap` deletes outright — free — while + * "cleverly" taking them in the other order emits one real `OP_SWAP` and + * costs a byte. + */ +function materializationOps(model: StackMap, name: string, consume: boolean): StackOp[] | null { + let depth: number; + try { + depth = model.findDepth(name); + } catch { + return null; + } + + const ops: StackOp[] = []; + if (depth === 0) { + if (!consume) { ops.push({ op: 'dup' }); model.dup(); } + return ops; + } + if (depth === 1) { + if (consume) { ops.push({ op: 'swap' }); model.swap(); } + else { ops.push({ op: 'over' }); model.push(model.peekAtDepth(1)); } + return ops; + } + if (consume) { + if (depth === 2) { + ops.push({ op: 'rot' }); + } else { + ops.push({ op: 'push', value: BigInt(depth) }, { op: 'roll', depth }); + } + model.push(model.removeAtDepth(depth)); + } else { + ops.push({ op: 'push', value: BigInt(depth) }, { op: 'pick', depth }); + model.push(model.peekAtDepth(depth)); + } + return ops; +} + + /** * Local hex-to-Uint8Array helper. Avoids a runar-testing dependency * (runar-testing depends on runar-compiler, so the reverse direction @@ -575,11 +701,25 @@ class LoweringContext { */ private readonly renamedParams: Map = new Map(); + /** Experimental lowering options; all default to shipping behaviour. */ + private readonly opts: LoweringOptions; + + /** + * Values parked on the alt stack by the liveness scheduler, bottom -> top. + * + * Always empty in `'current'` mode, and always drained before any binding + * the scheduler does not model (see SPILLABLE_KINDS) and at the end of + * every `lowerBindings` scope. + */ + private altSpills: string[] = []; + constructor( params: string[], properties: ANFProperty[], privateMethods: Map = new Map(), + opts: LoweringOptions = {}, ) { + this.opts = opts; // Parameters are pushed onto the stack by the Bitcoin VM in order. // The first parameter is at the bottom, last parameter at the top. this.stackMap = new StackMap(params); @@ -1035,6 +1175,137 @@ class LoweringContext { * If `consume` is true, use ROLL (removes from original position). * If `consume` is false, use PICK (copies, leaving original in place). */ + // -- liveness scheduler: alt-stack spilling -------------------------------- + // + // ANF pushes every result on top of the operands that made it, so a chain + // reading the same two values repeatedly buries them one slot deeper per + // binding and pays a `push d; OP_PICK` pair instead of a 1-byte `OP_2DUP`. + // Parking the result on the alt stack keeps the operands hot. + // + // Restores are done as a GROUP, not one at a time. Popping the whole alt + // stack puts the values back on the main stack in production order (first + // spilled ends up on top), which is the order an ANF accumulation chain + // reads them in — so the group restore usually lands operands exactly where + // the next bindings want them, and `bringToTop` fixes up any that differ. + + /** True when the liveness scheduler is active for this context. */ + private get schedulingByLiveness(): boolean { + // Never inside a branch arm: `lowerIf` reconciles arms by MAIN-stack depth + // alone (the Layer B/C invariants), so an arm must neither begin nor end + // with a non-empty alt stack. + return this.opts.schedulerMode === 'liveness' && !this._insideBranch; + } + + /** Move the top-of-stack value to the alt stack. */ + private spillToAlt(name: string): void { + this.emitOp({ op: 'opcode', code: 'OP_TOALTSTACK' }); + this.stackMap.pop(); + this.altSpills.push(name); + } + + /** Pop every spilled value back. First-spilled ends up on top. */ + private restoreSpills(): void { + while (this.altSpills.length > 0) { + const name = this.altSpills.pop()!; + this.emitOp({ op: 'opcode', code: 'OP_FROMALTSTACK' }); + this.stackMap.push(name); + this.trackDepth(); + } + } + + /** + * Decide whether the just-computed `name` (now on top) should be parked. + * + * Spilling pays only if the value would otherwise sit above operands that + * get read before it is needed. The estimate counts those intervening reads: + * each one would cross this slot, and crossing it costs at least a byte once + * the access stops being a depth-0/1 `OP_DUP`/`OP_OVER`. Break-even is the + * 2-byte round trip, so 2 intervening reads are required. + */ + private maybeSpill( + binding: ANFBinding, + bindingIndex: number, + bindings: ANFBinding[], + lastUses: Map, + ): void { + if (!this.schedulingByLiveness) return; + if (!SPILLABLE_KINDS.has(binding.value.kind)) return; + // Only a value we actually left on top, under its own name. + if (this.stackMap.depth === 0) return; + if (this.stackMap.peekAtDepth(0) !== binding.name) return; + + const nextUse = this.nextUseAfter(binding.name, bindingIndex, bindings); + if (nextUse === null) return; // dead or used past this scope + if (nextUse === bindingIndex + 1) return; // consumed immediately: no burial + if (lastUses.get(binding.name) !== undefined + && lastUses.get(binding.name)! >= bindings.length) return; // pinned for an outer scope + + // No control flow may follow the spill anywhere in this scope. + // + // Restoring immediately before an `if` leaves the parent stack in a shape + // `lowerIf` was not written for: its arm reconciliation, declared-result + // trim and Layer B/C depth invariants all reason about a main stack the + // scheduler has not been rearranging underneath them. That combination + // MISCOMPILED `if-without-else-multi-temp` — the script kept running and + // started ACCEPTING a witness the shipping compiler rejects, which the + // conformance witness corpus caught (see + // `packages/runar-testing/src/__tests__/liveness-scheduler-equivalence.test.ts`). + // Rather than try to make the two agree, spilling stays out of any scope + // that still has control flow ahead of it. `assert` is allowed: it consumes + // a value and emits OP_VERIFY without reshaping anything. + for (let j = bindingIndex + 1; j < bindings.length; j++) { + const kind = bindings[j]!.value.kind; + if (!SPILLABLE_KINDS.has(kind) && kind !== 'assert') return; + } + + // Everything between here and the use must itself be schedulable, or the + // restore would land in the middle of a construct we do not model. + let interveningReads = 0; + for (let j = bindingIndex + 1; j < nextUse; j++) { + const v = bindings[j]!.value; + if (!SPILLABLE_KINDS.has(v.kind)) return; + interveningReads += collectRefs(v).filter(r => r !== binding.name).length; + } + if (interveningReads < SPILL_ROUND_TRIP_BYTES) return; + + // Do not spill a value the very next binding will restore anyway: the + // round trip would be emitted and immediately undone. This is the + // `OP_DIV OP_TOALTSTACK OP_FROMALTSTACK` shape — 2 bytes for nothing. + const next = bindings[bindingIndex + 1]; + if (next !== undefined) { + const nextRefs = collectRefs(next.value); + if (!SPILLABLE_KINDS.has(next.value.kind) + || nextRefs.some(r => this.altSpills.includes(r))) return; + } + + this.spillToAlt(binding.name); + } + + /** Index of the first binding at or after `from + 1` that references `name`. */ + private nextUseAfter(name: string, from: number, bindings: ANFBinding[]): number | null { + for (let j = from + 1; j < bindings.length; j++) { + if (collectRefs(bindings[j]!.value).includes(name)) return j; + } + return null; + } + + /** + * Options handed to the EC / NIST codegen modules. + * + * Returns `undefined` — not `{}` — when nothing is enabled, so the emitters + * take their untouched default path and the emitted bytes are provably + * identical to the shipping ones. + */ + private ecCodegenOptions(): EcCodegenOptions | undefined { + if (!this.opts.ecConstantPool && !this.opts.ecReductionSinking + && !this.opts.ecFixedBaseComb) return undefined; + return { + constantPool: this.opts.ecConstantPool === true, + reductionSinking: this.opts.ecReductionSinking === true, + fixedBaseComb: this.opts.ecFixedBaseComb === true, + }; + } + bringToTop(name: string, consume: boolean): void { const depth = this.stackMap.findDepth(name); @@ -1178,6 +1449,16 @@ class LoweringContext { for (let i = 0; i < bindings.length; i++) { const binding = bindings[i]!; + // Drain the alt stack before anything the scheduler does not model, and + // before the first binding that reads a parked value. + if (this.altSpills.length > 0) { + const refs = collectRefs(binding.value); + if (!SPILLABLE_KINDS.has(binding.value.kind) + || refs.some(r => this.altSpills.includes(r)) + || i === lastAssertIdx || i === terminalIfIdx) { + this.restoreSpills(); + } + } // Propagate source location from ANF binding to StackOps this.currentSourceLoc = binding.sourceLoc; if (binding.value.kind === 'assert' && i === lastAssertIdx) { @@ -1188,10 +1469,13 @@ class LoweringContext { this.lowerIf(binding.name, binding.value.cond, binding.value.then, binding.value.else, binding.value.results ?? [], i, lastUses, true); } else { this.lowerBinding(binding, i, lastUses); + this.maybeSpill(binding, i, bindings, lastUses); } this.currentSourceLoc = undefined; } + // Nothing may outlive the scope on the alt stack. + this.restoreSpills(); } private lowerBinding( @@ -1494,6 +1778,45 @@ class LoweringContext { } } + /** + * Would materializing a commutative operator's operands right-then-left cost + * fewer bytes than left-then-right? + * + * Scored with the exact emit-time cost of each `bringToTop`, on a throwaway + * copy of the stack map so the real one is untouched. Ties keep source + * order, which is what makes `'current'` and `'liveness'` identical wherever + * this cannot help. + */ + private shouldSwapOperands( + op: string, + left: string, + right: string, + leftConsume: boolean, + rightConsume: boolean, + resultType?: string, + ): boolean { + if (this.opts.schedulerMode !== 'liveness') return false; + if (!COMMUTATIVE_BINOPS.has(op)) return false; + // `+` on ByteString operands is OP_CAT, which is not commutative. + if (resultType === 'bytes' && op === '+') return false; + if (left === right) return false; + + const cost = (first: string, firstConsume: boolean, second: string, secondConsume: boolean): number => { + const model = this.stackMap.clone(); + const ops: StackOp[] = []; + for (const [name, consume] of [[first, firstConsume], [second, secondConsume]] as const) { + const emitted = materializationOps(model, name, consume); + if (emitted === null) return Number.POSITIVE_INFINITY; // not resident + ops.push(...emitted); + } + // Score what the emitter would actually see, peephole included. + return estimateScriptBytes(optimizeStackIR(ops)); + }; + + return cost(right, rightConsume, left, leftConsume) + < cost(left, leftConsume, right, rightConsume); + } + private lowerBinOp( bindingName: string, op: string, @@ -1503,13 +1826,20 @@ class LoweringContext { lastUses: Map, resultType?: string, ): void { - // Get left operand to stack first const leftConsume = this.operandConsume(left, [left, right], bindingIndex, lastUses); - this.bringToTop(left, leftConsume); - - // Get right operand to stack const rightConsume = this.operandConsume(right, [left, right], bindingIndex, lastUses); - this.bringToTop(right, rightConsume); + + // Commutative operators may take their operands in either order, so pick + // the cheaper arrangement. The common win: the operand already on top is + // materialized SECOND for free, instead of being buried by the other one + // and then swapped back. + if (this.shouldSwapOperands(op, left, right, leftConsume, rightConsume, resultType)) { + this.bringToTop(right, rightConsume); + this.bringToTop(left, leftConsume); + } else { + this.bringToTop(left, leftConsume); + this.bringToTop(right, rightConsume); + } // Pop both operands (the opcode consumes them) this.stackMap.pop(); @@ -4901,13 +5231,14 @@ class LoweringContext { for (let i = 0; i < args.length; i++) this.stackMap.pop(); const emitFn = (op: StackOp) => this.emitOp(op); + const ecOpts = this.ecCodegenOptions(); switch (func) { - case 'ecAdd': emitEcAdd(emitFn); break; - case 'ecMul': emitEcMul(emitFn); break; - case 'ecMulGen': emitEcMulGen(emitFn); break; - case 'ecNegate': emitEcNegate(emitFn); break; - case 'ecOnCurve': emitEcOnCurve(emitFn); break; + case 'ecAdd': emitEcAdd(emitFn, ecOpts); break; + case 'ecMul': emitEcMul(emitFn, ecOpts); break; + case 'ecMulGen': emitEcMulGen(emitFn, ecOpts); break; + case 'ecNegate': emitEcNegate(emitFn, ecOpts); break; + case 'ecOnCurve': emitEcOnCurve(emitFn, ecOpts); break; case 'ecModReduce': emitEcModReduce(emitFn); break; case 'ecEncodeCompressed': emitEcEncodeCompressed(emitFn); break; case 'ecMakePoint': emitEcMakePoint(emitFn); break; @@ -4938,19 +5269,20 @@ class LoweringContext { for (let i = 0; i < args.length; i++) this.stackMap.pop(); const emitFn = (op: StackOp) => this.emitOp(op); + const ecOpts = this.ecCodegenOptions(); switch (func) { - case 'p256Add': emitP256Add(emitFn); break; - case 'p256Mul': emitP256Mul(emitFn); break; - case 'p256MulGen': emitP256MulGen(emitFn); break; - case 'p256Negate': emitP256Negate(emitFn); break; - case 'p256OnCurve': emitP256OnCurve(emitFn); break; + case 'p256Add': emitP256Add(emitFn, ecOpts); break; + case 'p256Mul': emitP256Mul(emitFn, ecOpts); break; + case 'p256MulGen': emitP256MulGen(emitFn, ecOpts); break; + case 'p256Negate': emitP256Negate(emitFn, ecOpts); break; + case 'p256OnCurve': emitP256OnCurve(emitFn, ecOpts); break; case 'p256EncodeCompressed': emitP256EncodeCompressed(emitFn); break; - case 'p384Add': emitP384Add(emitFn); break; - case 'p384Mul': emitP384Mul(emitFn); break; - case 'p384MulGen': emitP384MulGen(emitFn); break; - case 'p384Negate': emitP384Negate(emitFn); break; - case 'p384OnCurve': emitP384OnCurve(emitFn); break; + case 'p384Add': emitP384Add(emitFn, ecOpts); break; + case 'p384Mul': emitP384Mul(emitFn, ecOpts); break; + case 'p384MulGen': emitP384MulGen(emitFn, ecOpts); break; + case 'p384Negate': emitP384Negate(emitFn, ecOpts); break; + case 'p384OnCurve': emitP384OnCurve(emitFn, ecOpts); break; case 'p384EncodeCompressed': emitP384EncodeCompressed(emitFn); break; default: throw new Error(`Unknown NIST EC builtin: ${func}`); } @@ -4975,8 +5307,9 @@ class LoweringContext { this.stackMap.pop(); // sig this.stackMap.pop(); // msg const emitFn = (op: StackOp) => this.emitOp(op); - if (func === 'verifyECDSA_P256') emitVerifyECDSA_P256(emitFn); - else emitVerifyECDSA_P384(emitFn); + const ecOpts = this.ecCodegenOptions(); + if (func === 'verifyECDSA_P256') emitVerifyECDSA_P256(emitFn, ecOpts); + else emitVerifyECDSA_P384(emitFn, ecOpts); this.stackMap.push(bindingName); this.trackDepth(); } @@ -5452,7 +5785,7 @@ function hexToBytes(hex: string): Uint8Array { * named temporaries via a stack map and emits PICK/ROLL to materialise * values as needed. */ -export function lowerToStack(program: ANFProgram): StackProgram { +export function lowerToStack(program: ANFProgram, opts: LoweringOptions = {}): StackProgram { const methods: StackMethod[] = []; const privateMethods = new Map(); @@ -5466,7 +5799,7 @@ export function lowerToStack(program: ANFProgram): StackProgram { if (method.name !== 'constructor' && !method.isPublic) { continue; } - const stackMethod = lowerMethod(method, program.properties, privateMethods); + const stackMethod = lowerMethod(method, program.properties, privateMethods, opts); methods.push(stackMethod); } @@ -5562,10 +5895,43 @@ function methodUsesCodePart(bindings: ANFBinding[]): boolean { return false; } +/** + * Lower one method, and — under `schedulerMode: 'liveness'` — pick the + * cheaper of the two schedules by emitted bytes. + * + * The per-site spill heuristic in `maybeSpill` is greedy and approximate: it + * cannot know whether removing one slot actually moves an access across a + * cost boundary, because `OP_DUP`/`OP_SWAP`/`OP_OVER`/`OP_ROT` all cost one + * byte, so burying a value is free until the access reaches depth 3. Rather + * than model the whole stack evolution ahead of time, lower both ways and let + * the exact cost model decide. That makes "the scheduler never grows a method" + * a structural property rather than a hope. + * + * Sizes are compared AFTER the peephole pass, since that is what the emitter + * finally sees — spilling changes which peephole rules fire (an `OP_OVER + * OP_OVER` that fused into `OP_2DUP` may become a plain `OP_DUP`). + */ function lowerMethod( method: ANFMethod, properties: ANFProperty[], privateMethods: Map, + opts: LoweringOptions = {}, +): StackMethod { + if (opts.schedulerMode === 'liveness') { + const scheduled = lowerMethodOnce(method, properties, privateMethods, opts); + const baseline = lowerMethodOnce(method, properties, privateMethods, + { ...opts, schedulerMode: 'current' }); + const size = (m: StackMethod): number => estimateScriptBytes(optimizeStackIR(m.ops)); + return size(scheduled) < size(baseline) ? scheduled : baseline; + } + return lowerMethodOnce(method, properties, privateMethods, opts); +} + +function lowerMethodOnce( + method: ANFMethod, + properties: ANFProperty[], + privateMethods: Map, + opts: LoweringOptions = {}, ): StackMethod { const paramNames = method.params.map(p => p.name); @@ -5588,7 +5954,7 @@ function lowerMethod( paramNames.unshift('_codePart'); } - const ctx = new LoweringContext(paramNames, properties, privateMethods); + const ctx = new LoweringContext(paramNames, properties, privateMethods, opts); // Pass terminalAssert=true for public methods so the last assert leaves // its value on the stack (Bitcoin Script requires a truthy top-of-stack). ctx.lowerBindings(method.body, method.isPublic); diff --git a/packages/runar-compiler/src/passes/comb.ts b/packages/runar-compiler/src/passes/comb.ts new file mode 100644 index 000000000..d013299e2 --- /dev/null +++ b/packages/runar-compiler/src/passes/comb.ts @@ -0,0 +1,287 @@ +/** + * Fixed-base comb: compile-time table, and the soundness check that decides + * where the cheap incomplete addition may be used. + * + * The binary ladder in `p256-p384-codegen.ts` uses the cheap mixed add at every + * step but the last, justified by an interval argument over `c_i mod n`. That + * comment is emphatic that the argument must be RE-DERIVED, not assumed, by + * anything which changes the offset, the iteration count, or the reduce — and a + * comb changes all three. `combSafeRounds` below is that re-derivation, written + * as executable interval arithmetic rather than prose, so a round only gets the + * cheap add when the exception is proved unreachable. Rounds it cannot prove + * fall back to the complete add-or-double form. + * + * Nothing here emits Script. It is pure arithmetic over bigints, run once per + * compilation, and unit-tested against published curve vectors. + */ + +// --------------------------------------------------------------------------- +// Curve description +// --------------------------------------------------------------------------- + +export interface CombPoint { + x: bigint; + y: bigint; +} + +export interface CombCurve { + /** Field prime. */ + p: bigint; + /** Curve coefficient a. The NIST curves use a = -3; secp256k1 uses a = 0. */ + a: bigint; + /** Curve coefficient b. */ + b: bigint; + /** Group order. */ + n: bigint; + /** Base point. */ + g: CombPoint; +} + +/** + * Comb geometry for one window width, chosen so the top digit is never zero. + * + * The binary ladder hardcodes `k + 3n`, which puts the scalar's top bit at a + * fixed position and so keeps the accumulator off the point at infinity. A comb + * needs the same guarantee, but its first round reads bit `w*d - 1`, so the + * offset has to be chosen against `w*d` rather than assumed. `offsetMultiple` + * is the smallest `m` for which every `k + m*n` has bit `w*d - 1` set: + * + * m*n >= 2^(w*d - 1) and (m+1)*n - 1 < 2^(w*d) + * + * `m*n ≡ 0 (mod n)` so the result is unchanged. For P-256 at w=3 the search + * returns m=3, d=86 — i.e. exactly the `+3n` the binary ladder already uses. + * For P-384 at w=3 it returns m=5, d=129; assuming `+3n` there would have left + * the top digit free to be zero. + */ +export interface CombParams { + w: number; + /** Rounds, and the block width. Digit `i` reads bits `i, i+d, ..., i+(w-1)d`. */ + d: number; + /** Multiple of n added to the reduced scalar. */ + offsetMultiple: bigint; + /** Inclusive scalar domain after the offset. */ + lo: bigint; + hi: bigint; +} + +const P256_P = 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffffn; +const P256_N = 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551n; +const P256_B = 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604bn; +const P256_GX = 0x6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296n; +const P256_GY = 0x4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5n; + +const P384_P = 0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffeffffffff0000000000000000ffffffffn; +const P384_N = 0xffffffffffffffffffffffffffffffffffffffffffffffffc7634d81f4372ddf581a0db248b0a77aecec196accc52973n; +const P384_B = 0xb3312fa7e23ee7e4988e056be3f82d19181d9c6efe8141120314088f5013875ac656398d8a2ed19d2a85c8edd3ec2aefn; +const P384_GX = 0xaa87ca22be8b05378eb1c71ef320ad746e1d3b628ba79b9859f741e082542a385502f25dbf55296c3a545e3872760ab7n; +const P384_GY = 0x3617de4a96262c6f5d9e98bf9292dc29f8f41dbd289a147ce9da3113b5f0b8c00a60b1ce1d7e819d7a431d7c90ea0e5fn; + +const K1_P = 0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2fn; +const K1_N = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n; +const K1_GX = 0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798n; +const K1_GY = 0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8n; + +function bitLength(v: bigint): number { + return v === 0n ? 0 : v.toString(2).length; +} + +function makeCurve(p: bigint, b: bigint, n: bigint, gx: bigint, gy: bigint): CombCurve { + return { p, a: -3n, b, n, g: { x: gx, y: gy } }; +} + +/** + * Geometry for window width `w`, or null if no offset in the search range puts + * a guaranteed set bit at the top of the first digit. Returning null rather + * than guessing keeps the caller from silently combing a scalar whose leading + * digit can vanish. + */ +export function combParams(w: number, c: CombCurve): CombParams | null { + const base = Math.ceil(bitLength(c.n) / w); + for (let d = base; d <= base + 2; d++) { + const B = BigInt(w * d); + const top = 1n << (B - 1n); + const cap = 1n << B; + for (let m = 1n; m <= 16n; m++) { + const lo = m * c.n; + const hi = (m + 1n) * c.n - 1n; + if (lo >= top && hi < cap) { + return { w, d, offsetMultiple: m, lo, hi }; + } + } + } + return null; +} + +export const P256_COMB_CURVE: CombCurve = makeCurve(P256_P, P256_B, P256_N, P256_GX, P256_GY); +export const P384_COMB_CURVE: CombCurve = makeCurve(P384_P, P384_B, P384_N, P384_GX, P384_GY); + +/** + * secp256k1. Not built with `makeCurve`: that hardcodes the NIST `a = -3`, and + * secp256k1 is `y² = x³ + 7`. Getting `a` wrong here does not produce an + * obviously broken table — it produces a table of points on a DIFFERENT curve, + * which the on-curve check for that other curve would happily accept. Hence the + * published 2G vector pinned in `comb-table.test.ts`. + */ +export const SECP256K1_COMB_CURVE: CombCurve = { + p: K1_P, a: 0n, b: 7n, n: K1_N, g: { x: K1_GX, y: K1_GY }, +}; + +// --------------------------------------------------------------------------- +// Affine arithmetic (compile time only) +// --------------------------------------------------------------------------- + +const mod = (v: bigint, m: bigint): bigint => ((v % m) + m) % m; + +function inv(v: bigint, m: bigint): bigint { + // Extended Euclid. `v` is never 0 on the paths below. + let [old_r, r] = [mod(v, m), m]; + let [old_s, s] = [1n, 0n]; + while (r !== 0n) { + const q = old_r / r; + [old_r, r] = [r, old_r - q * r]; + [old_s, s] = [s, old_s - q * s]; + } + return mod(old_s, m); +} + +/** Affine addition. `null` is the point at infinity. */ +export function affineAddJS( + P: CombPoint | null, Q: CombPoint | null, c: CombCurve, +): CombPoint | null { + if (P === null) return Q; + if (Q === null) return P; + const { p, a } = c; + if (P.x === Q.x) { + if (mod(P.y + Q.y, p) === 0n) return null; // P == -Q + // Tangent. + const num = mod(3n * P.x * P.x + a, p); + const lam = mod(num * inv(mod(2n * P.y, p), p), p); + const x = mod(lam * lam - 2n * P.x, p); + return { x, y: mod(lam * (P.x - x) - P.y, p) }; + } + const lam = mod(mod(Q.y - P.y, p) * inv(mod(Q.x - P.x, p), p), p); + const x = mod(lam * lam - P.x - Q.x, p); + return { x, y: mod(lam * (P.x - x) - P.y, p) }; +} + +/** Double-and-add. `null` is the point at infinity. */ +export function scalarMulJS(k: bigint, P: CombPoint | null, c: CombCurve): CombPoint | null { + let r: CombPoint | null = null; + let base = P; + let e = mod(k, c.n); + while (e > 0n) { + if (e & 1n) r = affineAddJS(r, base, c); + base = affineAddJS(base, base, c); + e >>= 1n; + } + return r; +} + +// --------------------------------------------------------------------------- +// Comb table +// --------------------------------------------------------------------------- + +/** + * The multiple of G that table entry `j` represents. + * + * Comb round `i` consumes bits `{i, i+d, i+2d, ...}` of the scalar — one from + * each block — so entry `j` stands for the sum of `2^(t*d)` over the set bits + * `t` of `j`. + */ +export function combValue(j: number, d: number): bigint { + let v = 0n; + for (let t = 0; (j >> t) !== 0; t++) { + if ((j >> t) & 1) v += 1n << BigInt(t * d); + } + return v; +} + +/** `T[j] = combValue(j)·G`. Index 0 is the point at infinity and is never added. */ +export function combTable(w: number, d: number, c: CombCurve): Array { + const table: Array = []; + for (let j = 0; j < (1 << w); j++) { + table.push(j === 0 ? null : scalarMulJS(combValue(j, d), c.g, c)); + } + return table; +} + +// --------------------------------------------------------------------------- +// Soundness: where may the cheap incomplete addition be used? +// --------------------------------------------------------------------------- + +/** + * Bounds on the comb accumulator's multiplier before round `i`'s doubling. + * + * After processing rounds `d-1 .. i`, the accumulator is `c_i·G` with + * + * c_i = Σ_m 2^(m·d) · floor(K_m / 2^i) + * + * where `K_m` is the m-th `d`-bit block of the expanded scalar. Each floor + * discards less than one unit of its block, so + * + * k/2^i - Σ_m 2^(m·d) < c_i <= k/2^i + * + * and with `k` confined to `[scalarLo, scalarHi]` that gives a contiguous + * interval. The slack term is bounded by `2^(w·d)/(2^d - 1)`, far below `n`, + * which is why the interval stays narrower than the group order for all but the + * last few rounds — exactly the property the binary ladder's argument relies on. + */ +function accumulatorInterval(i: number, params: CombParams): { lo: bigint; hi: bigint } { + let slack = 0n; + for (let m = 0; m < params.w; m++) slack += 1n << BigInt(m * params.d); + const shift = BigInt(i); + const hi = params.hi >> shift; + const lo = (params.lo >> shift) - slack; + return { lo: lo < 0n ? 0n : lo, hi }; +} + +/** Does `[lo, hi]` contain any integer congruent to `target` modulo `n`? */ +function intervalHitsResidue(lo: bigint, hi: bigint, target: bigint, n: bigint): boolean { + if (hi < lo) return false; + if (hi - lo + 1n >= n) return true; // wraps a full residue class + const t = mod(target, n); + // Smallest value >= lo that is congruent to t (mod n). + const first = lo + mod(t - lo, n); + return first <= hi; +} + +/** + * Per-round verdict: may round `i` use the cheap incomplete mixed add? + * + * The exception the cheap formula cannot represent is a pre-add accumulator + * equal to the addend, its negation, or the point at infinity. After round + * `i`'s doubling the accumulator is `2·c_{i+1}·G`, and the addend is + * `combValue(j)·G` for whichever digit `j` the scalar selects — so the round is + * safe exactly when, for every `j`, + * + * 2·c_{i+1} ≢ 0, +combValue(j), -combValue(j) (mod n) + * + * over the whole interval of `c_{i+1}`. Both `G` and every table entry are + * compile-time constants and the curves have cofactor 1, so `ord(G) = n` and + * this is decidable here. Anything the checker cannot prove gets the complete + * add-or-double form instead; `true` is never assumed. + * + * Index `d-1` is `false` by construction: that round initialises the + * accumulator from the table and performs no addition at all. + */ +export function combSafeRounds(params: CombParams, c: CombCurve): boolean[] { + const { w, d } = params; + const values: bigint[] = []; + for (let j = 1; j < (1 << w); j++) values.push(combValue(j, d)); + + const safe: boolean[] = []; + for (let i = 0; i < d; i++) { + if (i === d - 1) { safe[i] = false; continue; } + const { lo, hi } = accumulatorInterval(i + 1, params); + const dLo = 2n * lo; + const dHi = 2n * hi; + let ok = !intervalHitsResidue(dLo, dHi, 0n, c.n); + for (const v of values) { + if (!ok) break; + ok = !intervalHitsResidue(dLo, dHi, v, c.n) + && !intervalHitsResidue(dLo, dHi, -v, c.n); + } + safe[i] = ok; + } + return safe; +} diff --git a/packages/runar-compiler/src/passes/ec-codegen.ts b/packages/runar-compiler/src/passes/ec-codegen.ts index e1dc017ff..b4f7b9795 100644 --- a/packages/runar-compiler/src/passes/ec-codegen.ts +++ b/packages/runar-compiler/src/passes/ec-codegen.ts @@ -10,6 +10,8 @@ */ import type { StackOp } from '../ir/index.js'; +import { sizeOfPushValue, estimateScriptBytes } from '../metrics/cost-model.js'; +import { combParams, combTable, combSafeRounds, SECP256K1_COMB_CURVE } from './comb.js'; // =========================================================================== // Constants @@ -40,13 +42,171 @@ function bigintToBytes32(n: bigint): Uint8Array { // ECTracker — named stack state tracker (mirrors SLHTracker) // =========================================================================== +/** + * Codegen options shared by every EC / NIST-curve emitter. + * + * Off by default: with no options (or `constantPool: false`) each emitter is + * byte-identical to what the seven tiers ship today, so no golden, size + * baseline, or cross-tier parity gate can move. + */ +export interface EcCodegenOptions { + /** + * Park large repeated constants (the field prime, the group order) in a + * stack slot and copy them with `OP_PICK` instead of re-pushing the literal. + * + * `fieldMod` pushes the 256-bit prime at every modular reduction — 34 bytes + * a time, 20,025 times in `p256-wallet` (71 % of that fixture). A pick from + * a slot a dozen deep costs 2. See + * `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; + + /** + * Use a fixed-base comb instead of the binary ladder wherever the base point + * is a compile-time constant (`p256MulGen`, `p384MulGen`, and the `u1·G` half + * of ECDSA verification). + * + * The window width is not fixed here: the emitter renders each candidate and + * keeps whichever the byte-cost model scores smallest. + */ + fixedBaseComb?: 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. */ +export const POOL_FIELD_P = '_pool$p'; +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; - - constructor(init: (string | null)[], emit: (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; + /** True when a compile-time-known base may use a fixed-base comb. */ + readonly comb: 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; + this.comb = opts?.fixedBaseComb === true; + } + + /** The options this tracker was built with, for handing to a nested tracker. */ + get options(): EcCodegenOptions { + return { constantPool: this.pooling, reductionSinking: this.sinking, fixedBaseComb: this.comb }; + } + + // -- 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; } @@ -58,16 +218,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; @@ -75,6 +248,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 { @@ -82,7 +258,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 }); } @@ -91,26 +269,88 @@ 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); } - toAlt(): void { this.op('OP_TOALTSTACK'); this.nm.pop(); } - fromAlt(n: string): void { this.op('OP_FROMALTSTACK'); this.nm.push(n); } + + // -- constant pool -------------------------------------------------------- + // + // A pooled constant is an ordinary tracked slot; nothing about the stack + // model changes. `pushConst` just chooses, per call site and by emitted + // bytes, between copying that slot and re-pushing the literal. Nested + // trackers built with `[...t.nm]` inherit the slot for free, so pooled + // constants work unchanged inside an `OP_IF` arm. + + /** Park `value` in `slot` for the lifetime of this emitter. No-op when pooling is off. */ + poolConstant(slot: string, value: bigint): void { + if (!this.pooling || this.nm.includes(slot)) return; + this.pushInt(slot, value); + } + + /** Remove a pooled slot. No-op when pooling is off or the slot is absent. */ + releaseConstant(slot: string): void { + if (!this.pooling || !this.nm.includes(slot)) return; + this.toTop(slot); + this.drop(); + } + + /** + * Materialize `value` on top as `name`, from the pooled `slot` when that is + * cheaper in emitted bytes than pushing the literal. + * + * The comparison is exact — `sizeOfPushValue` is the same encoder the emit + * pass uses — so pooling can never make a call site bigger. A pick at depth + * 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); + const pickCost = d <= 1 ? 1 : sizeOfPushValue(BigInt(d)) + 1; + if (pickCost < sizeOfPushValue(value)) { + this.pick(d, name); + return; + } + } + this.pushInt(name, value); + } + 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; @@ -118,24 +358,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); + } } } @@ -145,11 +392,10 @@ export class ECTracker { /** Push the field prime p onto the stack as a script number. */ function pushFieldP(t: ECTracker, name: string): void { - // Push p directly as a BigInt — the emit pass encodes it as a proper - // little-endian sign-magnitude script number push. - t.pushInt(name, FIELD_P); + t.pushConst(POOL_FIELD_P, FIELD_P, name); } + /** * Reduce a scalar to [0, n-1]: ((k mod n) + n) mod n. * @@ -165,7 +411,7 @@ function pushFieldP(t: ECTracker, name: string): void { * well defined. */ function emitScalarReduce(t: ECTracker, kName: string, resultName: string): void { - t.pushInt('_n_red', CURVE_N); + t.pushConst(POOL_GROUP_N, CURVE_N, '_n_red'); t.rawBlock([kName, '_n_red'], resultName, (e) => { e({ op: 'opcode', code: 'OP_2DUP' }); e({ op: 'opcode', code: 'OP_MOD' }); @@ -178,11 +424,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 @@ -196,40 +461,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) { @@ -239,13 +554,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); } /** @@ -304,8 +620,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 @@ -315,6 +631,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'); @@ -324,6 +644,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(); @@ -626,7 +947,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), false); + jacobianAddAffineBody(new ECTracker([...t.nm], e, t.options, [...t.dm]), false); } /** @@ -780,7 +1101,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); + 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. @@ -839,12 +1160,14 @@ function buildJacobianAddOrDoubleInline(e: (op: StackOp) => void, t: ECTracker): * Stack in: [point_a, point_b] (b on top) * Stack out: [result_point] */ -export function emitEcAdd(emit: (op: StackOp) => void): void { - const t = new ECTracker(['_pa', '_pb'], emit); +export function emitEcAdd(emit: (op: StackOp) => void, opts?: EcCodegenOptions): void { + const t = new ECTracker(['_pa', '_pb'], emit, opts); + t.poolConstant(POOL_FIELD_P, FIELD_P); decomposePoint(t, '_pa', 'px', 'py'); decomposePoint(t, '_pb', 'qx', 'qy'); affineAdd(t); composePoint(t, 'rx', 'ry', '_result'); + t.releaseConstant(POOL_FIELD_P); } /** @@ -858,8 +1181,10 @@ export function emitEcAdd(emit: (op: StackOp) => void): void { * This avoids the k+n overflow issue where bit 256 was only set for * large k, causing incorrect results for ~half of all scalar values. */ -export function emitEcMul(emit: (op: StackOp) => void): void { - const t = new ECTracker(['_pt', '_k'], emit); +export function emitEcMul(emit: (op: StackOp) => void, opts?: EcCodegenOptions): void { + const t = new ECTracker(['_pt', '_k'], emit, opts); + t.poolConstant(POOL_FIELD_P, FIELD_P); + t.poolConstant(POOL_GROUP_N, CURVE_N); decomposePoint(t, '_pt', 'ax', 'ay'); // k' = k + 3n: guarantees bit 257 is set for MSB-first double-and-add. @@ -870,15 +1195,15 @@ export function emitEcMul(emit: (op: StackOp) => void): void { // usually an unlock argument — so reduce it first. See emitScalarReduce. t.toTop('_k'); emitScalarReduce(t, '_k', '_kr'); - t.pushInt('_n', CURVE_N); + t.pushConst(POOL_GROUP_N, CURVE_N, '_n'); t.rawBlock(['_kr', '_n'], '_kn', (e) => { e({ op: 'opcode', code: 'OP_ADD' }); }); - t.pushInt('_n2', CURVE_N); + t.pushConst(POOL_GROUP_N, CURVE_N, '_n2'); t.rawBlock(['_kn', '_n2'], '_kn2', (e) => { e({ op: 'opcode', code: 'OP_ADD' }); }); - t.pushInt('_n3', CURVE_N); + t.pushConst(POOL_GROUP_N, CURVE_N, '_n3'); t.rawBlock(['_kn2', '_n3'], '_kn3', (e) => { e({ op: 'opcode', code: 'OP_ADD' }); }); @@ -917,7 +1242,7 @@ export function emitEcMul(emit: (op: StackOp) => void): void { // 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 @@ -935,6 +1260,243 @@ export function emitEcMul(emit: (op: StackOp) => void): void { t.toTop('_k'); t.drop(); composePoint(t, '_rx', '_ry', '_result'); + t.releaseConstant(POOL_GROUP_N); + t.releaseConstant(POOL_FIELD_P); +} + + +// =========================================================================== +// Fixed-base comb (secp256k1) +// =========================================================================== + +/** + * `k·G` by a Lim–Lee fixed-base comb instead of the 257-round binary ladder. + * + * The ladder doubles and conditionally adds once per SCALAR BIT. A comb splits + * the scalar into `w` blocks of `d` bits and reads one bit from each block per + * round, so it performs one doubling and one conditional add per COLUMN: the + * round count falls from `w*d` to `d` at the price of a `2^w - 1` entry table. + * G is a compile-time constant here, so the table costs nothing to build — it + * is `2·(2^w - 1)` literal pushes, resident for the whole emitter, read by + * every round with a 2-3 byte `OP_PICK`. + * + * This is the secp256k1 twin of `p256-p384-codegen.ts#cEmitCombMulGen`. The + * curve arithmetic is NOT shared: secp256k1 has `a = 0`, so `jacobianDouble` + * here computes `D = 3X²` where the NIST version computes `3(X-Z²)(X+Z²)`. + * Only `comb.ts` — the compile-time table and the interval checker — is common, + * and it takes `a` from the curve record rather than assuming it. + * + * SOUNDNESS. The cheap incomplete mixed add cannot represent a pre-add + * accumulator equal to the addend, its negation, or the point at infinity. + * `buildJacobianAddOrDoubleInline`'s comment justifies using it everywhere but + * the ladder's LAST step by an interval argument over `c_i mod n`, and insists + * that argument be re-derived by anything changing the offset or the iteration + * count. A comb changes both, so it is re-derived: `combSafeRounds` evaluates + * the same argument as executable interval arithmetic over the comb's own + * geometry, and any round it cannot prove gets the complete add-or-double form + * instead. Nothing is assumed safe. + * + * The other half of that argument is that the accumulator never starts at + * infinity, which needs the first digit non-zero. `combParams` searches for the + * scalar offset that guarantees it rather than reusing the ladder's hardcoded + * `+3n` — which happens to be right for secp256k1 at w=3 and is wrong for + * P-384. + * + * Stack in: [_k]. Stack out: [_result]. + */ +function emitCombMulGen( + emit: (op: StackOp) => void, + w: number, + opts?: EcCodegenOptions, +): boolean { + const curve = SECP256K1_COMB_CURVE; + const params = combParams(w, curve); + if (params === null) return false; + const { d, offsetMultiple } = params; + const table = combTable(w, d, curve); + const safe = combSafeRounds(params, curve); + const entries = (1 << w) - 1; + + const t = new ECTracker(['_k'], emit, opts); + t.poolConstant(POOL_FIELD_P, FIELD_P); + t.poolConstant(POOL_GROUP_N, CURVE_N); + + // k' = (k mod n) + m*n. The reduce is what confines k to [0, n-1] and so what + // makes the interval argument apply at all; see emitScalarReduce. + t.toTop('_k'); + emitScalarReduce(t, '_k', '_kr'); + t.rename('_k'); + for (let i = 0n; i < offsetMultiple; i++) { + t.pushConst(POOL_GROUP_N, CURVE_N, `_off${i}`); + t.rawBlock(['_k', `_off${i}`], '_k', (e) => { + e({ op: 'opcode', code: 'OP_ADD' }); + }); + } + t.setDomain('_k', Dom.NonNegative); + + // Table, resident for the whole comb: picking an entry costs 2-3 bytes + // against a 34-byte literal push, and every round reads all of them. + for (let j = 1; j <= entries; j++) { + const pt = table[j]!; + t.pushInt(`_Tx${j}`, pt.x); + t.pushInt(`_Ty${j}`, pt.y); + t.setDomain(`_Tx${j}`, Dom.Reduced); + t.setDomain(`_Ty${j}`, Dom.Reduced); + } + + /** + * Digit of round `i`, and the selected table entry, as `ax`/`ay`/`_flag`. + * + * Exactly one equality holds, so `Σ eq_j · T_j` is that entry's coordinate + * and every term is non-negative and below p — no reduction is needed, and + * the result is `Reduced` by construction. When the digit is zero every term + * vanishes and `_flag` is 0, so no add runs. + */ + const emitSelect = (i: number): void => { + for (let b = 0; b < w; b++) { + const shift = i + b * d; + t.copyToTop('_k', `_kc${b}`); + if (shift === 0) { + t.rename(`_sh${b}`); + } else if (shift === 1) { + t.rawBlock([`_kc${b}`], `_sh${b}`, (e) => { + e({ op: 'opcode', code: 'OP_2DIV' }); + }); + } else { + t.pushInt(`_sd${b}`, BigInt(shift)); + t.rawBlock([`_kc${b}`, `_sd${b}`], `_sh${b}`, (e) => { + e({ op: 'opcode', code: 'OP_RSHIFTNUM' }); + }); + } + t.pushInt(`_two${b}`, 2n); + t.rawBlock([`_sh${b}`, `_two${b}`], `_b${b}`, (e) => { + e({ op: 'opcode', code: 'OP_MOD' }); + }); + t.setDomain(`_b${b}`, Dom.Reduced); + } + + t.toTop('_b0'); + t.rename('_idx'); + for (let b = 1; b < w; b++) { + t.toTop(`_b${b}`); + t.pushInt(`_wt${b}`, BigInt(1 << b)); + t.rawBlock([`_b${b}`, `_wt${b}`], `_bw${b}`, (e) => { + e({ op: 'opcode', code: 'OP_MUL' }); + }); + t.toTop('_idx'); + t.rawBlock([`_bw${b}`, '_idx'], '_idx', (e) => { + e({ op: 'opcode', code: 'OP_ADD' }); + }); + } + t.setDomain('_idx', Dom.Reduced); + + for (let j = 1; j <= entries; j++) { + t.copyToTop('_idx', `_ic${j}`); + t.pushInt(`_jv${j}`, BigInt(j)); + t.rawBlock([`_ic${j}`, `_jv${j}`], `_eq${j}`, (e) => { + e({ op: 'opcode', code: 'OP_NUMEQUAL' }); + }); + t.setDomain(`_eq${j}`, Dom.Reduced); + } + + for (const coord of ['x', 'y'] as const) { + const acc = coord === 'x' ? 'ax' : 'ay'; + for (let j = 1; j <= entries; j++) { + t.copyToTop(`_eq${j}`, `_e${coord}${j}`); + t.copyToTop(`_T${coord}${j}`, `_t${coord}${j}`); + t.rawBlock([`_e${coord}${j}`, `_t${coord}${j}`], `_pr${coord}${j}`, (e) => { + e({ op: 'opcode', code: 'OP_MUL' }); + }); + if (j === 1) { + t.rename(acc); + } else { + t.toTop(acc); + t.rawBlock([`_pr${coord}${j}`, acc], acc, (e) => { + e({ op: 'opcode', code: 'OP_ADD' }); + }); + } + } + t.setDomain(acc, Dom.Reduced); + } + + for (let j = entries; j >= 1; j--) { t.toTop(`_eq${j}`); t.drop(); } + + t.toTop('_idx'); + t.rawBlock(['_idx'], '_flag', (e) => { + e({ op: 'opcode', code: 'OP_0NOTEQUAL' }); + }); + }; + + // Round d-1 initialises the accumulator. The first digit is non-zero by + // construction (combParams), so this is a real point and never infinity. + emitSelect(d - 1); + t.toTop('_flag'); t.drop(); + t.toTop('ax'); t.rename('jx'); + t.toTop('ay'); t.rename('jy'); + t.pushInt('jz', 1n); + t.setDomain('jz', Dom.Reduced); + + for (let i = d - 2; i >= 0; i--) { + jacobianDouble(t); + emitSelect(i); + + // `jacobianAddAffineBody` documents its layout as [..., ax, ay, jx, jy, jz] + // and replaces the accumulator IN PLACE at the top. The selection leaves + // ax/ay above jz, so restore the contract before the branch — otherwise the + // add arm would reorder the stack and the empty else arm would not, leaving + // the two arms with different layouts at OP_ENDIF. + t.toTop('_flag'); + t.toAlt(); + t.toTop('jx'); + t.toTop('jy'); + t.toTop('jz'); + t.fromAlt('_flag'); + + t.popTracked(); // consumed by OP_IF + const addOps: StackOp[] = []; + const addEmit = (op: StackOp) => addOps.push(op); + if (safe[i]) buildJacobianAddAffineInline(addEmit, t); + else buildJacobianAddOrDoubleInline(addEmit, t); + emit({ op: 'if', then: addOps, else: [] }); + + // The addend was selected fresh for this round; the add only copied it. + t.toTop('ay'); t.drop(); + t.toTop('ax'); t.drop(); + } + + jacobianToAffine(t, '_rx', '_ry'); + + for (let j = entries; j >= 1; j--) { + t.toTop(`_Ty${j}`); t.drop(); + t.toTop(`_Tx${j}`); t.drop(); + } + t.toTop('_k'); t.drop(); + + composePoint(t, '_rx', '_ry', '_result'); + t.releaseConstant(POOL_GROUP_N); + t.releaseConstant(POOL_FIELD_P); + return true; +} + +/** + * Emit the cheapest comb over the candidate window widths. + * + * Each candidate is rendered in full and scored with the same byte-cost model + * the emitter is measured by, and the smallest wins — the window width is not + * hardcoded. w=1 is the binary ladder and is excluded; beyond w=4 the `2^w` + * selection logic outgrows the saving. + * + * Returns null when no candidate could be built, so the caller falls back to + * the ladder rather than emitting nothing. + */ +function emitCombBest(opts?: EcCodegenOptions): StackOp[] | null { + let best: StackOp[] | null = null; + for (const w of [2, 3, 4]) { + const ops: StackOp[] = []; + if (!emitCombMulGen(op => ops.push(op), w, opts)) continue; + if (best === null || estimateScriptBytes(ops) < estimateScriptBytes(best)) best = ops; + } + return best; } /** @@ -942,14 +1504,25 @@ export function emitEcMul(emit: (op: StackOp) => void): void { * Stack in: [scalar] * Stack out: [result_point] */ -export function emitEcMulGen(emit: (op: StackOp) => void): void { +export function emitEcMulGen(emit: (op: StackOp) => void, opts?: EcCodegenOptions): void { + // G is a compile-time constant, so this is the one secp256k1 call site where + // a fixed-base comb applies. `emitEcMul` cannot use it: its base arrives at + // run time. + if (opts?.fixedBaseComb === true) { + const ops = emitCombBest(opts); + if (ops !== null) { + for (const op of ops) emit(op); + return; + } + } + // Push generator point as 64-byte blob, then delegate to ecMul const gPoint = new Uint8Array(64); gPoint.set(bigintToBytes32(GEN_X), 0); gPoint.set(bigintToBytes32(GEN_Y), 32); emit({ op: 'push', value: gPoint }); emit({ op: 'swap' }); // [point, scalar] - emitEcMul(emit); + emitEcMul(emit, opts); } /** @@ -957,12 +1530,14 @@ export function emitEcMulGen(emit: (op: StackOp) => void): void { * Stack in: [point] * Stack out: [negated_point] */ -export function emitEcNegate(emit: (op: StackOp) => void): void { - const t = new ECTracker(['_pt'], emit); +export function emitEcNegate(emit: (op: StackOp) => void, opts?: EcCodegenOptions): void { + const t = new ECTracker(['_pt'], emit, opts); + t.poolConstant(POOL_FIELD_P, FIELD_P); decomposePoint(t, '_pt', '_nx', '_ny'); pushFieldP(t, '_fp'); fieldSub(t, '_fp', '_ny', '_neg_y'); composePoint(t, '_nx', '_neg_y', '_result'); + t.releaseConstant(POOL_FIELD_P); } /** @@ -970,8 +1545,9 @@ export function emitEcNegate(emit: (op: StackOp) => void): void { * Stack in: [point] * Stack out: [boolean] */ -export function emitEcOnCurve(emit: (op: StackOp) => void): void { - const t = new ECTracker(['_pt'], emit); +export function emitEcOnCurve(emit: (op: StackOp) => void, opts?: EcCodegenOptions): void { + const t = new ECTracker(['_pt'], emit, opts); + t.poolConstant(POOL_FIELD_P, FIELD_P); decomposePoint(t, '_pt', '_x', '_y'); // GAP-301: coordinate canonicity. `decomposePoint` BIN2NUMs each coordinate @@ -1019,6 +1595,7 @@ export function emitEcOnCurve(emit: (op: StackOp) => void): void { t.rawBlock(['_canon', '_curve_eq'], '_result', (e) => { e({ op: 'opcode', code: 'OP_BOOLAND' }); }); + t.releaseConstant(POOL_FIELD_P); } /** 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 ac45060e0..d14d62c38 100644 --- a/packages/runar-compiler/src/passes/p256-p384-codegen.ts +++ b/packages/runar-compiler/src/passes/p256-p384-codegen.ts @@ -14,7 +14,14 @@ */ import type { StackOp } from '../ir/index.js'; -import { ECTracker } 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'; +import { estimateScriptBytes } from '../metrics/cost-model.js'; +import { + combParams, combTable, combSafeRounds, + P256_COMB_CURVE, P384_COMB_CURVE, + type CombCurve, +} from './comb.js'; // =========================================================================== // P-256 constants (secp256r1 / NIST P-256) @@ -129,10 +136,33 @@ const P384_PARAMS: CurveParams = { }; function pushFieldP(t: ECTracker, name: string, c: CurveParams): void { - t.pushInt(name, c.fieldP); + 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) => { @@ -145,36 +175,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) { @@ -184,12 +246,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); } /** @@ -230,7 +293,7 @@ const P256_GROUP: GroupParams = { n: P256_N, nMinus2: P256_N_MINUS_2 }; const P384_GROUP: GroupParams = { n: P384_N, nMinus2: P384_N_MINUS_2 }; function pushGroupN(t: ECTracker, name: string, g: GroupParams): void { - t.pushInt(name, g.n); + t.pushConst(POOL_GROUP_N, g.n, name); } function cGroupMod(t: ECTracker, aName: string, resultName: string, g: GroupParams): void { @@ -324,8 +387,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) => { @@ -334,6 +397,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'); @@ -343,6 +410,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(); @@ -648,7 +716,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), false, c); + jacobianAddAffineBody(new ECTracker([...t.nm], e, t.options, [...t.dm]), false, c); } /** @@ -795,7 +863,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); + 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. @@ -862,8 +930,11 @@ function cEmitMul( emit: (op: StackOp) => void, c: CurveParams, g: GroupParams, + opts?: EcCodegenOptions, ): void { - const t = new ECTracker(['_pt', '_k'], emit); + const t = new ECTracker(['_pt', '_k'], emit, opts); + t.poolConstant(POOL_FIELD_P, c.fieldP); + t.poolConstant(POOL_GROUP_N, g.n); cDecomposePoint(t, '_pt', 'ax', 'ay', c); // k' = k + 3n: guarantees a fixed high bit for MSB-first double-and-add. @@ -925,7 +996,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 @@ -943,6 +1014,242 @@ function cEmitMul( t.toTop('_k'); t.drop(); cComposePoint(t, '_rx', '_ry', '_result', c); + t.releaseConstant(POOL_GROUP_N); + t.releaseConstant(POOL_FIELD_P); +} + +// =========================================================================== +// Fixed-base comb (the base is a compile-time constant) +// =========================================================================== + +/** + * `k·G` by a Lim-Lee comb, for a base known at compile time. + * + * The binary ladder runs one doubling and one conditional add per scalar BIT. + * A comb splits the scalar into `w` blocks of `d` bits and runs one doubling + * and one conditional add per COLUMN, so the round count falls from + * `w*d` to `d` at the price of a `2^w - 1` entry table — which costs nothing to + * build here, because `G` is a constant. Measured optimum is w=3: the selection + * logic grows as `2^w` and overtakes the saving by w=5. + * + * P-256 u1·G: 90,610 B binary -> ~44,600 B comb (w=3, 86 rounds) + * + * SOUNDNESS. The cheap incomplete mixed add cannot represent a pre-add + * accumulator equal to the addend, its negation, or the point at infinity. + * `buildJacobianAddOrDoubleInline`'s comment justifies using it everywhere but + * the last step of the BINARY ladder by an interval argument over `c_i mod n`, + * and insists that argument be re-derived by anything changing the offset or + * the iteration count. A comb changes both, so it is re-derived — as executable + * interval arithmetic in `comb.ts#combSafeRounds`, evaluated here. Rounds it + * cannot prove get the complete add-or-double form instead; nothing is assumed. + * For P-256 at w=3 it proves 81 of 86 rounds, so the fallback costs ~1.2 kB. + * + * The other half of that argument is that the accumulator never starts at + * infinity, which needs the first digit to be non-zero. `combParams` searches + * for the scalar offset that guarantees it rather than reusing the ladder's + * hardcoded `+3n` — which happens to be right for P-256 at w=3 and WRONG for + * P-384 at w=3. + * + * Stack in: [_k]. Stack out: [_result]. + */ +function cEmitCombMulGen( + emit: (op: StackOp) => void, + c: CurveParams, + g: GroupParams, + curve: CombCurve, + w: number, + opts?: EcCodegenOptions, +): boolean { + const params = combParams(w, curve); + if (params === null) return false; + const { d, offsetMultiple, lo, hi } = params; + const table = combTable(w, d, curve); + const safe = combSafeRounds(params, curve); + const entries = (1 << w) - 1; + + const t = new ECTracker(['_k'], emit, opts); + t.poolConstant(POOL_FIELD_P, c.fieldP); + t.poolConstant(POOL_GROUP_N, g.n); + + // k' = (k mod n) + m*n. The reduce is what confines k to [0, n-1] and so what + // makes the interval argument apply at all; see cEmitScalarReduce. + t.toTop('_k'); + cEmitScalarReduce(t, '_k', '_kr', g); + t.rename('_k'); + for (let i = 0n; i < offsetMultiple; i++) { + t.pushConst(POOL_GROUP_N, g.n, `_off${i}`); + t.rawBlock(['_k', `_off${i}`], '_k', (e) => { + e({ op: 'opcode', code: 'OP_ADD' }); + }); + } + t.setDomain('_k', Dom.NonNegative); + + // Table, resident for the whole comb: picking an entry costs 2-3 bytes + // against a 34-byte literal push, and every round reads all of them. + for (let j = 1; j <= entries; j++) { + const pt = table[j]!; + t.pushInt(`_Tx${j}`, pt.x); + t.pushInt(`_Ty${j}`, pt.y); + t.setDomain(`_Tx${j}`, Dom.Reduced); + t.setDomain(`_Ty${j}`, Dom.Reduced); + } + + /** + * Digit of round `i`, and the selected table entry, as `ax`/`ay`/`_flag`. + * + * Exactly one equality holds, so `Σ eq_j · T_j` is that entry's coordinate + * and every term is non-negative and below p — no reduction is needed, and + * the result is `Reduced` by construction. When the digit is zero every term + * vanishes and `_flag` is 0, so no add runs. + */ + const emitSelect = (i: number): void => { + for (let b = 0; b < w; b++) { + const shift = i + b * d; + t.copyToTop('_k', `_kc${b}`); + if (shift === 0) { + t.rename(`_sh${b}`); + } else if (shift === 1) { + t.rawBlock([`_kc${b}`], `_sh${b}`, (e) => { + e({ op: 'opcode', code: 'OP_2DIV' }); + }); + } else { + t.pushInt(`_sd${b}`, BigInt(shift)); + t.rawBlock([`_kc${b}`, `_sd${b}`], `_sh${b}`, (e) => { + e({ op: 'opcode', code: 'OP_RSHIFTNUM' }); + }); + } + t.pushInt(`_two${b}`, 2n); + t.rawBlock([`_sh${b}`, `_two${b}`], `_b${b}`, (e) => { + e({ op: 'opcode', code: 'OP_MOD' }); + }); + t.setDomain(`_b${b}`, Dom.Reduced); + } + + t.toTop('_b0'); + t.rename('_idx'); + for (let b = 1; b < w; b++) { + t.toTop(`_b${b}`); + t.pushInt(`_wt${b}`, BigInt(1 << b)); + t.rawBlock([`_b${b}`, `_wt${b}`], `_bw${b}`, (e) => { + e({ op: 'opcode', code: 'OP_MUL' }); + }); + t.toTop('_idx'); + t.rawBlock([`_bw${b}`, '_idx'], '_idx', (e) => { + e({ op: 'opcode', code: 'OP_ADD' }); + }); + } + t.setDomain('_idx', Dom.Reduced); + + for (let j = 1; j <= entries; j++) { + t.copyToTop('_idx', `_ic${j}`); + t.pushInt(`_jv${j}`, BigInt(j)); + t.rawBlock([`_ic${j}`, `_jv${j}`], `_eq${j}`, (e) => { + e({ op: 'opcode', code: 'OP_NUMEQUAL' }); + }); + t.setDomain(`_eq${j}`, Dom.Reduced); + } + + for (const coord of ['x', 'y'] as const) { + const acc = coord === 'x' ? 'ax' : 'ay'; + for (let j = 1; j <= entries; j++) { + t.copyToTop(`_eq${j}`, `_e${coord}${j}`); + t.copyToTop(`_T${coord === 'x' ? 'x' : 'y'}${j}`, `_t${coord}${j}`); + t.rawBlock([`_e${coord}${j}`, `_t${coord}${j}`], `_pr${coord}${j}`, (e) => { + e({ op: 'opcode', code: 'OP_MUL' }); + }); + if (j === 1) { + t.rename(acc); + } else { + t.toTop(acc); + t.rawBlock([`_pr${coord}${j}`, acc], acc, (e) => { + e({ op: 'opcode', code: 'OP_ADD' }); + }); + } + } + t.setDomain(acc, Dom.Reduced); + } + + for (let j = entries; j >= 1; j--) { t.toTop(`_eq${j}`); t.drop(); } + + t.toTop('_idx'); + t.rawBlock(['_idx'], '_flag', (e) => { + e({ op: 'opcode', code: 'OP_0NOTEQUAL' }); + }); + }; + + // Round d-1 initialises the accumulator. The first digit is non-zero by + // construction (combParams), so this is a real point and never infinity. + emitSelect(d - 1); + t.toTop('_flag'); t.drop(); + t.toTop('ax'); t.rename('jx'); + t.toTop('ay'); t.rename('jy'); + t.pushInt('jz', 1n); + t.setDomain('jz', Dom.Reduced); + + for (let i = d - 2; i >= 0; i--) { + cJacobianDouble(t, c); + emitSelect(i); + + // `jacobianAddAffineBody` documents its layout as [..., ax, ay, jx, jy, jz] + // and replaces the accumulator IN PLACE at the top. The selection leaves + // ax/ay above jz, so restore the contract before the branch — otherwise the + // add arm would reorder the stack and the empty else arm would not, leaving + // the two arms with different layouts at OP_ENDIF. + t.toTop('_flag'); + t.toAlt(); + t.toTop('jx'); + t.toTop('jy'); + t.toTop('jz'); + t.fromAlt('_flag'); + + t.popTracked(); // consumed by OP_IF + const addOps: StackOp[] = []; + const addEmit = (op: StackOp) => addOps.push(op); + if (safe[i]) buildJacobianAddAffineInline(addEmit, t, c); + else buildJacobianAddOrDoubleInline(addEmit, t, c); + emit({ op: 'if', then: addOps, else: [] }); + + // The addend was selected fresh for this round; the add only copied it. + t.toTop('ay'); t.drop(); + t.toTop('ax'); t.drop(); + } + + cJacobianToAffine(t, '_rx', '_ry', c); + + for (let j = entries; j >= 1; j--) { + t.toTop(`_Ty${j}`); t.drop(); + t.toTop(`_Tx${j}`); t.drop(); + } + t.toTop('_k'); t.drop(); + + cComposePoint(t, '_rx', '_ry', '_result', c); + t.releaseConstant(POOL_GROUP_N); + t.releaseConstant(POOL_FIELD_P); + void lo; void hi; + return true; +} + +/** + * Emit the cheapest comb over the candidate window widths. + * + * The brief's instruction is not to hardcode a winner: each candidate is + * rendered in full and scored with the same byte-cost model the emitter is + * measured by, and the smallest wins. w=1 is the binary ladder and is excluded; + * beyond w=4 the `2^w` selection logic dominates. + * + * Returns null when no candidate could be built, so the caller falls back to + * the ladder rather than emitting nothing. + */ +function cEmitCombBest( + c: CurveParams, g: GroupParams, curve: CombCurve, opts?: EcCodegenOptions, +): StackOp[] | null { + let best: StackOp[] | null = null; + for (const w of [2, 3, 4]) { + const ops: StackOp[] = []; + if (!cEmitCombMulGen(op => ops.push(op), c, g, curve, w, opts)) continue; + if (best === null || estimateScriptBytes(ops) < estimateScriptBytes(best)) best = ops; + } + return best; } // =========================================================================== @@ -1018,8 +1325,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: @@ -1114,7 +1421,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) @@ -1128,7 +1435,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; @@ -1204,8 +1511,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); } /** @@ -1298,8 +1605,16 @@ function cEmitVerifyECDSA( sqrtExp: bigint, gx: bigint, gy: bigint, + combCurve: CombCurve, + opts?: EcCodegenOptions, ): void { - const t = new ECTracker(['_msg', '_sig', '_pk'], emit); + const t = new ECTracker(['_msg', '_sig', '_pk'], emit, opts); + // The verifier does hundreds of reductions OUTSIDE the two ladders — + // decompression's sqrt ladder, cGroupInv, cAffineAdd, the final cGroupMod. + // Each ladder pools separately: cEmitMul runs on its own tracker that + // deliberately cannot see this stack, so it cannot reach this slot. + t.poolConstant(POOL_FIELD_P, c.fieldP); + t.poolConstant(POOL_GROUP_N, g.n); // Step 0: length gate. `_sig` and `_pk` are bare ByteString in the builtin // table and the type checker imposes no width, so both arrive attacker-sized. @@ -1332,8 +1647,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'); @@ -1393,27 +1708,37 @@ function cEmitVerifyECDSA( gPointData.set(bigintToBytes(gx, c.coordBytes), 0); gPointData.set(bigintToBytes(gy, c.coordBytes), c.coordBytes); - t.pushBytes('_G', gPointData); + // u1*G. G is a compile-time constant, so this half can use a fixed-base comb + // — one doubling and one add per COLUMN instead of per bit. u2*Q below cannot: + // Q arrives in the witness. + const combOps = opts?.fixedBaseComb === true + ? cEmitCombBest(c, g, combCurve, opts) + : null; + + if (combOps === null) t.pushBytes('_G', gPointData); t.toTop('_u1'); - // Stash items we need later on altstack so cEmitMul sees only [_G, _u1]. - // _input_ok goes DEEPEST — the altstack is LIFO and it is popped last. + // Stash items we need later on altstack so the multiply sees only its own + // operands. _input_ok goes DEEPEST — the altstack is LIFO and it is popped last. t.toTop('_input_ok'); t.toAlt(); t.toTop('_r_save'); t.toAlt(); t.toTop('_u2'); t.toAlt(); t.toTop('_qy'); t.toAlt(); t.toTop('_qx'); t.toAlt(); - // 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 + // The multiply creates its own ECTracker and cannot see items below its + // operands. Remove them from ours. + t.popTracked(); // _u1 + if (combOps === null) t.popTracked(); // _G - // Emit the mul (it manages its own tracker internally) - cEmitMul(emit, c, g); + if (combOps !== null) { + for (const op of combOps) emit(op); + } else { + 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) @@ -1431,10 +1756,10 @@ function cEmitVerifyECDSA( // Stack: [..., _Q_point, _u2] // Pop from tracker, emit mul, push result - t.nm.pop(); // _u2 - t.nm.pop(); // _Q_point - cEmitMul(emit, c, g); - t.nm.push('_R2_point'); + t.popTracked(); // _u2 + t.popTracked(); // _Q_point + cEmitMul(emit, c, g, opts); + t.pushTracked('_R2_point'); // Restore R1 point t.fromAlt('_R1_point'); @@ -1487,6 +1812,8 @@ function cEmitVerifyECDSA( t.rawBlock(['_input_ok', '_sig_ok'], '_result', (e) => { e({ op: 'opcode', code: 'OP_BOOLAND' }); }); + t.releaseConstant(POOL_GROUP_N); + t.releaseConstant(POOL_FIELD_P); } // =========================================================================== @@ -1498,12 +1825,14 @@ function cEmitVerifyECDSA( * Stack in: [P256Point, P256Point] (second on top) * Stack out: [P256Point] */ -export function emitP256Add(emit: (op: StackOp) => void): void { - const t = new ECTracker(['_pa', '_pb'], emit); +export function emitP256Add(emit: (op: StackOp) => void, opts?: EcCodegenOptions): void { + const t = new ECTracker(['_pa', '_pb'], emit, opts); + t.poolConstant(POOL_FIELD_P, P256_PARAMS.fieldP); cDecomposePoint(t, '_pa', 'px', 'py', P256_PARAMS); cDecomposePoint(t, '_pb', 'qx', 'qy', P256_PARAMS); cAffineAdd(t, P256_PARAMS); cComposePoint(t, 'rx', 'ry', '_result', P256_PARAMS); + t.releaseConstant(POOL_FIELD_P); } /** @@ -1511,8 +1840,8 @@ export function emitP256Add(emit: (op: StackOp) => void): void { * Stack in: [P256Point, bigint] (scalar on top) * Stack out: [P256Point] */ -export function emitP256Mul(emit: (op: StackOp) => void): void { - cEmitMul(emit, P256_PARAMS, P256_GROUP); +export function emitP256Mul(emit: (op: StackOp) => void, opts?: EcCodegenOptions): void { + cEmitMul(emit, P256_PARAMS, P256_GROUP, opts); } /** @@ -1520,13 +1849,17 @@ export function emitP256Mul(emit: (op: StackOp) => void): void { * Stack in: [bigint] * Stack out: [P256Point] */ -export function emitP256MulGen(emit: (op: StackOp) => void): void { +export function emitP256MulGen(emit: (op: StackOp) => void, opts?: EcCodegenOptions): void { + if (opts?.fixedBaseComb === true) { + const ops = cEmitCombBest(P256_PARAMS, P256_GROUP, P256_COMB_CURVE, opts); + if (ops !== null) { for (const op of ops) emit(op); return; } + } const gPoint = new Uint8Array(64); gPoint.set(bigintToBytes(P256_GX, 32), 0); gPoint.set(bigintToBytes(P256_GY, 32), 32); emit({ op: 'push', value: gPoint }); emit({ op: 'swap' }); // [point, scalar] - emitP256Mul(emit); + emitP256Mul(emit, opts); } /** @@ -1534,12 +1867,14 @@ export function emitP256MulGen(emit: (op: StackOp) => void): void { * Stack in: [P256Point] * Stack out: [P256Point] */ -export function emitP256Negate(emit: (op: StackOp) => void): void { - const t = new ECTracker(['_pt'], emit); +export function emitP256Negate(emit: (op: StackOp) => void, opts?: EcCodegenOptions): void { + const t = new ECTracker(['_pt'], emit, opts); + t.poolConstant(POOL_FIELD_P, P256_PARAMS.fieldP); cDecomposePoint(t, '_pt', '_nx', '_ny', P256_PARAMS); pushFieldP(t, '_fp', P256_PARAMS); cFieldSub(t, '_fp', '_ny', '_neg_y', P256_PARAMS); cComposePoint(t, '_nx', '_neg_y', '_result', P256_PARAMS); + t.releaseConstant(POOL_FIELD_P); } /** @@ -1547,8 +1882,9 @@ export function emitP256Negate(emit: (op: StackOp) => void): void { * Stack in: [P256Point] * Stack out: [boolean] */ -export function emitP256OnCurve(emit: (op: StackOp) => void): void { - const t = new ECTracker(['_pt'], emit); +export function emitP256OnCurve(emit: (op: StackOp) => void, opts?: EcCodegenOptions): void { + const t = new ECTracker(['_pt'], emit, opts); + t.poolConstant(POOL_FIELD_P, P256_PARAMS.fieldP); cDecomposePoint(t, '_pt', '_x', '_y', P256_PARAMS); cEmitCanonicityGuard(t, '_x', '_y', P256_PARAMS); @@ -1578,6 +1914,7 @@ export function emitP256OnCurve(emit: (op: StackOp) => void): void { t.rawBlock(['_canon', '_curve_eq'], '_result', (e) => { e({ op: 'opcode', code: 'OP_BOOLAND' }); }); + t.releaseConstant(POOL_FIELD_P); } /** @@ -1616,8 +1953,8 @@ export function emitP256EncodeCompressed(emit: (op: StackOp) => void): void { * Stack in: [msg_bytes, sig(64B), pubkey(33B)] (pubkey on top) * Stack out: [boolean] */ -export function emitVerifyECDSA_P256(emit: (op: StackOp) => void): void { - cEmitVerifyECDSA(emit, P256_PARAMS, P256_GROUP, P256_B, P256_SQRT_EXP, P256_GX, P256_GY); +export function emitVerifyECDSA_P256(emit: (op: StackOp) => void, opts?: EcCodegenOptions): void { + cEmitVerifyECDSA(emit, P256_PARAMS, P256_GROUP, P256_B, P256_SQRT_EXP, P256_GX, P256_GY, P256_COMB_CURVE, opts); } // =========================================================================== @@ -1629,12 +1966,14 @@ export function emitVerifyECDSA_P256(emit: (op: StackOp) => void): void { * Stack in: [P384Point, P384Point] (second on top) * Stack out: [P384Point] */ -export function emitP384Add(emit: (op: StackOp) => void): void { - const t = new ECTracker(['_pa', '_pb'], emit); +export function emitP384Add(emit: (op: StackOp) => void, opts?: EcCodegenOptions): void { + const t = new ECTracker(['_pa', '_pb'], emit, opts); + t.poolConstant(POOL_FIELD_P, P384_PARAMS.fieldP); cDecomposePoint(t, '_pa', 'px', 'py', P384_PARAMS); cDecomposePoint(t, '_pb', 'qx', 'qy', P384_PARAMS); cAffineAdd(t, P384_PARAMS); cComposePoint(t, 'rx', 'ry', '_result', P384_PARAMS); + t.releaseConstant(POOL_FIELD_P); } /** @@ -1642,8 +1981,8 @@ export function emitP384Add(emit: (op: StackOp) => void): void { * Stack in: [P384Point, bigint] (scalar on top) * Stack out: [P384Point] */ -export function emitP384Mul(emit: (op: StackOp) => void): void { - cEmitMul(emit, P384_PARAMS, P384_GROUP); +export function emitP384Mul(emit: (op: StackOp) => void, opts?: EcCodegenOptions): void { + cEmitMul(emit, P384_PARAMS, P384_GROUP, opts); } /** @@ -1651,13 +1990,17 @@ export function emitP384Mul(emit: (op: StackOp) => void): void { * Stack in: [bigint] * Stack out: [P384Point] */ -export function emitP384MulGen(emit: (op: StackOp) => void): void { +export function emitP384MulGen(emit: (op: StackOp) => void, opts?: EcCodegenOptions): void { + if (opts?.fixedBaseComb === true) { + const ops = cEmitCombBest(P384_PARAMS, P384_GROUP, P384_COMB_CURVE, opts); + if (ops !== null) { for (const op of ops) emit(op); return; } + } const gPoint = new Uint8Array(96); gPoint.set(bigintToBytes(P384_GX, 48), 0); gPoint.set(bigintToBytes(P384_GY, 48), 48); emit({ op: 'push', value: gPoint }); emit({ op: 'swap' }); // [point, scalar] - emitP384Mul(emit); + emitP384Mul(emit, opts); } /** @@ -1665,12 +2008,14 @@ export function emitP384MulGen(emit: (op: StackOp) => void): void { * Stack in: [P384Point] * Stack out: [P384Point] */ -export function emitP384Negate(emit: (op: StackOp) => void): void { - const t = new ECTracker(['_pt'], emit); +export function emitP384Negate(emit: (op: StackOp) => void, opts?: EcCodegenOptions): void { + const t = new ECTracker(['_pt'], emit, opts); + t.poolConstant(POOL_FIELD_P, P384_PARAMS.fieldP); cDecomposePoint(t, '_pt', '_nx', '_ny', P384_PARAMS); pushFieldP(t, '_fp', P384_PARAMS); cFieldSub(t, '_fp', '_ny', '_neg_y', P384_PARAMS); cComposePoint(t, '_nx', '_neg_y', '_result', P384_PARAMS); + t.releaseConstant(POOL_FIELD_P); } /** @@ -1678,8 +2023,9 @@ export function emitP384Negate(emit: (op: StackOp) => void): void { * Stack in: [P384Point] * Stack out: [boolean] */ -export function emitP384OnCurve(emit: (op: StackOp) => void): void { - const t = new ECTracker(['_pt'], emit); +export function emitP384OnCurve(emit: (op: StackOp) => void, opts?: EcCodegenOptions): void { + const t = new ECTracker(['_pt'], emit, opts); + t.poolConstant(POOL_FIELD_P, P384_PARAMS.fieldP); cDecomposePoint(t, '_pt', '_x', '_y', P384_PARAMS); cEmitCanonicityGuard(t, '_x', '_y', P384_PARAMS); @@ -1709,6 +2055,7 @@ export function emitP384OnCurve(emit: (op: StackOp) => void): void { t.rawBlock(['_canon', '_curve_eq'], '_result', (e) => { e({ op: 'opcode', code: 'OP_BOOLAND' }); }); + t.releaseConstant(POOL_FIELD_P); } /** @@ -1747,6 +2094,6 @@ export function emitP384EncodeCompressed(emit: (op: StackOp) => void): void { * Stack in: [msg_bytes, sig(96B), pubkey(49B)] (pubkey on top) * Stack out: [boolean] */ -export function emitVerifyECDSA_P384(emit: (op: StackOp) => void): void { - cEmitVerifyECDSA(emit, P384_PARAMS, P384_GROUP, P384_B, P384_SQRT_EXP, P384_GX, P384_GY); +export function emitVerifyECDSA_P384(emit: (op: StackOp) => void, opts?: EcCodegenOptions): void { + cEmitVerifyECDSA(emit, P384_PARAMS, P384_GROUP, P384_B, P384_SQRT_EXP, P384_GX, P384_GY, P384_COMB_CURVE, opts); } diff --git a/packages/runar-testing/src/__tests__/ec-comb.test.ts b/packages/runar-testing/src/__tests__/ec-comb.test.ts new file mode 100644 index 000000000..0df4f9574 --- /dev/null +++ b/packages/runar-testing/src/__tests__/ec-comb.test.ts @@ -0,0 +1,215 @@ +/** + * Fixed-base comb — differential against the binary ladder, on the real engine. + * + * The comb changes the scalar recoding, the round count, the accumulator's + * initial value and which addition formula each round uses. Its soundness rests + * on `comb.ts#combSafeRounds`, an interval argument re-derived for the comb + * because the ladder's own version does not transfer. That is exactly the kind + * of argument that can be subtly wrong while every ordinary input still works, + * so the gate here is: for the same scalar, the comb and the ladder must return + * the SAME POINT — over the scalars that sit on every boundary the argument + * turns on. + */ + +import { describe, it, expect } from 'vitest'; +import { createSign, generateKeyPairSync } from 'node:crypto'; +import { + emitMethod, emitP256MulGen, emitP384MulGen, emitP256Mul, emitVerifyECDSA_P256, + emitEcMulGen, emitEcMul, +} from 'runar-compiler'; +import type { StackOp } from 'runar-ir-schema'; +import { ScriptVM } from '../index.js'; + +type Opts = { constantPool?: boolean; reductionSinking?: boolean; fixedBaseComb?: boolean }; + +const LADDER: Opts = { constantPool: true, reductionSinking: true }; +const COMB: Opts = { constantPool: true, reductionSinking: true, fixedBaseComb: true }; + +const P256_N = 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551n; +const P384_N = 0xffffffffffffffffffffffffffffffffffffffffffffffffc7634d81f4372ddf581a0db248b0a77aecec196accc52973n; +const P256_G = '6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296' + + '4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5'; +const K1_N = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n; +const K1_G = '79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798' + + '483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8'; + +function run( + emitter: (e: (o: StackOp) => void, o?: Opts) => void, + inputs: StackOp[], opts: Opts, +): string[] { + const ops = [...inputs]; + 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 }; + return r.error ? [`ERR:${r.error}`] : r.stack.map(b => Buffer.from(b).toString('hex')); +} + +const num = (v: bigint): StackOp => ({ op: 'push', value: v } as StackOp); +const bytes = (h: string): StackOp => ({ op: 'push', value: Uint8Array.from(Buffer.from(h, 'hex')) } as StackOp); + +describe('P-256 comb agrees with the binary ladder', () => { + /** + * Every boundary the interval argument turns on: the ends of the reduced + * domain, the values that make the accumulator hit a table entry early, the + * scalars whose leading comb digits are minimal, and the out-of-range inputs + * the reduce is there to fold back in. + */ + const SCALARS = [ + 0n, 1n, 2n, 3n, 4n, 5n, 6n, 7n, 8n, 15n, 16n, 17n, + P256_N - 2n, P256_N - 1n, P256_N, P256_N + 1n, 2n * P256_N, + -1n, -2n, -P256_N, + (1n << 85n), (1n << 86n), (1n << 86n) - 1n, + (1n << 171n), (1n << 172n), (1n << 255n), (1n << 256n) - 1n, + 0x2n ** 128n + 12345n, + 0xdeadbeefcafebaben, + ]; + + it.each(SCALARS)('G * %s', (k) => { + const comb = run(emitP256MulGen, [num(k)], COMB); + const ladder = run(emitP256MulGen, [num(k)], LADDER); + expect(comb).toEqual(ladder); + }); + + it('agrees with the generic ladder driven by an explicit G, too', () => { + // emitP256MulGen and emitP256Mul share a code path today; pin that the comb + // matches the INDEPENDENT generic-point ladder as well, so a shared bug in + // the MulGen wrapper cannot hide. + for (const k of [1n, 2n, 7n, P256_N - 1n, 0n]) { + const comb = run(emitP256MulGen, [num(k)], COMB); + const generic = run(emitP256Mul, [bytes(P256_G), num(k)], LADDER); + expect(comb, `k=${k}`).toEqual(generic); + } + }); +}); + +describe('secp256k1 comb agrees with the binary ladder', () => { + /** + * secp256k1 is the curve every `ecMulGen` contract in the corpus actually + * uses, and it is the one curve where the comb's doubling formula differs: + * a = 0, so `jacobianDouble` computes D = 3X² rather than the NIST + * 3(X-Z²)(X+Z²). Same boundary set as P-256 — the reduced domain's ends, the + * scalars whose leading comb digits are minimal, and the out-of-range inputs + * `emitScalarReduce` exists to fold back in. + */ + const SCALARS = [ + 0n, 1n, 2n, 3n, 4n, 5n, 6n, 7n, 8n, 15n, 16n, 17n, + K1_N - 2n, K1_N - 1n, K1_N, K1_N + 1n, 2n * K1_N, + -1n, -2n, -K1_N, + (1n << 85n), (1n << 86n), (1n << 86n) - 1n, + (1n << 171n), (1n << 172n), (1n << 255n), (1n << 256n) - 1n, + 0x2n ** 128n + 12345n, + 0xdeadbeefcafebaben, + ]; + + it.each(SCALARS)('G * %s', (k) => { + expect(run(emitEcMulGen, [num(k)], COMB)).toEqual(run(emitEcMulGen, [num(k)], LADDER)); + }); + + it('agrees with the generic ladder driven by an explicit G, too', () => { + // `emitEcMulGen` delegates to `emitEcMul` on the ladder path, so a shared + // bug in the wrapper would cancel out above. Pin against the generic + // runtime-point ladder as well. + for (const k of [1n, 2n, 7n, K1_N - 1n, 0n]) { + expect(run(emitEcMulGen, [num(k)], COMB), `k=${k}`) + .toEqual(run(emitEcMul, [bytes(K1_G), num(k)], LADDER)); + } + }); + + it('reproduces the published k·G vectors', () => { + // Absolute, not differential: both sides above could be wrong together. + const VECTORS: Array<[bigint, string]> = [ + [1n, K1_G], + [2n, 'c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5' + + '1ae168fea63dc339a3c58419466ceaeef7f632653266d0e1236431a950cfe52a'], + [3n, 'f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9' + + '388f7b0f632de8140fe337e62a37f3566500a99934c2231b6cb9fd7584b8e672'], + [7n, '5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc' + + '6aebca40ba255960a3178d6d861a54dba813d0b813fde7b5a5082628087264da'], + ]; + for (const [k, want] of VECTORS) { + expect(run(emitEcMulGen, [num(k)], COMB), `k=${k}`).toEqual([want]); + } + }); +}); + +describe('P-384 comb agrees with the binary ladder', () => { + // P-384 at w=3 needs a different scalar offset than P-256; if combParams got + // that wrong the leading digit could be zero and the accumulator would start + // at infinity. These are the cases that would show it. + const SCALARS = [0n, 1n, 2n, 3n, 7n, 8n, P384_N - 1n, P384_N, (1n << 128n), (1n << 383n)]; + + it.each(SCALARS)('G * %s', (k) => { + expect(run(emitP384MulGen, [num(k)], COMB)).toEqual(run(emitP384MulGen, [num(k)], LADDER)); + }); +}); + +describe('verifyECDSA_P256 with the comb for u1*G', () => { + // The verifier is the reason the comb exists. Q arrives in the witness so its + // half stays a ladder; only u1*G changes. Differential against the all-ladder + // build, then an absolute OpenSSL oracle. + const hx = (v: bigint) => v.toString(16).padStart(64, '0'); + const P256_P = 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffffn; + const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }); + const der = publicKey.export({ format: 'der', type: 'spki' }) as Buffer; + const un = der.subarray(der.length - 65).toString('hex'); + const qy = BigInt('0x' + un.slice(66)); + const compressed = ((qy & 1n) === 0n ? '02' : '03') + un.slice(2, 66); + const msgHex = '52c3ad6172206d657373616765'; + const signer = createSign('sha256'); + signer.update(Buffer.from(msgHex, 'hex')); + const d = signer.sign(privateKey) as Buffer; + let i = 0; + if (d[i++] !== 0x30) throw new Error('not DER'); + if (d[i]! & 0x80) i += 1 + (d[i]! & 0x7f); else i += 1; + const rd = (): bigint => { + if (d[i++] !== 0x02) throw new Error('not int'); + const len = d[i++]!; + const v = BigInt('0x' + d.subarray(i, i + len).toString('hex')); + i += len; + return v; + }; + const sigHex = hx(rd()) + hx(rd()); + + const verdict = (msg: string, sig: string, pk: string, o: Opts): boolean => { + const st = run(emitVerifyECDSA_P256, [bytes(msg), bytes(sig), bytes(pk)], o); + expect(st.length, `expected one boolean, got ${st.join(',')}`).toBe(1); + return st[0] !== '' && st[0] !== '00'; + }; + + const CASES: Array<[string, string, string, string, boolean]> = [ + ['genuine signature', msgHex, sigHex, compressed, true], + ['wrong message', msgHex + '00', sigHex, compressed, false], + ['flipped parity', msgHex, sigHex, + (compressed.slice(0, 2) === '02' ? '03' : '02') + compressed.slice(2), false], + ['all-zero signature', msgHex, '0'.repeat(128), compressed, false], + ['r = 0', msgHex, '0'.repeat(64) + sigHex.slice(64), compressed, false], + ['s = 0', msgHex, sigHex.slice(0, 64) + '0'.repeat(64), compressed, false], + ['non-canonical pubkey x', msgHex, sigHex, '02' + hx(P256_P + 1n), false], + ['truncated signature', msgHex, sigHex.slice(0, 64), compressed, false], + ]; + + it.each(CASES)('%s — comb matches ladder and the oracle', (_l, msg, sig, pk, want) => { + expect(verdict(msg, sig, pk, COMB)).toBe(verdict(msg, sig, pk, LADDER)); + expect(verdict(msg, sig, pk, COMB)).toBe(want); + }); +}); + +describe('the comb is actually smaller', () => { + it.each([ + ['emitP256MulGen', emitP256MulGen], + ['emitP384MulGen', emitP384MulGen], + ['emitVerifyECDSA_P256', emitVerifyECDSA_P256], + ['emitEcMulGen', emitEcMulGen], + ] as Array<[string, (e: (o: StackOp) => void, o?: Opts) => void]>)('%s', (name, e) => { + const size = (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; + }; + const ladder = size(LADDER); + const comb = size(COMB); + // eslint-disable-next-line no-console + console.log(` ${name}: ladder ${ladder} -> comb ${comb} (${(((comb - ladder) / ladder) * 100).toFixed(1)}%)`); + expect(comb).toBeLessThan(ladder); + }); +}); diff --git a/packages/runar-testing/src/__tests__/ec-constant-pool-equivalence.test.ts b/packages/runar-testing/src/__tests__/ec-constant-pool-equivalence.test.ts new file mode 100644 index 000000000..7ab6abad3 --- /dev/null +++ b/packages/runar-testing/src/__tests__/ec-constant-pool-equivalence.test.ts @@ -0,0 +1,251 @@ +/** + * EC constant pooling — semantic equivalence on the real interpreter. + * + * Pooling replaces ~20,000 inline pushes of a curve's field prime with picks + * from one resident stack slot (see + * `docs/experiments/script-size-optimization-baseline.md`). It changes stack + * layout inside every EC emitter, including inside `OP_IF` arms, so "the byte + * count went down" is not evidence of anything on its own. + * + * Two kinds of proof here, both through @bsv/sdk's `Spend`: + * + * 1. DIFFERENTIAL — for the same inputs, the pooled and unpooled scripts leave + * an identical stack. No oracle needed and no fixture to get wrong: the + * unpooled emitter is the specification. + * 2. ORACLE — `verifyECDSA_*` accepts a genuine OpenSSL signature and rejects + * every near-miss, under BOTH variants. This is the one that would catch a + * pooled slot being read where a *different* value was intended, which a + * pure differential over random inputs can miss if both variants are wrong + * in the same way (they cannot be here — only one of them was changed — + * but the reject cases also pin the security-relevant behaviour). + * + * Max stack depth is measured, not assumed: pooling adds resident slots, and + * the interpreter's 1,000-element budget is the real limit. + */ + +import { describe, it, expect } from 'vitest'; +import { createSign, generateKeyPairSync } from 'node:crypto'; +import { + emitMethod, + emitVerifyECDSA_P256, emitVerifyECDSA_P384, + emitP256Add, emitP256Mul, emitP256Negate, emitP256OnCurve, + emitP384Add, emitP384Negate, emitP384OnCurve, + emitEcAdd, emitEcMul, emitEcNegate, emitEcOnCurve, +} from 'runar-compiler'; +import type { StackOp } from 'runar-ir-schema'; +import { ScriptVM } from '../index.js'; + +type Emitter = (emit: (op: StackOp) => void, opts?: { constantPool?: boolean }) => void; + +const blob = (hex: string) => Uint8Array.from(Buffer.from(hex, 'hex')); + +interface RunResult { + stack: string[]; + error: string | null; + maxStackDepth: number; +} + +/** Emit `inputs` then the emitter's body, and execute the whole thing. */ +function run(emitter: Emitter, inputs: StackOp[], pooled: boolean): RunResult { + const ops: StackOp[] = [...inputs]; + emitter(op => ops.push(op), pooled ? { constantPool: true } : undefined); + const { scriptHex } = emitMethod({ name: 't', ops } as never) as { scriptHex: string }; + const r = new ScriptVM().executeHex(scriptHex) as never as { + stack: Uint8Array[]; error?: string; maxStackDepth: number; + }; + return { + stack: r.stack.map(b => Buffer.from(b).toString('hex')), + error: r.error ?? null, + maxStackDepth: r.maxStackDepth, + }; +} + +/** Assert both variants agree completely, and report the depth cost. */ +function expectSame(emitter: Emitter, inputs: StackOp[]): { off: RunResult; on: RunResult } { + const off = run(emitter, inputs, false); + const on = run(emitter, inputs, true); + expect(on.error).toBe(off.error); + expect(on.stack).toEqual(off.stack); + return { off, on }; +} + +const push = (hex: string): StackOp => ({ op: 'push', value: blob(hex) } as StackOp); +const pushN = (n: bigint): StackOp => ({ op: 'push', value: n } as StackOp); + +// --------------------------------------------------------------------------- +// Curve fixtures +// --------------------------------------------------------------------------- + +const P256 = { + p: 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffffn, + n: 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551n, + gx: 0x6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296n, + gy: 0x4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5n, + bytes: 32, +}; +const SECP = { + p: 0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2fn, + n: 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n, + gx: 0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798n, + gy: 0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8n, + bytes: 32, +}; +const P384 = { + gx: 0xaa87ca22be8b05378eb1c71ef320ad746e1d3b628ba79b9859f741e082542a385502f25dbf55296c3a545e3872760ab7n, + gy: 0x3617de4a96262c6f5d9e98bf9292dc29f8f41dbd289a147ce9da3113b5f0b8c00a60b1ce1d7e819d7a431d7c90ea0e5fn, + bytes: 48, +}; + +const hx = (v: bigint, bytes: number) => v.toString(16).padStart(bytes * 2, '0'); +const point = (x: bigint, y: bigint, bytes: number) => hx(x, bytes) + hx(y, bytes); + +// --------------------------------------------------------------------------- +// 1. Differential — the unpooled emitter is the specification +// --------------------------------------------------------------------------- + +describe('pooled and unpooled emitters agree (differential)', () => { + const G256 = point(P256.gx, P256.gy, 32); + const GSEC = point(SECP.gx, SECP.gy, 32); + const G384 = point(P384.gx, P384.gy, 48); + + it('p256Add: G + G (the doubling path)', () => { + expectSame(emitP256Add, [push(G256), push(G256)]); + }); + + it('p256Negate: -G', () => { + expectSame(emitP256Negate, [push(G256)]); + }); + + it('p256OnCurve: accepts G', () => { + const { off } = expectSame(emitP256OnCurve, [push(G256)]); + expect(off.stack).toEqual(['01']); + }); + + it('p256OnCurve: rejects a point off the curve', () => { + const { off } = expectSame(emitP256OnCurve, [push(point(P256.gx, P256.gy + 1n, 32))]); + expect(off.stack).toEqual(['']); + }); + + it('p256OnCurve: rejects a non-canonical x >= p', () => { + // The pooled prime is what the canonicity guard compares against, so this + // is the case that would break first if the pool ever served a stale slot. + expectSame(emitP256OnCurve, [push(point(P256.gx + P256.p, P256.gy, 32))]); + }); + + it.each([1n, 2n, 3n, 7n, P256.n - 1n, 0n, P256.n])('p256Mul: G * %s', (k) => { + expectSame(emitP256Mul, [push(G256), pushN(k)]); + }); + + it('p384Add: G + G', () => { + expectSame(emitP384Add, [push(G384), push(G384)]); + }); + + it('p384Negate / p384OnCurve on G', () => { + expectSame(emitP384Negate, [push(G384)]); + expectSame(emitP384OnCurve, [push(G384)]); + }); + + it('ecAdd: G + G (secp256k1)', () => { + expectSame(emitEcAdd, [push(GSEC), push(GSEC)]); + }); + + it('ecNegate / ecOnCurve on G (secp256k1)', () => { + expectSame(emitEcNegate, [push(GSEC)]); + expectSame(emitEcOnCurve, [push(GSEC)]); + }); + + it.each([1n, 2n, 5n, SECP.n - 1n, 0n])('ecMul: G * %s (secp256k1)', (k) => { + expectSame(emitEcMul, [push(GSEC), pushN(k)]); + }); + + it('agrees on garbage inputs too — both must fail the same way', () => { + // Totality matters: these builtins are specified as "consume N, push 1" + // for ANY argument bytes, so a divergence in the ERROR is as bad as a + // divergence in the result. + expectSame(emitP256OnCurve, [push('00'.repeat(64))]); + expectSame(emitP256Add, [push('ff'.repeat(64)), push('00'.repeat(64))]); + }); +}); + +// --------------------------------------------------------------------------- +// 2. Oracle — OpenSSL signatures, both variants +// --------------------------------------------------------------------------- + +/** DER SEQUENCE { INTEGER r, INTEGER s } -> fixed-width r||s. */ +function derToRaw(der: Buffer, bytes: number): string { + let i = 0; + if (der[i++] !== 0x30) throw new Error('not a DER sequence'); + if (der[i]! & 0x80) i += 1 + (der[i]! & 0x7f); else i += 1; + const readInt = (): bigint => { + if (der[i++] !== 0x02) throw new Error('not a DER integer'); + const len = der[i++]!; + const v = BigInt('0x' + der.subarray(i, i + len).toString('hex')); + i += len; + return v; + }; + const r = readInt(); + const s = readInt(); + const w = bytes * 2; + return r.toString(16).padStart(w, '0') + s.toString(16).padStart(w, '0'); +} + +const CURVES = [ + { name: 'p256', node: 'prime256v1' as const, bytes: 32, emit: emitVerifyECDSA_P256, n: P256.n }, + { name: 'p384', node: 'secp384r1' as const, bytes: 48, emit: emitVerifyECDSA_P384, + n: 0xffffffffffffffffffffffffffffffffffffffffffffffffc7634d81f4372ddf581a0db248b0a77aecec196accc52973n }, +]; + +for (const c of CURVES) { + describe(`${c.name} verifyECDSA agrees under pooling (OpenSSL oracle)`, () => { + const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: c.node }); + const pub = publicKey.export({ format: 'der', type: 'spki' }) as Buffer; + const uncompressed = pub.subarray(pub.length - (1 + c.bytes * 2)).toString('hex'); + const w = c.bytes * 2; + const qx = BigInt('0x' + uncompressed.slice(2, 2 + w)); + const qy = BigInt('0x' + uncompressed.slice(2 + w)); + const compressed = ((qy & 1n) === 0n ? '02' : '03') + hx(qx, c.bytes); + + const msgHex = '52c3ad6172206d657373616765'; // "Rúnar message" + const signer = createSign('sha256'); + signer.update(Buffer.from(msgHex, 'hex')); + const sigHex = derToRaw(signer.sign(privateKey) as Buffer, c.bytes); + + const verify = (msg: string, sig: string, pk: string, pooled: boolean): boolean => { + const r = run(c.emit, [push(msg), push(sig), push(pk)], pooled); + expect(r.error, 'verifier aborted instead of returning a boolean').toBe(null); + expect(r.stack.length, 'specified as 3 args in, 1 boolean out').toBe(1); + return r.stack[0] !== '' && r.stack[0] !== '00'; + }; + + const zero = '0'.repeat(w); + const rGen = sigHex.slice(0, w); + const sGen = sigHex.slice(w); + const flipped = (compressed.slice(0, 2) === '02' ? '03' : '02') + compressed.slice(2); + + const CASES: Array<[string, string, string, string, boolean]> = [ + ['genuine signature', msgHex, sigHex, compressed, true], + ['wrong message', msgHex + '00', sigHex, compressed, false], + ['wrong pubkey parity', msgHex, sigHex, flipped, false], + ['all-zero signature (universal forgery)', msgHex, zero + zero, compressed, false], + ['r = 0', msgHex, zero + sGen, compressed, false], + ['s = 0', msgHex, rGen + zero, compressed, false], + ['r = n', msgHex, hx(c.n, c.bytes) + sGen, compressed, false], + ['s = n', msgHex, rGen + hx(c.n, c.bytes), compressed, false], + ['truncated signature', msgHex, sigHex.slice(0, w), compressed, false], + ['oversized signature', msgHex, sigHex + 'ff', compressed, false], + ]; + + it.each(CASES)('%s', (_label, msg, sig, pk, want) => { + expect(verify(msg, sig, pk, false)).toBe(want); + expect(verify(msg, sig, pk, true)).toBe(want); + }); + + it('does not blow the interpreter stack budget', () => { + const off = run(c.emit, [push(msgHex), push(sigHex), push(compressed)], false); + const on = run(c.emit, [push(msgHex), push(sigHex), push(compressed)], true); + // The pool is a small constant number of extra resident slots. + expect(on.maxStackDepth).toBeLessThanOrEqual(off.maxStackDepth + 8); + expect(on.maxStackDepth).toBeLessThan(800); + }); + }); +} 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); + } + }); +}); diff --git a/packages/runar-testing/src/__tests__/liveness-scheduler-equivalence.test.ts b/packages/runar-testing/src/__tests__/liveness-scheduler-equivalence.test.ts new file mode 100644 index 000000000..418cd6a4b --- /dev/null +++ b/packages/runar-testing/src/__tests__/liveness-scheduler-equivalence.test.ts @@ -0,0 +1,156 @@ +/** + * Liveness scheduler + EC constant pool — semantic equivalence, source vs script. + * + * Both experiments move values around the stack (the scheduler parks results + * on the alt stack; the pool serves a constant from a resident slot). That is + * exactly the class of change that can produce a script which still runs and + * still leaves a truthy top-of-stack while computing something else, so a byte + * count proves nothing on its own. + * + * `runDifferentialExecution` compiles a contract, executes the deployed script + * on the real @bsv/sdk engine, AND runs the same spend through the ANF + * interpreter — a source-semantics oracle that knows nothing about stack + * layout. Running it once per variant on the same witness gives translation + * validation: + * + * current(w) == variant(w) == interpreter(w) for every witness w + * + * The witnesses are NOT invented here: they come from `conformance/witnesses/`, + * the same specs CI already runs, so every expectation is one the repo has + * independently committed to. `conformance/witnesses/coverage-claims.test.ts` + * enforces that each spec carries at least one accept AND one reject, so a + * variant that made everything pass cannot slip through. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { runDifferentialExecution, type WitnessArg } from '../oracle/differential-execution.js'; + +const CONFORMANCE = resolve(__dirname, '../../../../conformance'); +const WITNESS_DIR = join(CONFORMANCE, 'witnesses'); +const TESTS_DIR = join(CONFORMANCE, 'tests'); + +/** Decode a method-argument literal: bigint ("27n"), boolean, or bytes ("0x…"). */ +function decodeArg(v: unknown): WitnessArg { + if (typeof v === 'boolean') return v; + if (typeof v === 'string') { + if (/^-?\d+n$/.test(v)) return BigInt(v.slice(0, -1)); + if (v.startsWith('0x')) return Uint8Array.from(Buffer.from(v.slice(2), 'hex')); + } + throw new Error(`unencodable witness arg: ${JSON.stringify(v)}`); +} + +function decodeCtor(v: unknown): bigint | boolean | string { + if (typeof v === 'boolean') return v; + if (typeof v === 'string') { + if (/^-?\d+n$/.test(v)) return BigInt(v.slice(0, -1)); + if (v.startsWith('0x')) return v.slice(2); + } + throw new Error(`unencodable constructor arg: ${JSON.stringify(v)}`); +} + +interface Spend { method: string; args: unknown[]; expect: 'accept' | 'reject'; note?: string } +interface Spec { fixture: string; constructorArgs?: Record; spends: Spend[] } + +const NON_SPEC_JSON = new Set(['coverage-ledger.json']); +const SPECS: Spec[] = readdirSync(WITNESS_DIR) + .filter(f => f.endsWith('.json') && !NON_SPEC_JSON.has(f)) + .sort() + .map(f => JSON.parse(readFileSync(join(WITNESS_DIR, f), 'utf-8')) as Spec); + +/** Variants under test. `current` is the baseline every other is compared to. */ +const VARIANTS = [ + { name: 'liveness', opts: { schedulerMode: 'liveness' as const } }, + { name: 'ec-pool', opts: { ecConstantPool: true } }, + { name: 'both', opts: { schedulerMode: 'liveness' as const, ecConstantPool: true } }, + // Everything at once. Each optimization is proved separately elsewhere, but + // they compose in one compilation and the combination is what a user would + // actually turn on, so it gets its own pass over the witness corpus. + { + name: 'all', + opts: { + schedulerMode: 'liveness' as const, + ecConstantPool: true, + ecReductionSinking: true, + ecFixedBaseComb: true, + }, + }, +]; + +describe('experimental backends preserve acceptance', () => { + it('found the witness corpus', () => { + expect(SPECS.length).toBeGreaterThanOrEqual(10); + }); + + for (const spec of SPECS) { + const fixtureDir = join(TESTS_DIR, spec.fixture); + const srcCfg = JSON.parse(readFileSync(join(fixtureDir, 'source.json'), 'utf-8')) as + { sources?: Record; path?: string }; + const tsRel = srcCfg.sources?.['.runar.ts'] ?? srcCfg.path; + if (!tsRel) throw new Error(`no .runar.ts source in ${spec.fixture}/source.json`); + const srcPath = resolve(fixtureDir, tsRel); + const source = readFileSync(srcPath, 'utf-8'); + const fileName = srcPath.split('/').pop()!; + const ctor: Record = {}; + for (const [k, v] of Object.entries(spec.constructorArgs ?? {})) ctor[k] = decodeCtor(v); + + describe(spec.fixture, () => { + for (const s of spec.spends) { + for (const variant of VARIANTS) { + it(`${variant.name}: ${s.method}(${s.args.join(',')}) → ${s.expect}`, () => { + const common = { + source, fileName, method: s.method, + args: s.args.map(decodeArg), constructorArgs: ctor, + }; + const base = runDifferentialExecution(common); + const other = runDifferentialExecution({ ...common, ...variant.opts }); + + // The witness spec itself must hold, or the comparison is vacuous. + expect(base.vmAccepted, 'witness spec disagrees with the shipping compiler') + .toBe(s.expect === 'accept'); + // Translation validation, both directions against the interpreter. + expect(other.vmAccepted).toBe(base.vmAccepted); + expect(base.vmAccepted).toBe(base.interpreterAccepted); + expect(other.vmAccepted).toBe(other.interpreterAccepted); + expect(other.vmError ?? null).toBe(base.vmError ?? null); + }); + } + } + }); + } + + it('the liveness scheduler really does change bytes somewhere in this corpus', () => { + // Guards against the whole suite passing because every variant compiled to + // the identical script. + const changed: string[] = []; + for (const spec of SPECS) { + const fixtureDir = join(TESTS_DIR, spec.fixture); + const srcCfg = JSON.parse(readFileSync(join(fixtureDir, 'source.json'), 'utf-8')) as + { sources?: Record; path?: string }; + const tsRel = srcCfg.sources?.['.runar.ts'] ?? srcCfg.path; + if (!tsRel) continue; + const srcPath = resolve(fixtureDir, tsRel); + const ctor: Record = {}; + for (const [k, v] of Object.entries(spec.constructorArgs ?? {})) ctor[k] = decodeCtor(v); + const first = spec.spends[0]!; + const common = { + source: readFileSync(srcPath, 'utf-8'), + fileName: srcPath.split('/').pop()!, + method: first.method, + args: first.args.map(decodeArg), + constructorArgs: ctor, + }; + const base = runDifferentialExecution(common); + const sched = runDifferentialExecution({ ...common, schedulerMode: 'liveness' }); + if (sched.lockingHex !== base.lockingHex) { + changed.push(spec.fixture); + // And it must never be bigger — the cost model picks per method. + expect(sched.lockingHex.length, `${spec.fixture} grew`) + .toBeLessThan(base.lockingHex.length); + } + } + expect(changed.length, 'scheduler was a no-op on every witnessed fixture').toBeGreaterThan(0); + console.log(` scheduler changed bytes on: ${changed.join(', ')}`); + }); +}); diff --git a/packages/runar-testing/src/__tests__/scheduler-headroom.test.ts b/packages/runar-testing/src/__tests__/scheduler-headroom.test.ts new file mode 100644 index 000000000..f8e4c2e21 --- /dev/null +++ b/packages/runar-testing/src/__tests__/scheduler-headroom.test.ts @@ -0,0 +1,127 @@ +/** + * Headroom probe for the stack scheduler. + * + * `conformance/tests/arithmetic` is the smallest fixture whose bytes are + * produced ENTIRELY by the generic ANF -> Stack lowering: no crypto macro, no + * sighash scaffolding, no state continuation. 16 of its 28 bytes (57 %) are + * stack access. That makes it the honest measuring stick for "how much can a + * better schedule win on ordinary contracts?". + * + * This test pins two things: + * + * 1. what the compiler emits today, and + * 2. that a hand-written alternative schedule — operands held hot at the top, + * finished results parked on the alt stack — accepts and rejects exactly + * the same inputs while being materially smaller. + * + * (2) is not a claim about what the compiler does; it is the TARGET the + * liveness scheduler is aimed at, executed on the real interpreter so the + * headroom number in `docs/experiments/stack-scheduler-design.md` is measured + * rather than estimated. If a future scheduler beats it, tighten this test. + */ + +import { describe, it, expect } from 'vitest'; +import { ScriptVM } from '../vm/script-vm.js'; + +/** Encode a bigint as a minimally-encoded Bitcoin script number push. */ +function pushNum(n: bigint): string { + if (n === 0n) return '00'; + if (n >= 1n && n <= 16n) return (0x50 + Number(n)).toString(16).padStart(2, '0'); + const neg = n < 0n; + let v = neg ? -n : n; + const bytes: number[] = []; + while (v > 0n) { bytes.push(Number(v & 0xffn)); v >>= 8n; } + if (bytes[bytes.length - 1]! & 0x80) bytes.push(neg ? 0x80 : 0x00); + else if (neg) bytes[bytes.length - 1] = bytes[bytes.length - 1]! | 0x80; + return bytes.length.toString(16).padStart(2, '0') + bytes.map(b => b.toString(16).padStart(2, '0')).join(''); +} + +/** + * What the compiler emits today for `Arithmetic.verify`, with the constructor + * placeholder (`00`) replaced by a real target push. + * + * OP_2DUP OP_ADD sum + * OP_2 OP_PICK OP_2 OP_PICK OP_SUB diff + * OP_3 OP_PICK OP_3 OP_PICK OP_MUL prod + * OP_4 OP_ROLL OP_4 OP_ROLL OP_DIV quot + * OP_3 OP_ROLL OP_3 OP_ROLL OP_ADD OP_ROT OP_ADD OP_SWAP OP_ADD + * OP_NUMEQUAL + */ +function currentSchedule(target: bigint): string { + return `6e9352795279945379537995547a547a96537a537a937b937c93${pushNum(target)}9c`; +} + +/** + * The same computation, scheduled so `a` and `b` never leave the top two + * slots and each finished result is spilled to the alt stack: + * + * OP_2DUP OP_ADD OP_TOALTSTACK sum -> alt + * OP_2DUP OP_SUB OP_TOALTSTACK diff -> alt + * OP_2DUP OP_MUL OP_TOALTSTACK prod -> alt + * OP_DIV quot (consumes a, b) + * OP_FROMALTSTACK OP_ADD + prod + * OP_FROMALTSTACK OP_ADD + diff + * OP_FROMALTSTACK OP_ADD + sum + * OP_NUMEQUAL + * + * Addition is associative and commutative over script numbers here, so the + * reversed accumulation order is value-identical. + */ +function altStackSchedule(target: bigint): string { + return `6e936b6e946b6e956b966c936c936c93${pushNum(target)}9c`; +} + +function run(scriptHex: string, a: bigint, b: bigint): boolean { + const vm = new ScriptVM(); + const unlocking = `${pushNum(a)}${pushNum(b)}`; + const r = vm.execute( + Uint8Array.from(Buffer.from(unlocking, 'hex')), + Uint8Array.from(Buffer.from(scriptHex, 'hex')), + ); + return r.success; +} + +/** a + b, a - b, a * b, a / b summed — the contract's `result`. */ +function expected(a: bigint, b: bigint): bigint { + // Script's OP_DIV truncates toward zero, which matches bigint division. + return (a + b) + (a - b) + a * b + a / b; +} + +const CASES: [bigint, bigint][] = [ + [7n, 3n], [3n, 7n], [1n, 1n], [100n, 7n], [-5n, 3n], [5n, -3n], + [-5n, -3n], [0n, 1n], [16n, 16n], [17n, 2n], [255n, 4n], [-1n, -1n], + [1000n, 3n], [2n, 1000n], +]; + +describe('stack scheduler headroom (conformance/tests/arithmetic)', () => { + it('pins the byte cost of both schedules', () => { + // 5 is the byte cost of the `target` push in these probes (4-byte push of + // a value that needs a sign byte); both schedules carry the same one, so + // the difference is entirely scheduling. + const t = 1000n; + const cur = currentSchedule(t).length / 2; + const alt = altStackSchedule(t).length / 2; + expect(cur).toBe(30); + expect(alt).toBe(20); + // 33 % fewer bytes, all of it stack traffic. + expect(1 - alt / cur).toBeGreaterThan(0.3); + }); + + it('the emitted schedule matches the checked-in golden modulo the placeholder', () => { + // Golden is the template: `00` where the constructor arg is spliced in. + const template = '6e9352795279945379537995547a547a96537a537a937b937c93009c'; + expect(currentSchedule(0n)).toBe(template); + }); + + it.each(CASES)('both schedules accept exactly the right target for a=%s b=%s', (a, b) => { + const want = expected(a, b); + expect(run(currentSchedule(want), a, b)).toBe(true); + expect(run(altStackSchedule(want), a, b)).toBe(true); + }); + + it.each(CASES)('both schedules reject a wrong target for a=%s b=%s', (a, b) => { + const wrong = expected(a, b) + 1n; + expect(run(currentSchedule(wrong), a, b)).toBe(false); + expect(run(altStackSchedule(wrong), a, b)).toBe(false); + }); +}); diff --git a/packages/runar-testing/src/oracle/differential-execution.ts b/packages/runar-testing/src/oracle/differential-execution.ts index 8c68f3ed4..c6b5f8b8c 100644 --- a/packages/runar-testing/src/oracle/differential-execution.ts +++ b/packages/runar-testing/src/oracle/differential-execution.ts @@ -50,6 +50,16 @@ export interface DiffExecOptions { args: WitnessArg[]; // method arguments (interpreter + witness order) constructorArgs?: Record; disableConstantFolding?: boolean; // default false → fold-ON deployed bytes + /** + * EXPERIMENTAL backend toggles. Present so a size experiment can be run + * through this same source-vs-script oracle: compile the SAME contract with + * a flag off and on, execute both on the SAME witness, and require identical + * acceptance. Both default to the shipping path. + */ + schedulerMode?: 'current' | 'liveness'; + ecConstantPool?: boolean; + ecReductionSinking?: boolean; + ecFixedBaseComb?: boolean; } export interface DiffExecResult { @@ -88,6 +98,10 @@ export function runDifferentialExecution(opts: DiffExecOptions): DiffExecResult fileName: opts.fileName, disableConstantFolding: opts.disableConstantFolding ?? false, constructorArgs: ctor, + schedulerMode: opts.schedulerMode, + ecConstantPool: opts.ecConstantPool, + ecReductionSinking: opts.ecReductionSinking, + ecFixedBaseComb: opts.ecFixedBaseComb, }); if (!compiled.success || !compiled.artifact) { const errs = compiled.diagnostics