Skip to content

Faster secp256k1 arithmetic: reduction, Nat residues, wNAF, combined verify - #33

Merged
Kamirus merged 21 commits into
caffeinelabs:mainfrom
research-ag:optimization
Oct 21, 2026
Merged

Kamirus merged 21 commits into
caffeinelabs:mainfrom
research-ag:optimization

Conversation

@timohanke

@timohanke timohanke commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

The same branch was reviewed in research-ag#16 (CodeRabbit: approved, one finding fixed, see item 18). There is no version bump yet, so merging this does not tag or publish a release.

Optimization work after 0.2.3, one technique per commit. No version bump yet, so this isn't a release PR. Each commit message has its own before/after numbers, and bench/OPTIMIZATIONS.md keeps the running log.

Results (instructions; Δ vs the row above)

Step ECDSA verify Δ Verify heap Δ BIP32 depth 5 Δ
0.2.3 (GLV, % p) 158_720_308 6.81 MiB 521_699_275
Reduction specialized to secp256k1's p 138_177_588 −12.9% 11.78 MiB +73.0% 469_896_817 −9.9%
Field arithmetic on Nat residues 127_092_370 −8.0% 10.67 MiB −9.4% 442_104_728 −5.9%
Width-5 wNAF, Jacobian tables 116_817_125 −8.1% 9.78 MiB −8.3% 413_335_328 −6.5%
Combined multiplication in verify 87_478_767 −25.1% 7.18 MiB −26.6% 412_601_208 −0.2%
Iterative field inversion 86_057_048 −1.6% 7.11 MiB −1.0% 409_007_176 −0.9%
Verify without affine conversion 83_714_883 −2.7% 6.93 MiB −2.5% 409_007_176 0%
Precomputed tables for G (width 7) 72_794_211 −13.0% 5.98 MiB −13.7% 409_009_816 0%
Square root by addition chain 72_813_632 0% 5.99 MiB 0% 320_855_562 −21.6%
G tables in mul and mulBase 72_813_572 0% 5.99 MiB 0% 264_985_466 −17.4%
Doubling formula for a = 0 68_677_192 −5.7% 5.62 MiB −6.2% 244_672_531 −7.7%
Split G's scalar at 2⁶⁴ (width 6) 68_097_977 −0.8% 5.65 MiB +0.5% 184_148_568 −24.7%
Keep the field helpers private 68_401_510 +0.4% 5.65 MiB 0% 185_044_210 +0.5%
wNAF recoding on Nat64 words 60_580_051 −11.4% 5.24 MiB −7.3% 170_790_134 −7.7%
Leaner addition formulas 59_210_804 −2.3% 5.11 MiB −2.5% 167_182_713 −2.1%

