perf: reuse pre-allocated scratch for all entry-point allocations

Pool toAffine/toAffineX temp LongArrays (zInv, zInv2, zInv3) into
PointScratch. Add scratch-aware overloads that hot paths now use,
keeping allocating convenience overloads for one-time init paths
(buildCombTable).

Consolidate verify/sign/pubCreate/ECDH scratch into shared entry*
fields in PointScratch (entryPx, entryPy, entryPoint, entryResult,
entryTmp, entryTmp2). This replaces the old verify-specific fields
(verifyPx/Py/R/S/E/Rx/Ry/PPoint/Result) with fewer, shared buffers.

Update all Secp256k1 entry points (pubkeyCreate, signSchnorr,
signSchnorrInternal, verifySchnorr, pubKeyTweakMul, ecdhXOnly)
to use scratch instead of per-call allocations.

Add KeyCodec.liftX overload accepting a temp buffer to avoid 1
LongArray allocation per call.

Eliminate 2 longArrayOf allocations in ScalarN.reduceWideTo by
reusing the output array as temporary storage for hi limbs.

https://claude.ai/code/session_017UbWduFi1sLUsgVUMUH2nx
This commit is contained in:
Claude
2026-04-08 01:22:05 +00:00
parent fc690d66c7
commit f912520ee2
5 changed files with 124 additions and 105 deletions
@@ -855,11 +855,7 @@ internal object ECPoint {
// ==================== Coordinate Conversion ====================
/**
* Convert from Jacobian (X, Y, Z) to affine (x, y) = (X/Z², Y/Z³).
* Requires one field inversion (the most expensive single operation).
* Returns false if the point is at infinity.
*/
/** Convert Jacobian → affine (convenience, allocates temps). For one-time init paths. */
fun toAffine(
p: MutablePoint,
outX: LongArray,
@@ -877,21 +873,32 @@ internal object ECPoint {
return true
}
/**
* Convert from Jacobian to affine, returning only the x-coordinate: x = X/Z².
* Saves 2 multiplications vs full toAffine (no zInv3, no outY computation).
* Used by ecdhXOnly where only the x-coordinate of the shared point is needed.
*/
/** Convert Jacobian → affine using pre-allocated scratch (hot path). */
fun toAffine(
p: MutablePoint,
outX: LongArray,
outY: LongArray,
s: PointScratch,
): Boolean {
if (p.isInfinity()) return false
FieldP.inv(s.zInv, p.z)
FieldP.sqr(s.zInv2, s.zInv)
FieldP.mul(s.zInv3, s.zInv2, s.zInv)
FieldP.mul(outX, p.x, s.zInv2)
FieldP.mul(outY, p.y, s.zInv3)
return true
}
/** Convert Jacobian → affine x-only using pre-allocated scratch (hot path). */
fun toAffineX(
p: MutablePoint,
outX: LongArray,
s: PointScratch,
): Boolean {
if (p.isInfinity()) return false
val zInv = LongArray(4)
val zInv2 = LongArray(4)
FieldP.inv(zInv, p.z)
FieldP.sqr(zInv2, zInv)
FieldP.mul(outX, p.x, zInv2)
FieldP.inv(s.zInv, p.z)
FieldP.sqr(s.zInv2, s.zInv)
FieldP.mul(outX, p.x, s.zInv2)
return true
}
}
@@ -61,6 +61,23 @@ internal object KeyCodec {
return true
}
/** liftX with caller-provided temp buffer (avoids 1 LongArray alloc). */
fun liftX(
outX: LongArray,
outY: LongArray,
x: LongArray,
tmp: LongArray,
): Boolean {
if (U256.cmp(x, FieldP.P) >= 0) return false
FieldP.sqr(tmp, x)
FieldP.mul(tmp, tmp, x)
FieldP.add(tmp, tmp, B)
if (!FieldP.sqrt(outY, tmp)) return false
U256.copyInto(outX, x)
if (outY[0] and 1L != 0L) FieldP.neg(outY, outY)
return true
}
/** Check if y-coordinate is even (LSB = 0). */
fun hasEvenY(y: LongArray): Boolean = y[0] and 1L == 0L
@@ -127,14 +127,16 @@ internal class PointScratch {
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()
// Pre-allocated scratch for toAffine / toAffineX (avoids 3 LongArray allocs per call)
val zInv = LongArray(4)
val zInv2 = LongArray(4)
val zInv3 = LongArray(4)
// Pre-allocated scratch for Secp256k1 entry points (avoids per-call allocations)
val entryPx = LongArray(4) // liftX / parsePublicKey output
val entryPy = LongArray(4)
val entryPoint = MutablePoint() // pubkeyCreate, signSchnorr, ecdhXOnly
val entryResult = MutablePoint() // mulG / mul output
val entryTmp = LongArray(4) // liftX temp, auxrand XOR, nonce, etc.
val entryTmp2 = LongArray(4) // secondary temp for signSchnorr R-point
}
@@ -173,14 +173,14 @@ internal object ScalarN {
val lo1 = w[1]
val lo2 = w[2]
val lo3 = w[3]
val hi0 = w[4]
val hi1 = w[5]
val hi2 = w[6]
val hi3 = w[7]
val hiArr = longArrayOf(hi0, hi1, hi2, hi3)
// Use `out` as temporary storage for hi limbs (avoids longArrayOf allocation)
out[0] = w[4]
out[1] = w[5]
out[2] = w[6]
out[3] = w[7]
val hiTimesNC = w // reuse w as scratch
U256.mulWide(hiTimesNC, hiArr, N_COMPLEMENT)
U256.mulWide(hiTimesNC, out, N_COMPLEMENT)
// sum = hiTimesNC + lo
var carry = 0L
@@ -213,14 +213,17 @@ internal object ScalarN {
return
}
// Round 2
val hi2Arr = longArrayOf(w[4], w[5], w[6], w[7])
// Round 2: reuse out for hi2 limbs (avoids longArrayOf allocation)
out[0] = w[4]
out[1] = w[5]
out[2] = w[6]
out[3] = w[7]
val saved0 = w[0]
val saved1 = w[1]
val saved2 = w[2]
val saved3 = w[3]
val hi2NC = w
U256.mulWide(hi2NC, hi2Arr, N_COMPLEMENT)
U256.mulWide(hi2NC, out, N_COMPLEMENT)
var c2 = 0L
for (i in 0 until 4) {
@@ -136,12 +136,10 @@ object Secp256k1 {
require(seckey.size == 32)
val scalar = U256.fromBytes(seckey)
require(ScalarN.isValid(scalar))
val p = MutablePoint()
ECPoint.mulG(p, scalar)
val x = LongArray(4)
val y = LongArray(4)
check(ECPoint.toAffine(p, x, y))
return KeyCodec.serializeUncompressed(x, y)
val sc = ECPoint.getScratch()
ECPoint.mulG(sc.entryResult, scalar)
check(ECPoint.toAffine(sc.entryResult, sc.entryPx, sc.entryPy, sc))
return KeyCodec.serializeUncompressed(sc.entryPx, sc.entryPy)
}
/**
@@ -246,14 +244,12 @@ object Secp256k1 {
require(ScalarN.isValid(d0))
// Derive public key (one G multiplication + one inversion)
val pubPoint = MutablePoint()
ECPoint.mulG(pubPoint, d0)
val px = LongArray(4)
val py = LongArray(4)
check(ECPoint.toAffine(pubPoint, px, py))
val sc = ECPoint.getScratch()
ECPoint.mulG(sc.entryResult, d0)
check(ECPoint.toAffine(sc.entryResult, sc.entryPx, sc.entryPy, sc))
val xOnlyPub = U256.toBytes(px)
return signSchnorrInternal(data, d0, xOnlyPub, KeyCodec.hasEvenY(py), auxrand)
val xOnlyPub = U256.toBytes(sc.entryPx)
return signSchnorrInternal(data, d0, xOnlyPub, KeyCodec.hasEvenY(sc.entryPy), auxrand)
}
/**
@@ -297,23 +293,31 @@ object Secp256k1 {
pubKeyHasEvenY: Boolean,
auxrand: ByteArray?,
): ByteArray {
val d = if (pubKeyHasEvenY) d0 else ScalarN.neg(d0)
val sc = ECPoint.getScratch()
val tmp = sc.entryTmp
val d =
if (pubKeyHasEvenY) {
d0
} else {
ScalarN.negTo(tmp, d0)
tmp
}
val dBytes = U256.toBytes(d)
val t =
if (auxrand != null) {
require(auxrand.size == 32)
val auxHash = sha256(AUX_PREFIX + auxrand)
val tArr = LongArray(4)
U256.xorTo(tArr, U256.fromBytes(dBytes), U256.fromBytes(auxHash))
U256.toBytes(tArr)
} else {
dBytes
}
val tBytes: ByteArray
if (auxrand != null) {
require(auxrand.size == 32)
val auxHash = sha256(AUX_PREFIX + auxrand)
U256.xorTo(sc.entryTmp2, U256.fromBytes(dBytes), U256.fromBytes(auxHash))
tBytes = U256.toBytes(sc.entryTmp2)
} else {
tBytes = dBytes
}
val nonceInput = ByteArray(64 + 32 + 32 + data.size)
NONCE_PREFIX.copyInto(nonceInput, 0)
t.copyInto(nonceInput, 64)
tBytes.copyInto(nonceInput, 64)
pBytes.copyInto(nonceInput, 96)
data.copyInto(nonceInput, 128)
val rand = sha256(nonceInput)
@@ -321,11 +325,10 @@ object Secp256k1 {
require(!U256.isZero(k0))
// R = k0·G
val rPoint = MutablePoint()
ECPoint.mulG(rPoint, k0)
val rx = LongArray(4)
val ry = LongArray(4)
check(ECPoint.toAffine(rPoint, rx, ry))
ECPoint.mulG(sc.entryResult, k0)
val rx = sc.entryPx
val ry = sc.entryPy
check(ECPoint.toAffine(sc.entryResult, rx, ry, sc))
val k = if (KeyCodec.hasEvenY(ry)) k0 else ScalarN.neg(k0)
@@ -371,14 +374,12 @@ object Secp256k1 {
// Use thread-local scratch to avoid per-verify allocations.
// Saves ~10 LongArray(4) + 2 MutablePoint = ~14 object allocations per call.
val sc = ECPoint.getScratch()
val px = sc.verifyPx
val py = sc.verifyPy
if (!liftXCached(px, py, pub)) return false
if (!liftXCached(sc.entryPx, sc.entryPy, pub)) return false
val r = sc.verifyR
val r = sc.entryTmp
U256.fromBytesInto(r, signature, 0)
if (U256.cmp(r, FieldP.P) >= 0) return false
val s = sc.verifyS
val s = sc.entryTmp2
U256.fromBytesInto(s, signature, 32)
if (U256.cmp(s, ScalarN.N) >= 0) return false
@@ -389,23 +390,20 @@ object Secp256k1 {
pub.copyInto(hashInput, 96)
data.copyInto(hashInput, 128)
val eHash = sha256(hashInput)
val e = sc.verifyE
// Reuse entryPx for e (liftX result already copied into pPoint below)
val e = sc.zInv // safe: zInv not used until toAffine after mulDoubleG
U256.fromBytesInto(e, eHash, 0)
if (U256.cmp(e, ScalarN.N) >= 0) U256.subTo(e, e, ScalarN.N) // inline reduce
// R = s·G + (-e)·P via Shamir's trick
ScalarN.negTo(e, e) // negate in-place
val pPoint = sc.verifyPPoint
pPoint.setAffine(px, py)
val result = sc.verifyResult
ECPoint.mulDoubleG(result, s, pPoint, e)
sc.entryPoint.setAffine(sc.entryPx, sc.entryPy) // copies px/py, so entryPx is free
ECPoint.mulDoubleG(sc.entryResult, s, sc.entryPoint, e)
if (result.isInfinity()) return false
val rx = sc.verifyRx
val ry = sc.verifyRy
if (!ECPoint.toAffine(result, rx, ry)) return false
if (!KeyCodec.hasEvenY(ry)) return false
return U256.cmp(rx, r) == 0
if (sc.entryResult.isInfinity()) return false
if (!ECPoint.toAffine(sc.entryResult, sc.entryPx, sc.entryPy, sc)) return false
if (!KeyCodec.hasEvenY(sc.entryPy)) return false
return U256.cmp(sc.entryPx, r) == 0
}
// ==================== Tweak operations ====================
@@ -427,24 +425,19 @@ object Secp256k1 {
tweak: ByteArray,
): ByteArray {
require(tweak.size == 32)
val x = LongArray(4)
val y = LongArray(4)
check(KeyCodec.parsePublicKey(pubkey, x, y))
val sc = ECPoint.getScratch()
check(KeyCodec.parsePublicKey(pubkey, sc.entryPx, sc.entryPy))
val scalar = U256.fromBytes(tweak)
require(ScalarN.isValid(scalar))
val p = MutablePoint()
p.setAffine(x, y)
val result = MutablePoint()
ECPoint.mul(result, p, scalar)
val rx = LongArray(4)
val ry = LongArray(4)
check(ECPoint.toAffine(result, rx, ry))
sc.entryPoint.setAffine(sc.entryPx, sc.entryPy)
ECPoint.mul(sc.entryResult, sc.entryPoint, scalar)
check(ECPoint.toAffine(sc.entryResult, sc.entryPx, sc.entryPy, sc))
return if (pubkey.size == 33) {
KeyCodec.serializeCompressed(rx, ry)
KeyCodec.serializeCompressed(sc.entryPx, sc.entryPy)
} else {
KeyCodec.serializeUncompressed(rx, ry)
KeyCodec.serializeUncompressed(sc.entryPx, sc.entryPy)
}
}
@@ -466,25 +459,22 @@ object Secp256k1 {
scalar: ByteArray,
): ByteArray {
require(xOnlyPub.size == 32 && scalar.size == 32)
val x = U256.fromBytes(xOnlyPub)
require(U256.cmp(x, FieldP.P) < 0)
val sc = ECPoint.getScratch()
U256.fromBytesInto(sc.entryTmp, xOnlyPub, 0)
require(U256.cmp(sc.entryTmp, FieldP.P) < 0)
val k = U256.fromBytes(scalar)
require(ScalarN.isValid(k))
// Compute y = sqrt(x³ + 7). We need SOME valid y for EC point operations,
// but the result's x-coordinate is the same regardless of y sign.
// Use liftX which returns the even-y variant.
val px = LongArray(4)
val py = LongArray(4)
check(KeyCodec.liftX(px, py, x)) { "Not a valid x-coordinate on secp256k1" }
check(KeyCodec.liftX(sc.entryPx, sc.entryPy, sc.entryTmp, sc.entryTmp2)) {
"Not a valid x-coordinate on secp256k1"
}
val p = MutablePoint()
p.setAffine(px, py)
val result = MutablePoint()
ECPoint.mul(result, p, k)
val rx = LongArray(4)
check(ECPoint.toAffineX(result, rx))
return U256.toBytes(rx)
sc.entryPoint.setAffine(sc.entryPx, sc.entryPy)
ECPoint.mul(sc.entryResult, sc.entryPoint, k)
check(ECPoint.toAffineX(sc.entryResult, sc.entryPx, sc))
return U256.toBytes(sc.entryPx)
}
/** BIP-340 tagged hash (for tags not cached above). */