refactor: reorganize secp256k1 for clarity without affecting performance
Extract MutablePoint, AffinePoint, and PointScratch from Point.kt into PointTypes.kt (140 lines). Rename Point.kt to ECPoint.kt (897 lines) to match the single top-level declaration (ktlint convention). Remove dead code: - addWnafJacobian: private function never called (replaced by addWnafMixedPP with effective-affine tables) Remove 5 KeyCodec wrapper functions from ECPoint that just delegated (liftX, hasEvenY, parsePublicKey, serializeUncompressed, serializeCompressed). Callers in Secp256k1.kt and PointTest.kt now use KeyCodec directly, making ownership clear: KeyCodec owns key encoding/decoding, ECPoint owns point arithmetic. Update Secp256k1.kt header documentation with current benchmark numbers (verify 1.7x, sign 0.9x faster than native, etc.) and a concise list of optimizations. No functional or performance changes — all 170 tests pass, benchmark numbers unchanged. https://claude.ai/code/session_017UbWduFi1sLUsgVUMUH2nx
This commit is contained in:
+16
-214
@@ -24,112 +24,29 @@ package com.vitorpamplona.quartz.utils.secp256k1
|
|||||||
// ELLIPTIC CURVE POINT OPERATIONS ON secp256k1
|
// ELLIPTIC CURVE POINT OPERATIONS ON secp256k1
|
||||||
// =====================================================================================
|
// =====================================================================================
|
||||||
//
|
//
|
||||||
// This file implements point arithmetic on the secp256k1 elliptic curve: y² = x³ + 7 (mod p).
|
// Point arithmetic on the secp256k1 curve: y² = x³ + 7 (mod p).
|
||||||
// It provides point addition, doubling, scalar multiplication, and serialization.
|
// See PointTypes.kt for MutablePoint, AffinePoint, and PointScratch.
|
||||||
//
|
|
||||||
// JACOBIAN COORDINATES
|
|
||||||
// ====================
|
|
||||||
// Points are stored in Jacobian projective coordinates (X, Y, Z) which represent the
|
|
||||||
// affine point (X/Z², Y/Z³). This avoids expensive field inversions during intermediate
|
|
||||||
// steps of scalar multiplication — inversion is only needed once at the very end to
|
|
||||||
// convert back to affine (x, y) form.
|
|
||||||
//
|
|
||||||
// The "point at infinity" (identity element) is represented by Z = 0.
|
|
||||||
//
|
//
|
||||||
// POINT FORMULAS
|
// POINT FORMULAS
|
||||||
// ==============
|
// ==============
|
||||||
// - doublePoint: 3M+4S (uses fe_half for L=(3/2)·X², same as libsecp256k1)
|
// - doublePoint: 3M+4S (uses fe_half for L=(3/2)·X², same as libsecp256k1)
|
||||||
// - addMixed (Jacobian + Affine): 8M+3S (used for precomputed table lookups)
|
// - addMixed (Jacobian + Affine): 8M+3S (precomputed table lookups)
|
||||||
// - addPoints (Jacobian + Jacobian): 11M+5S (used when both points are Jacobian)
|
// - addPoints (Jacobian + Jacobian): 11M+5S (both points Jacobian)
|
||||||
//
|
//
|
||||||
// SCALAR MULTIPLICATION STRATEGIES
|
// SCALAR MULTIPLICATION
|
||||||
// ================================
|
// =====================
|
||||||
// Three methods are used depending on the context:
|
// 1. mulG (Generator): Comb method, only 3 doublings + ~43 table lookups.
|
||||||
|
// 2. mul (Arbitrary): GLV + wNAF-5, ~130 shared doublings.
|
||||||
|
// 3. mulDoubleG (Verify: s·G + e·P): Strauss/Shamir + GLV + wNAF, 4 streams.
|
||||||
//
|
//
|
||||||
// 1. mulG (Generator multiplication): Comb method (Hamburg 2012).
|
// PRECOMPUTED TABLES (lazily initialized)
|
||||||
// Arranges scalar bits into a 4×66 matrix, processes 4 rows with 11 table lookups
|
// =======================================
|
||||||
// each. Only 3 doublings total. Uses a precomputed 704-entry affine table (~45KB).
|
// - combTable: 704 affine points for mulG (~45KB)
|
||||||
// Cost: ~43 mixed additions + 3 doublings ≈ 494 field ops.
|
// - gOddTable: 1024 affine points for G-side wNAF-12 (~128KB)
|
||||||
// Used by: pubkeyCreate, signSchnorr.
|
// - gLamTable: 1024 affine points for λ(G)-side wNAF-12 (~128KB)
|
||||||
//
|
// - pTableCache: 256-entry cache of P-side wNAF tables (~256KB, for verify)
|
||||||
// 2. mul (Arbitrary point multiplication): GLV endomorphism + wNAF-5 (Glv.kt).
|
|
||||||
// Splits the 256-bit scalar into two ~128-bit halves via the secp256k1 endomorphism,
|
|
||||||
// then processes both with wNAF encoding in a single pass of ~130 shared doublings.
|
|
||||||
// P-side tables are batch-inverted to affine (effective-affine technique) so the
|
|
||||||
// main loop uses addMixed (8M+3S) instead of addPoints (11M+5S), saving ~4M per add.
|
|
||||||
// Used by: pubKeyTweakMul (ECDH), ecdhXOnly.
|
|
||||||
//
|
|
||||||
// 3. mulDoubleG (Verification: s·G + e·P): Strauss/Shamir trick with GLV + wNAF.
|
|
||||||
// Splits both scalars via GLV into 4 half-scalar streams. G-side uses a precomputed
|
|
||||||
// 1024-entry affine wNAF-12 table (~128KB); P-side tables batch-inverted to affine.
|
|
||||||
// All 4 streams share ~130 doublings with mixed additions throughout.
|
|
||||||
// Used by: verifySchnorr.
|
|
||||||
//
|
|
||||||
// BATCH INVERSION
|
|
||||||
// ===============
|
|
||||||
// Montgomery's trick: convert n Jacobian→affine with 1 inversion + 3(n-1) muls instead
|
|
||||||
// of n individual inversions. Used for wNAF table construction and G table initialization.
|
|
||||||
//
|
|
||||||
// PRECOMPUTED TABLES
|
|
||||||
// ==================
|
|
||||||
// All tables are lazily initialized on first use (Kotlin `by lazy`):
|
|
||||||
// - combTable: 704 affine points for mulG (~45KB, built once per process)
|
|
||||||
// - gOddTable: 1024 affine points for G-side wNAF-12 (~128KB, batch-inverted)
|
|
||||||
// - gLamTable: 1024 affine points for λ(G)-side wNAF-12 (~128KB, derived from gOddTable)
|
|
||||||
//
|
|
||||||
// Note: C libsecp256k1 uses WINDOW_G=15 (8192 entries, 1MB) as compile-time .rodata.
|
|
||||||
// On JVM, w=15 is slower due to cache pressure from heap-allocated AffinePoint objects.
|
|
||||||
// w=12 (1024 entries, ~128KB) is the sweet spot — fits in L2, fewer additions than w=8.
|
|
||||||
// =====================================================================================
|
// =====================================================================================
|
||||||
|
|
||||||
/**
|
|
||||||
* Mutable Jacobian point for in-place computation.
|
|
||||||
*
|
|
||||||
* Points are mutable to avoid allocating new objects during the inner loop of scalar
|
|
||||||
* multiplication, which performs thousands of doublings and additions per operation.
|
|
||||||
*/
|
|
||||||
internal class MutablePoint(
|
|
||||||
val x: LongArray = LongArray(4),
|
|
||||||
val y: LongArray = LongArray(4),
|
|
||||||
val z: LongArray = LongArray(4),
|
|
||||||
) {
|
|
||||||
fun isInfinity(): Boolean = U256.isZero(z)
|
|
||||||
|
|
||||||
fun setInfinity() {
|
|
||||||
for (i in 0 until 4) {
|
|
||||||
x[i] = 0L
|
|
||||||
z[i] = 0L
|
|
||||||
}
|
|
||||||
y[0] = 1L
|
|
||||||
for (i in 1 until 4) y[i] = 0L
|
|
||||||
}
|
|
||||||
|
|
||||||
fun copyFrom(other: MutablePoint) {
|
|
||||||
other.x.copyInto(x)
|
|
||||||
other.y.copyInto(y)
|
|
||||||
other.z.copyInto(z)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setAffine(
|
|
||||||
ax: LongArray,
|
|
||||||
ay: LongArray,
|
|
||||||
) {
|
|
||||||
ax.copyInto(x)
|
|
||||||
ay.copyInto(y)
|
|
||||||
z[0] = 1L
|
|
||||||
for (i in 1 until 4) z[i] = 0L
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Affine point (x, y) — no Z coordinate.
|
|
||||||
* Used for precomputed tables where we want compact storage and mixed addition.
|
|
||||||
*/
|
|
||||||
internal class AffinePoint(
|
|
||||||
val x: LongArray = LongArray(4),
|
|
||||||
val y: LongArray = LongArray(4),
|
|
||||||
)
|
|
||||||
|
|
||||||
internal object ECPoint {
|
internal object ECPoint {
|
||||||
// ==================== Generator point G ====================
|
// ==================== Generator point G ====================
|
||||||
|
|
||||||
@@ -328,69 +245,7 @@ internal object ECPoint {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Thread-local scratch buffers ====================
|
// ==================== Thread-local scratch ====================
|
||||||
|
|
||||||
/**
|
|
||||||
* Scratch space for point operations. Each thread gets its own set of temporary
|
|
||||||
* field elements and a wide buffer to avoid allocation and ThreadLocal lookups
|
|
||||||
* in the inner loops. The 12 temp buffers (t[0]..t[11]) are shared across
|
|
||||||
* doublePoint and addPoints — this is safe because these functions only call
|
|
||||||
* each other in the equal-point degenerate case, which returns immediately
|
|
||||||
* after the recursive call without using the temps further.
|
|
||||||
*
|
|
||||||
* The wide buffer (LongArray(8)) is pre-fetched once per top-level operation
|
|
||||||
* and passed through to FieldP.mul/sqr, avoiding ~500+ ThreadLocal.get() calls
|
|
||||||
* per scalar multiplication (~20-30ns each on JVM).
|
|
||||||
*/
|
|
||||||
internal class PointScratch {
|
|
||||||
val t = Array(12) { LongArray(4) }
|
|
||||||
val dblCopy = MutablePoint() // Copy buffer for in-place doubling (out === input)
|
|
||||||
val w = LongArray(8) // Wide buffer for FieldP.mul/sqr — shared, avoids ThreadLocal
|
|
||||||
|
|
||||||
// Pre-allocated scratch for wNAF encoding (avoids IntArray allocation per call).
|
|
||||||
// Size 145 = 129 (max bits after GLV split) + 15 (max window) + 1 (headroom).
|
|
||||||
val wnaf1 = IntArray(145)
|
|
||||||
val wnaf2 = IntArray(145)
|
|
||||||
val wnaf3 = IntArray(145) // mulDoubleG needs 4 wNAF arrays
|
|
||||||
val wnaf4 = IntArray(145)
|
|
||||||
val wnafTmp = LongArray(4) // scratch for wnaf scalar copy (GLV scalars are up to 4 limbs)
|
|
||||||
|
|
||||||
// Pre-allocated scratch for wNAF mixed addition
|
|
||||||
val mixTmp = MutablePoint()
|
|
||||||
val mixNegY = LongArray(4)
|
|
||||||
|
|
||||||
// Pre-allocated P-side tables for mul/mulDoubleG (avoids ~80 LongArray allocs per call)
|
|
||||||
val pOddJac = Array(8) { MutablePoint() }
|
|
||||||
val pLamOddJac = Array(8) { MutablePoint() }
|
|
||||||
val pOddAff = Array(8) { AffinePoint() }
|
|
||||||
val pLamOddAff = Array(8) { AffinePoint() }
|
|
||||||
val p2 = MutablePoint() // doublePoint temp for table building
|
|
||||||
|
|
||||||
// Pre-allocated batch inversion temps (avoids 12 LongArray allocs per call)
|
|
||||||
val cumZ = Array(8) { LongArray(4) }
|
|
||||||
val batchInv = LongArray(4)
|
|
||||||
val batchZInv = LongArray(4)
|
|
||||||
val batchZInv2 = LongArray(4)
|
|
||||||
val batchZInv3 = LongArray(4)
|
|
||||||
|
|
||||||
// Pre-allocated scratch for Glv.splitScalar (avoids ~26 LongArray allocs per call)
|
|
||||||
val splitWide = LongArray(8) // mulShift384 and ScalarN.mulTo scratch
|
|
||||||
val splitT1 = LongArray(4) // temporary for mul results
|
|
||||||
val splitT2 = LongArray(4) // temporary for mul results
|
|
||||||
val splitK1 = LongArray(4) // output k1
|
|
||||||
val splitK2 = LongArray(4) // output k2
|
|
||||||
|
|
||||||
// Pre-allocated scratch for verifySchnorr (avoids per-call allocations)
|
|
||||||
val verifyPx = LongArray(4)
|
|
||||||
val verifyPy = LongArray(4)
|
|
||||||
val verifyR = LongArray(4)
|
|
||||||
val verifyS = LongArray(4)
|
|
||||||
val verifyE = LongArray(4)
|
|
||||||
val verifyRx = LongArray(4)
|
|
||||||
val verifyRy = LongArray(4)
|
|
||||||
val verifyPPoint = MutablePoint()
|
|
||||||
val verifyResult = MutablePoint()
|
|
||||||
}
|
|
||||||
|
|
||||||
private val scratch = ScratchLocal { PointScratch() }
|
private val scratch = ScratchLocal { PointScratch() }
|
||||||
|
|
||||||
@@ -883,33 +738,6 @@ internal object ECPoint {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Process one wNAF digit with full Jacobian addition (for P-side tables). */
|
|
||||||
private fun addWnafJacobian(
|
|
||||||
out: MutablePoint,
|
|
||||||
tmp: MutablePoint,
|
|
||||||
negScratch: MutablePoint,
|
|
||||||
wnafDigits: IntArray,
|
|
||||||
bitIndex: Int,
|
|
||||||
table: Array<MutablePoint>,
|
|
||||||
glvNeg: Boolean,
|
|
||||||
s: PointScratch,
|
|
||||||
) {
|
|
||||||
if (bitIndex >= wnafDigits.size) return
|
|
||||||
val d = wnafDigits[bitIndex]
|
|
||||||
if (d == 0) return
|
|
||||||
val idx = (if (d > 0) d else -d) / 2
|
|
||||||
val effectiveNeg = (d < 0) xor glvNeg
|
|
||||||
if (!effectiveNeg) {
|
|
||||||
addPoints(tmp, out, table[idx], s)
|
|
||||||
} else {
|
|
||||||
table[idx].x.copyInto(negScratch.x)
|
|
||||||
FieldP.neg(negScratch.y, table[idx].y)
|
|
||||||
table[idx].z.copyInto(negScratch.z)
|
|
||||||
addPoints(tmp, out, negScratch, s)
|
|
||||||
}
|
|
||||||
out.copyFrom(tmp)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Batch Affine Conversion (Montgomery's Trick) ====================
|
// ==================== Batch Affine Conversion (Montgomery's Trick) ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1066,30 +894,4 @@ internal object ECPoint {
|
|||||||
FieldP.mul(outX, p.x, zInv2)
|
FieldP.mul(outX, p.x, zInv2)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Key Encoding (delegates to KeyCodec) ====================
|
|
||||||
|
|
||||||
fun liftX(
|
|
||||||
outX: LongArray,
|
|
||||||
outY: LongArray,
|
|
||||||
x: LongArray,
|
|
||||||
) = KeyCodec.liftX(outX, outY, x)
|
|
||||||
|
|
||||||
fun hasEvenY(y: LongArray) = KeyCodec.hasEvenY(y)
|
|
||||||
|
|
||||||
fun parsePublicKey(
|
|
||||||
pubkey: ByteArray,
|
|
||||||
outX: LongArray,
|
|
||||||
outY: LongArray,
|
|
||||||
) = KeyCodec.parsePublicKey(pubkey, outX, outY)
|
|
||||||
|
|
||||||
fun serializeUncompressed(
|
|
||||||
x: LongArray,
|
|
||||||
y: LongArray,
|
|
||||||
) = KeyCodec.serializeUncompressed(x, y)
|
|
||||||
|
|
||||||
fun serializeCompressed(
|
|
||||||
x: LongArray,
|
|
||||||
y: LongArray,
|
|
||||||
) = KeyCodec.serializeCompressed(x, y)
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.utils.secp256k1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutable Jacobian point for in-place computation.
|
||||||
|
*
|
||||||
|
* Points are stored in Jacobian projective coordinates (X, Y, Z) which represent
|
||||||
|
* the affine point (X/Z², Y/Z³). This avoids expensive field inversions during
|
||||||
|
* intermediate steps — inversion is only needed once at the end to convert back
|
||||||
|
* to affine (x, y) form.
|
||||||
|
*
|
||||||
|
* The "point at infinity" (identity element) is represented by Z = 0.
|
||||||
|
*
|
||||||
|
* Mutable to avoid allocating new objects during the inner loop of scalar
|
||||||
|
* multiplication, which performs thousands of doublings and additions per operation.
|
||||||
|
*/
|
||||||
|
internal class MutablePoint(
|
||||||
|
val x: LongArray = LongArray(4),
|
||||||
|
val y: LongArray = LongArray(4),
|
||||||
|
val z: LongArray = LongArray(4),
|
||||||
|
) {
|
||||||
|
fun isInfinity(): Boolean = U256.isZero(z)
|
||||||
|
|
||||||
|
fun setInfinity() {
|
||||||
|
for (i in 0 until 4) {
|
||||||
|
x[i] = 0L
|
||||||
|
z[i] = 0L
|
||||||
|
}
|
||||||
|
y[0] = 1L
|
||||||
|
for (i in 1 until 4) y[i] = 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
fun copyFrom(other: MutablePoint) {
|
||||||
|
other.x.copyInto(x)
|
||||||
|
other.y.copyInto(y)
|
||||||
|
other.z.copyInto(z)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAffine(
|
||||||
|
ax: LongArray,
|
||||||
|
ay: LongArray,
|
||||||
|
) {
|
||||||
|
ax.copyInto(x)
|
||||||
|
ay.copyInto(y)
|
||||||
|
z[0] = 1L
|
||||||
|
for (i in 1 until 4) z[i] = 0L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Affine point (x, y) — no Z coordinate.
|
||||||
|
* Used for precomputed tables where we want compact storage and mixed addition.
|
||||||
|
*/
|
||||||
|
internal class AffinePoint(
|
||||||
|
val x: LongArray = LongArray(4),
|
||||||
|
val y: LongArray = LongArray(4),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-allocated scratch space for point operations. Each thread gets its own
|
||||||
|
* instance via [ScratchLocal] to avoid allocation and ThreadLocal lookups in the
|
||||||
|
* inner loops of scalar multiplication.
|
||||||
|
*
|
||||||
|
* The 12 temp buffers (t[0]..t[11]) are shared across doublePoint and addPoints —
|
||||||
|
* this is safe because these functions only call each other in the equal-point
|
||||||
|
* degenerate case, which returns immediately after the recursive call without
|
||||||
|
* using the temps further.
|
||||||
|
*
|
||||||
|
* The wide buffer (LongArray(8)) is pre-fetched once per top-level operation and
|
||||||
|
* passed through to FieldP.mul/sqr, avoiding ~500+ ThreadLocal.get() calls per
|
||||||
|
* scalar multiplication (~20-30ns each on JVM).
|
||||||
|
*/
|
||||||
|
internal class PointScratch {
|
||||||
|
val t = Array(12) { LongArray(4) }
|
||||||
|
val dblCopy = MutablePoint() // Copy buffer for in-place doubling (out === input)
|
||||||
|
val w = LongArray(8) // Wide buffer for FieldP.mul/sqr — shared, avoids ThreadLocal
|
||||||
|
|
||||||
|
// Pre-allocated scratch for wNAF encoding (avoids IntArray allocation per call).
|
||||||
|
// Size 145 = 129 (max bits after GLV split) + 15 (max window) + 1 (headroom).
|
||||||
|
val wnaf1 = IntArray(145)
|
||||||
|
val wnaf2 = IntArray(145)
|
||||||
|
val wnaf3 = IntArray(145) // mulDoubleG needs 4 wNAF arrays
|
||||||
|
val wnaf4 = IntArray(145)
|
||||||
|
val wnafTmp = LongArray(4) // scratch for wnaf scalar copy (GLV scalars are up to 4 limbs)
|
||||||
|
|
||||||
|
// Pre-allocated scratch for wNAF mixed addition
|
||||||
|
val mixTmp = MutablePoint()
|
||||||
|
val mixNegY = LongArray(4)
|
||||||
|
|
||||||
|
// Pre-allocated P-side tables for mul/mulDoubleG (avoids ~80 LongArray allocs per call)
|
||||||
|
val pOddJac = Array(8) { MutablePoint() }
|
||||||
|
val pLamOddJac = Array(8) { MutablePoint() }
|
||||||
|
val pOddAff = Array(8) { AffinePoint() }
|
||||||
|
val pLamOddAff = Array(8) { AffinePoint() }
|
||||||
|
val p2 = MutablePoint() // doublePoint temp for table building
|
||||||
|
|
||||||
|
// Pre-allocated batch inversion temps (avoids 12 LongArray allocs per call)
|
||||||
|
val cumZ = Array(8) { LongArray(4) }
|
||||||
|
val batchInv = LongArray(4)
|
||||||
|
val batchZInv = LongArray(4)
|
||||||
|
val batchZInv2 = LongArray(4)
|
||||||
|
val batchZInv3 = LongArray(4)
|
||||||
|
|
||||||
|
// Pre-allocated scratch for Glv.splitScalar (avoids ~26 LongArray allocs per call)
|
||||||
|
val splitWide = LongArray(8) // mulShift384 and ScalarN.mulTo scratch
|
||||||
|
val splitT1 = LongArray(4) // temporary for mul results
|
||||||
|
val splitT2 = LongArray(4) // temporary for mul results
|
||||||
|
val splitK1 = LongArray(4) // output k1
|
||||||
|
val splitK2 = LongArray(4) // output k2
|
||||||
|
|
||||||
|
// Pre-allocated scratch for verifySchnorr (avoids per-call allocations)
|
||||||
|
val verifyPx = LongArray(4)
|
||||||
|
val verifyPy = LongArray(4)
|
||||||
|
val verifyR = LongArray(4)
|
||||||
|
val verifyS = LongArray(4)
|
||||||
|
val verifyE = LongArray(4)
|
||||||
|
val verifyRx = LongArray(4)
|
||||||
|
val verifyRy = LongArray(4)
|
||||||
|
val verifyPPoint = MutablePoint()
|
||||||
|
val verifyResult = MutablePoint()
|
||||||
|
}
|
||||||
+31
-53
@@ -25,59 +25,37 @@ import com.vitorpamplona.quartz.utils.sha256.sha256
|
|||||||
/**
|
/**
|
||||||
* Pure Kotlin implementation of secp256k1 elliptic curve operations for Nostr.
|
* Pure Kotlin implementation of secp256k1 elliptic curve operations for Nostr.
|
||||||
*
|
*
|
||||||
* This replaces the native fr.acinq.secp256k1 JNI bindings with a portable KMP
|
* Portable KMP implementation that runs on all Kotlin targets (JVM, Android, iOS,
|
||||||
* implementation that runs on all Kotlin targets (JVM, Android, iOS, Linux) without
|
* Linux) without requiring platform-specific native libraries.
|
||||||
* requiring platform-specific native libraries.
|
|
||||||
*
|
*
|
||||||
* Provides only the operations used by Nostr:
|
* Provides only the operations used by Nostr:
|
||||||
* - [pubkeyCreate] / [pubKeyCompress]: Key generation
|
* - [pubkeyCreate] / [pubKeyCompress]: Key generation
|
||||||
* - [secKeyVerify]: Key validation
|
* - [secKeyVerify]: Key validation
|
||||||
* - [signSchnorr] / [verifySchnorr]: BIP-340 Schnorr signatures (NIP-01)
|
* - [signSchnorr] / [signSchnorrWithPubKey] / [verifySchnorr]: BIP-340 Schnorr (NIP-01)
|
||||||
* - [privKeyTweakAdd]: BIP-32 key derivation (NIP-06)
|
* - [privKeyTweakAdd]: BIP-32 key derivation (NIP-06)
|
||||||
* - [pubKeyTweakMul] / [ecdhXOnly]: ECDH shared secrets (NIP-04, NIP-44)
|
* - [pubKeyTweakMul] / [ecdhXOnly]: ECDH shared secrets (NIP-04, NIP-44)
|
||||||
*
|
*
|
||||||
* Performance on Java 21 (vs native C/JNI secp256k1, well-warmed):
|
* Performance on JVM (vs native C/JNI secp256k1, 2000+ warmup, 3000-5000 iterations):
|
||||||
* verify ~8,000 ops/s (3.4× native) — Strauss + GLV + wNAF-12
|
* verify ~15,000 ops/s (1.7× native, with pubkey cache)
|
||||||
* sign ~26,000 ops/s (1.1× native) — comb method (cached pubkey)
|
* sign ~18,000 ops/s (1.5× native)
|
||||||
* pubCreate ~36,000 ops/s (1.6× native) — comb method, 3 doublings
|
* sign(cached) ~28,000 ops/s (0.9× — FASTER than native)
|
||||||
* ECDH ~11,000 ops/s (2.8× native) — GLV + wNAF-5, effective-affine
|
* pubCreate ~38,000 ops/s (1.3× native)
|
||||||
* compress ~7M ops/s (1.7× FASTER) — pure Kotlin, no JNI overhead
|
* ECDH ~14,000 ops/s (2.0× native)
|
||||||
* secKeyVerify ~8M ops/s (1.2× FASTER) — scalar range check, no JNI
|
|
||||||
*
|
*
|
||||||
* Architecture:
|
* Key optimizations:
|
||||||
* Field arithmetic uses 4×64-bit limbs (LongArray(4)) with Math.unsignedMultiplyHigh
|
* - Unrolled 4×64-bit field arithmetic (mulWide, sqrWide, addTo, subTo)
|
||||||
* (Java 18+, single UMULH instruction) for 64×64→128-bit products. 16 products per
|
* - GLV endomorphism + wNAF + comb method + Strauss/Shamir
|
||||||
* field multiply vs C's 25 (5×52-bit limbs), but each C product is a single native
|
* - Ping-pong point buffers (eliminates copyFrom in scalar mul loops)
|
||||||
* 128-bit MUL instruction vs our UMULH + MUL + carry propagation (~7 insns total).
|
* - Pre-allocated ThreadLocal scratch (eliminates ~130 allocs/operation)
|
||||||
|
* - Pubkey decompression cache (skips sqrt for repeated pubkeys)
|
||||||
|
* - P-side wNAF table cache (skips table build for repeated pubkeys)
|
||||||
|
* - Direct unsigned multiplyHigh fallback (faster on Android < API 31)
|
||||||
*
|
*
|
||||||
* Per-doublePoint cost analysis (instruction-level, vs C libsecp256k1):
|
* Remaining gap vs C libsecp256k1:
|
||||||
* mul/sqr (7 ops): Kotlin ~1,204 insns vs C ~455 insns (2.6× — UMULH overhead)
|
* - No lazy reduction (4×64 limbs fully packed; C's 5×52 have 12-bit headroom)
|
||||||
* add/neg/half: Kotlin ~312 insns vs C ~75 insns (4.2× — no lazy reduction)
|
* - Fermat inversion instead of safegcd (safegcd slower on JVM)
|
||||||
* Total: Kotlin ~1,516 insns vs C ~530 insns (2.9× — matches benchmarks)
|
* - WINDOW_G=12 vs 15 (JVM heap tables cause cache pressure at w=15)
|
||||||
*
|
* - No constant-time guarantees (not needed for Nostr)
|
||||||
* Optimizations implemented (matching or adapted from libsecp256k1):
|
|
||||||
* - Math.unsignedMultiplyHigh (Java 18+): eliminates 4-insn signed→unsigned correction
|
|
||||||
* - GLV endomorphism: splits 256-bit scalars into 2×128-bit halves
|
|
||||||
* - wNAF encoding: windowed non-adjacent form for sparse addition patterns
|
|
||||||
* - Comb method: generator multiplication with only 3 doublings (Hamburg 2012)
|
|
||||||
* - Strauss/Shamir: interleaved multi-scalar multiplication for verification
|
|
||||||
* - Effective-affine: batch-inverts wNAF tables for cheaper mixed adds (saves ~4M/add)
|
|
||||||
* - Shared Z inversion: GLV table pairs share Z coords, one inversion for both
|
|
||||||
* - Batch inversion: Montgomery's trick (1 inv + 3(n-1) muls for n inversions)
|
|
||||||
* - Pre-allocated scratch: ThreadLocal PointScratch eliminates ~130 allocs/operation
|
|
||||||
* - Dedicated squaring: 10 products vs 16 for general multiplication
|
|
||||||
* - secp256k1-specific reduceSelf: single branch on a[3]==-1 (>99.99% fast path)
|
|
||||||
*
|
|
||||||
* Differences from C libsecp256k1 (due to JVM constraints):
|
|
||||||
* - No lazy reduction (4×64 limbs have no headroom; C's 5×52 limbs have 12-bit spare
|
|
||||||
* capacity per limb, allowing 3-8 chained add/sub without normalizing — this accounts
|
|
||||||
* for 24% of the remaining per-operation gap)
|
|
||||||
* - Fermat inversion (255 sqr + 15 mul) instead of safegcd (safegcd is slower on JVM
|
|
||||||
* due to 128-bit arithmetic overhead in the inner divstep matrix multiply)
|
|
||||||
* - WINDOW_G=12 instead of 15 (JVM heap-allocated tables cause cache pressure at
|
|
||||||
* larger sizes; C uses contiguous compile-time .rodata arrays)
|
|
||||||
* - No constant-time guarantees (not needed for Nostr — secrets are nonces, not
|
|
||||||
* long-term keys exposed to timing side-channels)
|
|
||||||
*/
|
*/
|
||||||
object Secp256k1 {
|
object Secp256k1 {
|
||||||
// ==================== Cached BIP-340 tag hash prefixes ====================
|
// ==================== Cached BIP-340 tag hash prefixes ====================
|
||||||
@@ -145,7 +123,7 @@ object Secp256k1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cache miss — compute sqrt and store
|
// Cache miss — compute sqrt and store
|
||||||
if (!ECPoint.liftX(outX, outY, U256.fromBytes(pub))) return false
|
if (!KeyCodec.liftX(outX, outY, U256.fromBytes(pub))) return false
|
||||||
|
|
||||||
pubkeyCache[slot] = CachedPubkey(pub.copyOf(), outX.copyOf(), outY.copyOf())
|
pubkeyCache[slot] = CachedPubkey(pub.copyOf(), outX.copyOf(), outY.copyOf())
|
||||||
return true
|
return true
|
||||||
@@ -163,7 +141,7 @@ object Secp256k1 {
|
|||||||
val x = LongArray(4)
|
val x = LongArray(4)
|
||||||
val y = LongArray(4)
|
val y = LongArray(4)
|
||||||
check(ECPoint.toAffine(p, x, y))
|
check(ECPoint.toAffine(p, x, y))
|
||||||
return ECPoint.serializeUncompressed(x, y)
|
return KeyCodec.serializeUncompressed(x, y)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -275,7 +253,7 @@ object Secp256k1 {
|
|||||||
check(ECPoint.toAffine(pubPoint, px, py))
|
check(ECPoint.toAffine(pubPoint, px, py))
|
||||||
|
|
||||||
val xOnlyPub = U256.toBytes(px)
|
val xOnlyPub = U256.toBytes(px)
|
||||||
return signSchnorrInternal(data, d0, xOnlyPub, ECPoint.hasEvenY(py), auxrand)
|
return signSchnorrInternal(data, d0, xOnlyPub, KeyCodec.hasEvenY(py), auxrand)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -349,7 +327,7 @@ object Secp256k1 {
|
|||||||
val ry = LongArray(4)
|
val ry = LongArray(4)
|
||||||
check(ECPoint.toAffine(rPoint, rx, ry))
|
check(ECPoint.toAffine(rPoint, rx, ry))
|
||||||
|
|
||||||
val k = if (ECPoint.hasEvenY(ry)) k0 else ScalarN.neg(k0)
|
val k = if (KeyCodec.hasEvenY(ry)) k0 else ScalarN.neg(k0)
|
||||||
|
|
||||||
// Challenge: e = H(R || P || msg)
|
// Challenge: e = H(R || P || msg)
|
||||||
val chalInput = ByteArray(64 + 32 + 32 + data.size)
|
val chalInput = ByteArray(64 + 32 + 32 + data.size)
|
||||||
@@ -426,7 +404,7 @@ object Secp256k1 {
|
|||||||
val rx = sc.verifyRx
|
val rx = sc.verifyRx
|
||||||
val ry = sc.verifyRy
|
val ry = sc.verifyRy
|
||||||
if (!ECPoint.toAffine(result, rx, ry)) return false
|
if (!ECPoint.toAffine(result, rx, ry)) return false
|
||||||
if (!ECPoint.hasEvenY(ry)) return false
|
if (!KeyCodec.hasEvenY(ry)) return false
|
||||||
return U256.cmp(rx, r) == 0
|
return U256.cmp(rx, r) == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,7 +429,7 @@ object Secp256k1 {
|
|||||||
require(tweak.size == 32)
|
require(tweak.size == 32)
|
||||||
val x = LongArray(4)
|
val x = LongArray(4)
|
||||||
val y = LongArray(4)
|
val y = LongArray(4)
|
||||||
check(ECPoint.parsePublicKey(pubkey, x, y))
|
check(KeyCodec.parsePublicKey(pubkey, x, y))
|
||||||
val scalar = U256.fromBytes(tweak)
|
val scalar = U256.fromBytes(tweak)
|
||||||
require(ScalarN.isValid(scalar))
|
require(ScalarN.isValid(scalar))
|
||||||
|
|
||||||
@@ -464,9 +442,9 @@ object Secp256k1 {
|
|||||||
check(ECPoint.toAffine(result, rx, ry))
|
check(ECPoint.toAffine(result, rx, ry))
|
||||||
|
|
||||||
return if (pubkey.size == 33) {
|
return if (pubkey.size == 33) {
|
||||||
ECPoint.serializeCompressed(rx, ry)
|
KeyCodec.serializeCompressed(rx, ry)
|
||||||
} else {
|
} else {
|
||||||
ECPoint.serializeUncompressed(rx, ry)
|
KeyCodec.serializeUncompressed(rx, ry)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -498,7 +476,7 @@ object Secp256k1 {
|
|||||||
// Use liftX which returns the even-y variant.
|
// Use liftX which returns the even-y variant.
|
||||||
val px = LongArray(4)
|
val px = LongArray(4)
|
||||||
val py = LongArray(4)
|
val py = LongArray(4)
|
||||||
check(ECPoint.liftX(px, py, x)) { "Not a valid x-coordinate on secp256k1" }
|
check(KeyCodec.liftX(px, py, x)) { "Not a valid x-coordinate on secp256k1" }
|
||||||
|
|
||||||
val p = MutablePoint()
|
val p = MutablePoint()
|
||||||
p.setAffine(px, py)
|
p.setAffine(px, py)
|
||||||
|
|||||||
+11
-11
@@ -286,10 +286,10 @@ class PointTest {
|
|||||||
fun liftXGenerator() {
|
fun liftXGenerator() {
|
||||||
val x = LongArray(4)
|
val x = LongArray(4)
|
||||||
val y = LongArray(4)
|
val y = LongArray(4)
|
||||||
assertTrue(ECPoint.liftX(x, y, ECPoint.GX))
|
assertTrue(KeyCodec.liftX(x, y, ECPoint.GX))
|
||||||
assertEquals(toHex(ECPoint.GX), toHex(x))
|
assertEquals(toHex(ECPoint.GX), toHex(x))
|
||||||
// liftX returns even y
|
// liftX returns even y
|
||||||
assertTrue(ECPoint.hasEvenY(y))
|
assertTrue(KeyCodec.hasEvenY(y))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -297,27 +297,27 @@ class PointTest {
|
|||||||
// p itself is not a valid x coordinate
|
// p itself is not a valid x coordinate
|
||||||
val x = LongArray(4)
|
val x = LongArray(4)
|
||||||
val y = LongArray(4)
|
val y = LongArray(4)
|
||||||
assertFalse(ECPoint.liftX(x, y, FieldP.P))
|
assertFalse(KeyCodec.liftX(x, y, FieldP.P))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Serialization round-trips ====================
|
// ==================== Serialization round-trips ====================
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun compressDecompressRoundTrip() {
|
fun compressDecompressRoundTrip() {
|
||||||
val compressed = ECPoint.serializeCompressed(ECPoint.GX, ECPoint.GY)
|
val compressed = KeyCodec.serializeCompressed(ECPoint.GX, ECPoint.GY)
|
||||||
val x = LongArray(4)
|
val x = LongArray(4)
|
||||||
val y = LongArray(4)
|
val y = LongArray(4)
|
||||||
assertTrue(ECPoint.parsePublicKey(compressed, x, y))
|
assertTrue(KeyCodec.parsePublicKey(compressed, x, y))
|
||||||
assertEquals(toHex(ECPoint.GX), toHex(x))
|
assertEquals(toHex(ECPoint.GX), toHex(x))
|
||||||
assertEquals(toHex(ECPoint.GY), toHex(y))
|
assertEquals(toHex(ECPoint.GY), toHex(y))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun uncompressedRoundTrip() {
|
fun uncompressedRoundTrip() {
|
||||||
val uncompressed = ECPoint.serializeUncompressed(ECPoint.GX, ECPoint.GY)
|
val uncompressed = KeyCodec.serializeUncompressed(ECPoint.GX, ECPoint.GY)
|
||||||
val x = LongArray(4)
|
val x = LongArray(4)
|
||||||
val y = LongArray(4)
|
val y = LongArray(4)
|
||||||
assertTrue(ECPoint.parsePublicKey(uncompressed, x, y))
|
assertTrue(KeyCodec.parsePublicKey(uncompressed, x, y))
|
||||||
assertEquals(toHex(ECPoint.GX), toHex(x))
|
assertEquals(toHex(ECPoint.GX), toHex(x))
|
||||||
assertEquals(toHex(ECPoint.GY), toHex(y))
|
assertEquals(toHex(ECPoint.GY), toHex(y))
|
||||||
}
|
}
|
||||||
@@ -326,8 +326,8 @@ class PointTest {
|
|||||||
fun parseInvalidKey() {
|
fun parseInvalidKey() {
|
||||||
val x = LongArray(4)
|
val x = LongArray(4)
|
||||||
val y = LongArray(4)
|
val y = LongArray(4)
|
||||||
assertFalse(ECPoint.parsePublicKey(ByteArray(10), x, y))
|
assertFalse(KeyCodec.parsePublicKey(ByteArray(10), x, y))
|
||||||
assertFalse(ECPoint.parsePublicKey(ByteArray(33), x, y)) // wrong prefix (0x00)
|
assertFalse(KeyCodec.parsePublicKey(ByteArray(33), x, y)) // wrong prefix (0x00)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -366,9 +366,9 @@ class PointTest {
|
|||||||
assertEquals(0x03.toByte(), compressed[0]) // Odd y → 03 prefix
|
assertEquals(0x03.toByte(), compressed[0]) // Odd y → 03 prefix
|
||||||
val x = LongArray(4)
|
val x = LongArray(4)
|
||||||
val y = LongArray(4)
|
val y = LongArray(4)
|
||||||
assertTrue(ECPoint.parsePublicKey(compressed, x, y))
|
assertTrue(KeyCodec.parsePublicKey(compressed, x, y))
|
||||||
// Round-trip: compress again should give same result
|
// Round-trip: compress again should give same result
|
||||||
val recompressed = ECPoint.serializeCompressed(x, y)
|
val recompressed = KeyCodec.serializeCompressed(x, y)
|
||||||
assertEquals(compressed.toList(), recompressed.toList())
|
assertEquals(compressed.toList(), recompressed.toList())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user