diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/ECPoint.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/ECPoint.kt index 8c7bb7da2..8a7a8c439 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/ECPoint.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/ECPoint.kt @@ -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 } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/KeyCodec.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/KeyCodec.kt index 8ada7efdd..59b886700 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/KeyCodec.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/KeyCodec.kt @@ -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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/PointTypes.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/PointTypes.kt index d942de931..037d3b354 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/PointTypes.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/PointTypes.kt @@ -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 } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/ScalarN.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/ScalarN.kt index 1382d9914..04932711b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/ScalarN.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/ScalarN.kt @@ -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) { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Secp256k1.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Secp256k1.kt index 6246bc148..6bea29216 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Secp256k1.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/Secp256k1.kt @@ -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). */