In total, verify is 62.7% cheaper than 0.2.3 and allocates 25% less; BIP32 derivation is 68.0% cheaper. Since the generator tables, bench/ec_ops.bench.mo also tracks full-size operations: public key decompression went from 24.4M to 6.8M (−72%), and mulBase with a 256-bit scalar from 57.0M to 25.9M (−55%). For comparison, motoko_ecdsa 8.0.2 verifies in 110_018_892 instructions with 5.41 MiB of heap, and its optimization branch (research-ag/motoko_ecdsa#4) in 66_070_124 with 5.44 MiB, measured on the same machine and toolchain.

Commits

  1. Reduction specialized to secp256k1's p. Folds the bits above 2²⁵⁶ back in using 2²⁵⁶ ≡ 2³² + 977 (mod p), instead of % p.

  2. Field arithmetic on Nat residues. Coordinates are canonical residues in [0, p). Products use a fixed two-pass fold, and additions and subtractions use a conditional add or subtract of p. The formulas are unchanged. This also makes the doubling checks in the addition formulas exact: with signed values, a difference ≡ 0 but equal to ±p wasn't recognized.

  3. Width-5 wNAF. Tables of odd multiples P…15P and their endomorphism images. Adds Numbers.toWnaf() with property tests. Widths 4 and 6 measured worse. Short scalars get slower (the mul, k < 2⁵⁷ benchmark: +3.7%), because building the table has a fixed cost.

  4. Documented experiment: affine tables, not adopted. Converting the tables to affine with one batched inversion brings back mixed additions, but the inversion (about 3.7M instructions with extended Euclid) costs as much as it saves (verify +0.6%).

  5. Combined multiplication. Adds Jacobi.mulAdd(P1, a, P2, b) and uses it in Ecdsa.verify. All four GLV half-scalars share one loop, so about 129 doublings instead of 258. Tests compare it with add(mul, mul) on edge cases: zero scalars, points at infinity, equal and opposite points, sums that cancel, and scalars ≥ r.

  6. Iterative field inversion. Field.inverse does one bignum division per step instead of two (one inversion 3.14M → 2.44M). Checked against the old implementation on small, composite and degenerate moduli and on random inputs.

  7. Verify without affine conversion. It compares X with r·Z² (or (r + n)·Z² when r + n < p) instead of inverting Z. Wycheproof's "k*G has a large x-coordinate" vector covers the second case: the test fails with that branch disabled.

  8. Precomputed tables for G. The wNAF tables of G and φ(G) are constants in the generated src/ec/Secp256k1Generator.mo (from scripts/secp256k1_generator_table.py). They're affine and use window 7. mulAdd uses them whenever an input point is the generator. Large Nat literals are built by code at program start, so the cost is Wasm size, plus about 20k instructions once at init. For a canister that calls Ecdsa.verify (243 KB after wasm-opt -O3):

    Width Wasm Verify vs no table
    5 +5.4 KB (+2.2%) −8.7%
    6 +10.8 KB (+4.4%) −11.5%
    7 +21.4 KB (+8.8%) −13.0%
    8 +42.5 KB (+17.5%) −14.4%
  9. Benchmarks for full-size operations. Adds bench/ec_ops.bench.mo: key decompression, plus mulBase, mul and mulAdd with 256-bit scalars.

  10. Square root by addition chain. Field.mul uses the secp256k1 fold when the modulus is p and the product is below 2⁵¹². Its contract, (a * b) % n, is unchanged. Fp.sqrt() uses the 253-squaring, 13-multiplication chain. Decompression costs 72% less. Verifying against a compressed public key saves the same 17.6M; the verify benchmark doesn't show it because it uses a parsed key.

  11. G tables in mul and mulBase. mulBase −19.8%. The table test was rewritten so it no longer depends on mulBase.

  12. Doubling formula for a = 0. It uses 7 multiplications instead of 8, plus fewer subtractions: −5.7% to −9.2% across all operations.

  13. Split G's scalar at 2⁶⁴. Each GLV half of a multiple of G is split once more against tables for 2⁶⁴·G, so mulBase needs about 65 doublings instead of 129: −29.7%. That benefits BIP32 public key derivation and Taproot tweaking; the library doesn't sign locally. The four tables use width 6, so they hold 64 points, as many as the two width-7 tables before: Wasm +684 B, init +0.4k. Width 7 for all four would save another 1.9M on mulBase and 1.3M on verify, for +21.8 KB of Wasm and +34k init.

  14. Keep the field helpers private. Field.secp256k1P, Field.reduceSecp256k1 and Field.sqrtSecp256k1 are no longer public. The fold is reached through Field.mul, and the square-root chain is private to Fp. That costs about +0.4%, from one extra size check and a cross-module call per multiplication. Secp256k1Generator stays a public module (Motoko has no package-private modules), and its doc comment says it's internal.

  15. Retire bench/ec_arith.bench.mo. It only used scalars below 2⁵⁷, and its mulPoint row multiplied G. bench/ec_ops.bench.mo gets a 57-bit k column with the same scalar.

  16. wNAF recoding on Nat64 words. Comparing primitives with Faster scalar multiplication and ECDSA verify research-ag/motoko_ecdsa#4 showed that Numbers.toWnaf was the main difference. It did a bignum % and / per digit, 2.56M instructions per 128-bit scalar, and verify recodes six scalars. It now runs libsecp256k1's recoding on 64-bit words: 61.5k (42× faster). The output is identical to the old algorithm (checked on more than 4,000 inputs). Verify −11.4%.

  17. Leaner addition formulas. The H³ form, with the same multiplications but about half the field additions and subtractions of madd/add-2007-bl. Verify −2.3%.

  18. Guard the projective verify check (from CodeRabbit's review). Comparing X with r·Z² and (r + n)·Z² equals x mod n == r only if n < p < 2n, which holds on secp256k1. On other custom Curve values it accepted signatures with r ≥ p (n > p) or rejected valid ones (p > 2n). Verify now takes the projective check only if n < p < 2n and otherwise converts to affine. A test checks every (r, s) on two toy curves against directly computed results and fails without the guard. Cost on secp256k1: +0.006%.

  19. secp256r1 as a test-only curve. It's defined in test/ec/Secp256r1.mo and not exported. The tests run all 484 Wycheproof secp256r1 vectors (converted by scripts/wycheproof_ecdsa_to_motoko.py), and ecdsa_verify and ec_ops have a secp256r1 column. It has no GLV and a = −3, so it covers the generic code paths; verify takes 260.5M there. No change to src/.

API additions

  • Numbers.toWnaf(n, w)
  • Jacobi.mulAdd(point1, a, point2, b)
  • Secp256k1Generator: generated tables, documented as internal and not a stable API

All are listed under Unreleased in the CHANGELOG.

Checks

  • mops test --locked: 91 passed (85 before, plus tests for wNAF, mulAdd, the square-root chain, the fast reduction, verify on toy curves and the Wycheproof secp256r1 vectors), interpreted and compiled (--mode wasi). The generator tables are checked against Jacobi.add and a plain double-and-add.
  • The field reduction was checked against % on edge values and random products in temporary tests
  • moc --check with 2.0.0 and with the [requirements] floor 1.4.0: no warnings
  • Prettier: clean; doc coverage: 100%

Possible next steps

  • Affine tables, retried with the faster inversion: verify −1.1%, BIP32 −1.1%, but short scalars 9–34% slower. Dropped as too small a gain for the extra code (documented in the log).

🤖 Generated with Claude Code

timohanke and others added 20 commits October 20, 2026 11:35
Route every reduction in the point formulas through modP, which for the
secp256k1 prime folds the bits above 2^256 back in using
2^256 = 2^32 + 977 (mod p) instead of a bignum division. Other curves
keep `% p`.

ECDSA verify: 158.7M -> 138.2M instructions (-13%). Allocation per
verify grows from 6.8 to 11.8 MiB because of the intermediate bignums.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The point formulas used signed Int values in (-p, p) and reduced after
nearly every operation, including 768-bit products of three values. Now
every coordinate is a canonical residue in [0, p): products go through a
fixed two-pass secp256k1 fold (or % p on other curves), and additions and
subtractions use a conditional add or subtract of p. The formulas are
algebraically unchanged.

This also makes the doubling checks (H == 0, r == 0) in the addition
formulas exact. With signed values, a difference congruent to 0 but
equal to +-p was not recognized.

Benchmarks vs the previous commit (instructions / heap):
- ECDSA verify:   138_177_588 -> 127_092_370 (-8.0%), 11.78 -> 10.67 MiB (-9.4%)
- BIP32 depth 5:  469_896_817 -> 442_104_728 (-5.9%), 34.30 -> 31.52 MiB (-8.1%)
- mul, k < 2^57:   26_035_801 ->  23_711_013 (-8.9%),  2.25 ->  2.01 MiB (-10.7%)

Start bench/OPTIMIZATIONS.md to track what each step delivered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The GLV loop used width-2 NAF digits, so about one digit in three needed
a point addition. Each half scalar is now written in width-5 NAF (one
addition per six digits) against a table of the odd multiples
P, 3P, ..., 15P and their images under the endomorphism. The tables stay
in Jacobian coordinates, so additions are full additions.

Add Numbers.toWnaf() with property tests (digits sum to n, odd and
bounded, at least w apart; width 2 equals toNaf).

Benchmarks vs the previous commit (instructions / heap):
- ECDSA verify:   127_092_370 -> 116_817_125 (-8.1%), 10.67 -> 9.78 MiB (-8.3%)
- BIP32 depth 5:  442_104_728 -> 413_335_328 (-6.5%), 31.52 -> 29.02 MiB (-7.9%)
- mul, k < 2^57:   23_711_013 ->  24_593_089 (+3.7%),  2.01 ->  2.08 MiB (+3.5%)

Short scalars get slower because the tables cost one doubling and seven
additions per multiplication. Widths 4 and 6 measured 118.6M and 119.0M
for verify, so 5 stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Converting the wNAF tables to affine coordinates with one batched
inversion (Montgomery's trick) restores mixed additions in the main
loop, but the inversion (extended Euclid, about 3.7M instructions) costs
as much as it saves. Not adopted; recorded in bench/OPTIMIZATIONS.md.

Measured vs the previous commit (instructions / heap):
- ECDSA verify:   116_817_125 -> 117_561_742 (+0.6%), 9.78 -> 9.75 MiB
- BIP32 depth 5:  413_335_328 -> 412_244_576 (-0.3%), 29.02 -> 28.70 MiB
- mul, k small:     9_799_199 ->  13_553_484 (+38.3%)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add Jacobi.mulAdd(point1, a, point2, b), which returns a * P1 + b * P2.
On curves with GLV parameters it splits both scalars into their four
half-length terms and runs a single interleaved wNAF loop over all of
them, so the two multiplications share about 129 doublings instead of
needing about 258. Other curves fall back to add(mul(..), mul(..)).

The GLV code is refactored into glvTerms (split a scalar into wNAF terms
with their tables) and wnafSum (the shared loop); mul uses both with two
terms. Ecdsa.verify now calls mulAdd.

Tests compare mulAdd against add(mul, mul), including zero scalars,
points at infinity, equal and opposite points, sums that cancel, and
scalars of r and above.

Benchmarks vs the previous step (instructions / heap):
- ECDSA verify:  116_817_125 -> 87_478_767 (-25.1%), 9.78 -> 7.18 MiB (-26.6%)
- BIP32 depth 5: 413_335_328 -> 412_601_208 (-0.2%)
- mul, k < 2^57:  24_593_089 ->  24_599_742 (0%)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The recursive version on Int did two bignum divisions per step (a % b
and a / b) and tracked both Bezout coefficients. The iterative version
does one division per step and tracks only the coefficient of `a`. Same
contract: null when gcd(a, n) != 1, never traps. Checked against the old
implementation on small, composite and degenerate moduli and on random
inputs mod p and mod n.

One inversion mod p: 3.14M -> 2.44M instructions (-22%).

Benchmarks vs the previous commit (instructions / heap):
- ECDSA verify:   87_478_767 ->  86_057_048 (-1.6%), 7.18 -> 7.11 MiB
- BIP32 depth 5: 412_601_208 -> 409_007_176 (-0.9%)
- mul, k < 2^57:  24_599_742 ->  23_890_156 (-2.9%)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Verify converted u1*G + u2*Q to affine coordinates, one field inversion,
only to compare its x with r. Since x = X / Z^2 < p < 2n, x mod n == r
means x == r, or x == r + n when r + n < p. Compare X with r * Z^2 (and
(r + n) * Z^2 in the second case) instead.

Wycheproof's "k*G has a large x-coordinate" vector exercises the r + n
case; the ECDSA test fails with that branch disabled.

Benchmarks vs the previous commit (instructions / heap):
- ECDSA verify:  86_057_048 -> 83_714_883 (-2.7%), 7.11 -> 6.93 MiB (-2.5%)
- BIP32 and mul are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the faster inversion, affine tables measured verify -1.1% and
BIP32 depth 5 -1.1%, but scalar multiplication with short scalars got
9.5-34% slower. Dropped as too small a gain for the extra code; recorded
in bench/OPTIMIZATIONS.md. No code change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Verify built the wNAF tables for G and phi(G) on every call. They are
now constants in src/ec/Secp256k1Generator.mo, generated by
scripts/secp256k1_generator_table.py (which checks every point on the
curve, against an independent scalar multiplication, and phi(G) against
lambda * G). Being constant, they are affine, so G's digits use mixed
additions, and they use window 7 instead of 5. mulAdd uses them for any
input point that is the secp256k1 generator; mul and mulBase don't yet.

A new test recomputes every table entry with Jacobi.mulBase.

Large Nat literals are built by code at program start, not stored as
data. Measured on a canister calling Ecdsa.verify (242_950 bytes after
wasm-opt -O3 without tables) and with wasmtime fuel for the init cost:

  width  Wasm (-O3)          init (once)  verify      vs no table
  5      +5.4 KB  (+2.2%)    ~5k          76_459_061  -8.7%
  6      +10.8 KB (+4.4%)    ~10k         74_104_636  -11.5%
  7      +21.4 KB (+8.8%)    ~20k         72_794_211  -13.0%
  8      +42.5 KB (+17.5%)   ~39k         71_661_465  -14.4%

Benchmarks vs the previous commit (instructions / heap), width 7:
- ECDSA verify:  83_714_883 -> 72_794_211 (-13.0%), 6.93 -> 5.98 MiB (-13.7%)
- BIP32 and mul are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Large Nat literals are built by code in the Wasm start function, which
the IC runs at install, reinstall and every upgrade. The cost is charged
to that operation, not to later messages or to the first use of the
curve. Record the evidence (start section present, unchanged counter in
canister_init, equal first and second access, wasmtime fuel growing with
the table) and the caveat that the init cost was measured with wasmtime
fuel, not on a replica. No code change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
bench/ec_arith.bench.mo only uses scalars below 2^57, and nothing
measured public key decompression. bench/ec_ops.bench.mo measures
Jacobi.fromBytes on a compressed key, and mulBase, mul and mulAdd with
256-bit scalars, as the baseline for the next steps:

- decompress: 24_419_901
- mulBase:    56_959_886
- mul:        59_227_618
- mulAdd:     73_905_529

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Public key decompression computes a^((p+1)/4) through Fp.sqrt() and
Field.pow, which still used the generic % p for each of about 500
multiplications. Field.mul now folds with 2^256 = 2^32 + 977 (mod p)
when the modulus is the secp256k1 prime and the product is below 2^512,
so its contract, (a * b) % n for any inputs, is unchanged. Fp.sqrt()
uses an addition chain of 253 squarings and 13 multiplications (the one
in libsecp256k1) on secp256k1. The fold moves from Jacobi to
Field.reduceSecp256k1, shared by both.

New tests: the chain equals Field.pow with exponent (p+1)/4, and the
fold and Field.mul equal % p, including inputs above p and products of
2^512 and more.

Benchmarks vs the previous commit (instructions):
- decompress (fromBytes): 24_419_901 ->  6_772_845 (-72.3%), heap 949 -> 603 KiB
- BIP32 depth 5:         409_009_816 -> 320_855_562 (-21.6%)
- ECDSA verify with a parsed key, mulBase, mul, mulAdd: unchanged.
  Verifying against a compressed key saves the same 17.6M.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mul now picks the precomputed secp256k1 generator tables whenever its
point is the generator, like mulAdd already did, through a shared
pointTerms helper. mulBase no longer builds G's tables on every call,
and its additions are mixed additions with window 7.

The generator table test no longer relies on mulBase (which now uses
the tables): it builds (2i + 1)G with Jacobi.add, checks each phi entry
against beta * x, and checks phi(G) = lambda * G with a plain
double-and-add.

Benchmarks vs the previous commit (instructions / heap):
- mulBase, 256-bit:  56_945_858 ->  45_687_955 (-19.8%), 4.79 -> 3.82 MiB
- BIP32 depth 5:    320_855_562 -> 264_985_466 (-17.4%)
- mul of G, k<2^57:  23_865_696 ->  16_946_046 (-29.0%)
- mul on other points, mulAdd, ECDSA verify: unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On secp256k1 a = 0, but doDouble still computed Z^2, and it formed
Z3 = (Y + Z)^2 - YY - ZZ and S = 2((X + YY)^2 - XX - YYYY). Compute
S = 4 * X * YY and Z3 = 2 * Y * Z directly (dbl-2009-l) and square Z
only when a != 0: seven multiplications instead of eight, and fewer
subtractions. With bignums a multiplication costs about as much as a
squaring, so those squaring tricks only added work.

Also fix the "mul, k < 2^57" value of the square root row in
bench/OPTIMIZATIONS.md, which repeated the row above instead of that
run's measurement (23_865_696).

Benchmarks vs the previous commit (instructions / heap):
- ECDSA verify:   72_813_572 ->  68_677_192 (-5.7%), 5.99 -> 5.62 MiB
- BIP32 depth 5: 264_985_466 -> 244_672_531 (-7.7%)
- mulBase:        45_687_955 ->  41_492_544 (-9.2%)
- mul:            58_969_426 ->  54_834_056 (-7.0%)
- mulAdd:         73_652_493 ->  69_493_523 (-5.6%)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A multiple of G needs as many doublings as its longest wNAF term, about
129 for the GLV halves of a 256-bit scalar. Each GLV half k is now split
once more into kLow + 2^64 * kHigh, against precomputed tables of G,
H = 2^64 * G, phi(G) and phi(H), so mulBase needs about 65 doublings
instead of 129 and about the same number of additions. This speeds up
BIP32 public key derivation and Taproot key tweaking, the callers of
mulBase. (The library doesn't sign locally; Bitcoin.mo signs through an
EcdsaProxy.) Verify doesn't gain doublings, because Q's terms still have
129 digits.

The generator script emits the two new tables; the tables now use width
6, so four tables hold 64 points, as many as the two width-7 tables
before. The table test checks the new tables against repeated doubling,
Jacobi.add and a plain double-and-add.

Measured against the previous commit (Wasm of a canister calling verify
and mulBase after wasm-opt -O3; init with wasmtime fuel):

  tables       Wasm             init   mulBase     verify      BIP32 d5
  2 x width 7  266_539 B        ~34k   41_492_544  68_677_192  244_672_531
  4 x width 6  +684 B (+0.3%)   +0.4k  29_150_343  68_097_977  184_148_568
  4 x width 7  +21.8 KB (+8.2%) +34k   27_227_307  66_811_099  177_534_723

Committed with width 6:
- mulBase, 256-bit: 41_492_544 -> 29_150_343 (-29.7%), heap 3.44 -> 2.44 MiB
- BIP32 depth 5:   244_672_531 -> 184_148_568 (-24.7%)
- ECDSA verify:     68_677_192 ->  68_097_977 (-0.8%)
- mul of G with k < 2^57: +6.1% (no doublings to save, narrower window)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Field.secp256k1P, Field.reduceSecp256k1 and Field.sqrtSecp256k1 were
public only so that Jacobi and Fp could share them. The fold is now
private to Field and reached through Field.mul (whose contract is
unchanged), and the square-root chain is private to Fp. Jacobi's fmul
and fsqr call Field.mul.

Secp256k1Generator must stay a public module (Motoko has no
package-private modules); its doc comment now says it is internal and
not a stable API.

The field tests check the chain through Fp.sqrt() and the fold through
Field.mul.

Cost of going through Field.mul (an extra t < 2^512 check and a
cross-module call per multiplication), vs the previous commit:
- ECDSA verify:  68_097_977 ->  68_401_510 (+0.4%)
- BIP32 depth 5: 184_148_568 -> 185_044_210 (+0.5%)
- mulBase:        29_150_343 ->  29_273_390 (+0.4%)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
bench/ec_arith.bench.mo only used scalars below 2^57, so it misrepresented
every full-size change, and its mulPoint row multiplied G, so it measured
mulBase twice. bench/ec_ops.bench.mo gets a "57-bit k" column with the
same scalar next to the "256-bit k" column; its mulBase cell matches the
old "k large" cell (16_312_000 vs 16_311_884), so the short-scalar column
in bench/OPTIMIZATIONS.md continues from it.

ec_ops, 256-bit / 57-bit k (instructions):
- decompress: 6_826_397 / 6_826_080 (no scalar)
- mulBase:   29_273_496 / 16_312_000
- mul:       55_447_338 / 24_743_706
- mulAdd:    69_958_803 / 27_150_015

No code change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Numbers.toWnaf did a bignum % and / for every digit position: about
2.56M instructions for a 128-bit scalar, as much as 13 doublings, and
verify recodes six scalars. Comparing with research-ag/motoko_ecdsa#4,
whose primitives otherwise cost about the same as ours, showed this as
the main difference.

toWnaf now splits |n| into 64-bit words once and runs libsecp256k1's
recoding on Nat64 values: skip bits equal to the carry, otherwise take a
whole window, turn it into an odd digit and carry the excess. 61.5k
instructions for a 128-bit scalar (42x faster). The width-w NAF of a
number is unique, so the output is unchanged; checked against the old
algorithm on more than 4,000 inputs (edge values around 2^64 and 2^128,
negative values, widths 2-8, 31, 32, 33). Widths above 32 keep the
bignum algorithm.

Benchmarks vs the previous commit (instructions / heap):
- ECDSA verify:  68_401_510 ->  60_580_051 (-11.4%), 5.65 -> 5.24 MiB
- BIP32 depth 5: 185_044_210 -> 170_790_134 (-7.7%)
- mulBase:        29_273_390 ->  26_491_277 (-9.5%)
- mul:            55_446_920 ->  50_583_269 (-8.8%)
- mulAdd:         69_958_715 ->  62_263_255 (-11.0%)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
madd-2007-bl and add-2007-bl scale by 2 (I = 4 * HH, r = 2(S2 - S1),
2 * V, 2 * Y1 * J, Z3 = (Z1 + H)^2 - Z1Z1 - HH) to trade multiplications
for squarings and additions, which only pays off when a squaring is much
cheaper than a multiplication. With bignums it isn't, so the H^3 form
used by motoko_ecdsa, with the same 11 (mixed) and 16 (full)
multiplications and about half the additions and subtractions, is
cheaper. In isolation: mixed addition 302.8k -> 282.4k, full addition
423.2k -> 403.7k instructions. When H = 0 and r != 0 (P2 = -P1), the
formulas now return the point at infinity directly.

Benchmarks vs the previous commit (instructions / heap):
- ECDSA verify:   60_580_051 ->  59_210_804 (-2.3%), 5.24 -> 5.11 MiB
- BIP32 depth 5: 170_790_134 -> 167_182_713 (-2.1%)
- mulBase:        26_491_277 ->  25_855_869 (-2.4%)
- mul:            50_583_269 ->  49_925_359 (-1.3%)
- mulAdd:         62_263_255 ->  61_152_465 (-1.8%)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found by CodeRabbit's review of #16. Comparing
X with r * Z^2 and (r + n) * Z^2 is equivalent to x mod n == r only if
the group order n and the field prime p satisfy n < p < 2n, as on
secp256k1. Curves.Curve is public, and on other curves the check was
wrong both ways: with n > p it accepted signatures with r >= p
(curve.Fp(r) reduces r mod p), and with p > 2n it rejected valid
signatures whose x is r + 2n or more. Verify now uses the projective
check only if n < p < 2n and otherwise converts to affine as before.

A new test checks every (r, s) on two toy curves (p = 5, n = 7 and
p = 13, n = 5) against results computed directly in Python, including
pairs the unguarded check got wrong; it fails with the guard removed.
It also covers Jacobi's generic path for a != 0.

Also add docstrings to the generator script's functions (CodeRabbit's
docstring check); the generated module is unchanged.

Cost on secp256k1: verify 59_210_804 -> 59_214_243 instructions
(+0.006%), heap unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@timohanke
timohanke marked this pull request as ready for review October 20, 2026 15:00
@timohanke
timohanke requested a review from a team as a code owner October 20, 2026 15:00
secp256r1 (NIST P-256) is defined in test/ec/Secp256r1.mo for tests and
benchmarks only; Curves doesn't export it. It has no GLV endomorphism and
a = -3, so it exercises the generic code paths secp256k1 never takes.

- Tests: all 484 Wycheproof secp256r1 (SHA-256) vectors, converted from
  C2SP/wycheproof testvectors_v1 by the new
  scripts/wycheproof_ecdsa_to_motoko.py. The Wycheproof harness takes the
  curve as a parameter. Vectors flagged MissingZero (a legacy DER encoding
  the decoder accepts, "acceptable" in the older secp256k1 file) count as
  acceptable.
- Benchmarks: a secp256r1 column in ecdsa_verify and ec_ops.

Baseline on the generic paths (instructions): verify 260_500_021
(11.31 MiB), mulBase 130_937_616, mul 133_063_220, mulAdd 261_322_479,
decompression 15_896_705. secp256k1 numbers are unchanged. No change to
src/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Kamirus
Kamirus merged commit e895527 into caffeinelabs:main Oct 21, 2026
2 checks passed
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.

2 participants