Skip to content

Fixed-base comb: p256-wallet 179,890 → 147,113 B (and why Straus doesn't pay) - #159

Closed
icellan wants to merge 1 commit into
feat/ec-reduction-sinkingfrom
feat/ec-fixed-base-comb
Closed

Fixed-base comb: p256-wallet 179,890 → 147,113 B (and why Straus doesn't pay)#159
icellan wants to merge 1 commit into
feat/ec-reduction-sinkingfrom
feat/ec-fixed-base-comb

Conversation

@icellan

@icellan icellan commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Stacked on #158 (targets feat/ec-reduction-sinking). Opt-in via
--ec-fixed-base-comb; inert by default, all 72 goldens reproduce byte-for-byte,
script-size-check 72/72 ok.

Results

fixture shipping + pool (#156) + sinking (#158) + 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 %)
p256-primitives 928,219 296,303 173,640 140,864 (−84.8 %)
p384-primitives 1,883,767 447,707 261,534 212,061 (−88.7 %)

Per emitter: emitP256MulGen 90,676 → 54,117 (−40.3 %), emitP384MulGen
136,599 → 81,418 (−40.4 %), emitVerifyECDSA_P256 195,120 → 158,560 (−18.7 %).

Straus/Shamir was measured first, and rejected

The obvious move is a joint ladder for u1·G + u2·Q. It does not pay, and the
reason is worth recording.

The ladder's speed comes from the cheap incomplete mixed add.
buildJacobianAddOrDoubleInline justifies using it everywhere but the last 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. 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, 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, +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 complete adds ~1,138

So Straus wins only by dropping an unconditional guarantee for a DLP-hardness
one, inside a signature verifier. The comb keeps a single generator, so the
argument survives — that is why this PR is a comb and not a joint ladder.

Soundness

passes/comb.ts re-derives the interval argument rather than assuming it, as
executable arithmetic:

  • combParams searches for the scalar offset m with m·n ≥ 2^(w·d−1) and
    (m+1)·n − 1 < 2^(w·d), so the first comb digit is never zero and the
    accumulator never starts at infinity. For P-256 at w=3 it returns the same
    +3n the ladder hardcodes — and for P-384 at w=3 it returns +5n.
    Reusing +3n there would have left the leading digit free to vanish.
  • combSafeRounds proves per round that the pre-add accumulator cannot be
    0, +T[j] or −T[j] mod n for any table entry, 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. true is never
    assumed.

The window width is not hardcoded either: cEmitCombBest renders w = 2, 3, 4 in
full and keeps whichever estimateScriptBytes scores smallest.

Testing

ec-comb.test.ts — differential against the binary ladder on the real @bsv/sdk
engine over the scalars the argument turns on: 0, 1, small values, n−1, n,
n+1, 2n, negatives, and the powers of two either side of every block boundary
(2^85, 2^86, 2^171, 2^172, 2^255). Plus a cross-check against the independent
generic-point ladder, so a shared bug in the MulGen wrapper cannot hide. Then
the verifier under an OpenSSL oracle: genuine signature accepted, seven
near-misses rejected, comb and ladder agreeing on each.

comb-table.test.ts — compile-time arithmetic against published vectors (2G,
n·G = infinity), every table entry on-curve for both curves and w ∈ {2,3,4},
and the safety analysis monotone under a widened domain. A checker that proved
every round would be broken, not clever, so that is asserted too.

  • 4,113 compiler tests pass
  • 1,737 testing / CLI / conformance tests pass

Not in scope

secp256k1. comb.ts is curve-generic, but the emitter uses the NIST
codegen's a = −3 doubling, so ec-codegen.ts needs its own wiring. That is why
ec-primitives, ec-demo, schnorr-zkp, ec-unit and convergence-proof are
unchanged in the table above — worth roughly another −35 % on 4.5 MB of fixtures.

The 7-tier port. Same posture as #156 and #158: the flag ships off.

One doubling and one conditional add per COLUMN instead of per bit, wherever
the base point is a compile-time constant: p256MulGen, p384MulGen, and the
u1*G half of ECDSA verification. u2*Q keeps the ladder — Q arrives in the
witness.

    p256-wallet    958,792 -> 179,890 (sink) -> 147,113  (-84.7%)
    p384-wallet  1,963,300 -> 272,678        -> 223,204  (-88.6%)

    emitP256MulGen        90,676 -> 54,117   (-40.3%)
    emitP384MulGen       136,599 -> 81,418   (-40.4%)
    emitVerifyECDSA_P256 195,120 -> 158,560  (-18.7%)

Opt-in and inert by default: all 72 goldens still reproduce byte-for-byte and
script-size-check is 72/72 ok.

WHY NOT STRAUS

The obvious move is a joint ladder for u1*G + u2*Q. Measured, it does not pay.
The ladder's speed comes from the CHEAP incomplete mixed add, justified in
buildJacobianAddOrDoubleInline by an interval argument over c_i mod n — an
argument that holds because the accumulator is c_i*P and the addend is P, one
generator. 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, so the exception becomes reachable at an arbitrary step. Completing the
addition costs a measured +299 B/round (a whole ladder goes 90,610 -> 167,410,
+84.8%), against 690 B/round for two independent ladders — so joint + complete
lands at ~700 B/round. A loss. The comb keeps a single generator, so the
argument survives.

SOUNDNESS

comb.ts re-derives the interval argument for the comb rather than assuming it,
as executable arithmetic:

  - combParams searches for the scalar offset m with m*n >= 2^(w*d-1) and
    (m+1)*n - 1 < 2^(w*d), so the first digit is never zero and the accumulator
    never starts at infinity. For P-256 at w=3 that returns the same +3n the
    ladder hardcodes; for P-384 at w=3 it returns +5n. Reusing +3n there would
    have left the leading digit free to vanish.
  - combSafeRounds proves, per round, that the pre-add accumulator cannot be 0,
    +T[j] or -T[j] modulo n for any table entry, 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. `true` is never
    assumed.

The window width is not hardcoded: cEmitCombBest renders w = 2, 3 and 4 in full
and keeps whichever estimateScriptBytes scores smallest.

TESTING

ec-comb.test.ts is a differential against the binary ladder on the real @bsv/sdk
engine over the scalars the argument turns on — 0, 1, small values, n-1, n, n+1,
2n, negatives, and the powers of two either side of each block boundary
(2^85, 2^86, 2^171, 2^172, 2^255) — plus a cross-check against the INDEPENDENT
generic-point ladder so a shared bug in the MulGen wrapper cannot hide. Then the
verifier under an OpenSSL oracle: genuine signature accepted, seven near-misses
rejected, comb and ladder agreeing on every one.

comb-table.test.ts pins the compile-time arithmetic against published vectors
(2G, n*G = infinity), checks every table entry is on the curve for both curves
and w in {2,3,4}, and checks the safety analysis is monotone under a widened
domain — a checker that proved every round would be broken, not clever.

NOT IN SCOPE

secp256k1. comb.ts is curve-generic, but the emitter uses the NIST codegen's
a = -3 doubling; ec-codegen.ts needs its own wiring, which is why ec-primitives,
ec-demo, schnorr-zkp, ec-unit and convergence-proof are unchanged here.
@icellan

icellan commented Aug 29, 2026

Copy link
Copy Markdown
Owner Author

Superseded by #160, which consolidates this branch with the other two into a single PR. No commits are lost — #160 contains all seven, and the combined measurement is in its description.

@icellan icellan closed this Aug 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant