Repository navigation
Faster secp256k1 arithmetic: reduction, Nat residues, wNAF, combined verify - #33
Merged
Merged
Conversation
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
marked this pull request as ready for review
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
approved these changes
Oct 21, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.mdkeeps the running log.Results (instructions; Δ vs the row above)
% p)NatresiduesmulandmulBaseNat64wordsIn 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.moalso tracks full-size operations: public key decompression went from 24.4M to 6.8M (−72%), andmulBasewith 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
Reduction specialized to secp256k1's p. Folds the bits above 2²⁵⁶ back in using 2²⁵⁶ ≡ 2³² + 977 (mod p), instead of
% p.Field arithmetic on
Natresidues. 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.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 (themul, k < 2⁵⁷ benchmark: +3.7%), because building the table has a fixed cost.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%).
Combined multiplication. Adds
Jacobi.mulAdd(P1, a, P2, b)and uses it inEcdsa.verify. All four GLV half-scalars share one loop, so about 129 doublings instead of 258. Tests compare it withadd(mul, mul)on edge cases: zero scalars, points at infinity, equal and opposite points, sums that cancel, and scalars ≥ r.Iterative field inversion.
Field.inversedoes 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.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.
Precomputed tables for G. The wNAF tables of G and φ(G) are constants in the generated
src/ec/Secp256k1Generator.mo(fromscripts/secp256k1_generator_table.py). They're affine and use window 7.mulAdduses them whenever an input point is the generator. LargeNatliterals are built by code at program start, so the cost is Wasm size, plus about 20k instructions once at init. For a canister that callsEcdsa.verify(243 KB afterwasm-opt -O3):Benchmarks for full-size operations. Adds
bench/ec_ops.bench.mo: key decompression, plusmulBase,mulandmulAddwith 256-bit scalars.Square root by addition chain.
Field.muluses 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.G tables in
mulandmulBase.mulBase−19.8%. The table test was rewritten so it no longer depends onmulBase.Doubling formula for a = 0. It uses 7 multiplications instead of 8, plus fewer subtractions: −5.7% to −9.2% across all operations.
Split G's scalar at 2⁶⁴. Each GLV half of a multiple of G is split once more against tables for 2⁶⁴·G, so
mulBaseneeds 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 onmulBaseand 1.3M on verify, for +21.8 KB of Wasm and +34k init.Keep the field helpers private.
Field.secp256k1P,Field.reduceSecp256k1andField.sqrtSecp256k1are no longer public. The fold is reached throughField.mul, and the square-root chain is private toFp. That costs about +0.4%, from one extra size check and a cross-module call per multiplication.Secp256k1Generatorstays a public module (Motoko has no package-private modules), and its doc comment says it's internal.Retire
bench/ec_arith.bench.mo. It only used scalars below 2⁵⁷, and itsmulPointrow multiplied G.bench/ec_ops.bench.mogets a57-bit kcolumn with the same scalar.wNAF recoding on
Nat64words. Comparing primitives with Faster scalar multiplication and ECDSA verify research-ag/motoko_ecdsa#4 showed thatNumbers.toWnafwas 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%.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%.
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
Curvevalues 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%.secp256r1 as a test-only curve. It's defined in
test/ec/Secp256r1.moand not exported. The tests run all 484 Wycheproof secp256r1 vectors (converted byscripts/wycheproof_ecdsa_to_motoko.py), andecdsa_verifyandec_opshave a secp256r1 column. It has no GLV and a = −3, so it covers the generic code paths; verify takes 260.5M there. No change tosrc/.API additions
Numbers.toWnaf(n, w)Jacobi.mulAdd(point1, a, point2, b)Secp256k1Generator: generated tables, documented as internal and not a stable APIAll are listed under
Unreleasedin 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 againstJacobi.addand a plain double-and-add.%on edge values and random products in temporary testsmoc --checkwith 2.0.0 and with the[requirements]floor 1.4.0: no warningsPossible next steps
🤖 Generated with Claude Code