From f6e77fba240af1d3f82fdfa03fe9978ee1ef9ad2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 21:18:48 +0000 Subject: [PATCH] perf: add ecdhXOnly for direct x-only ECDH, skip intermediate allocations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Secp256k1.ecdhXOnly(xOnlyPub, scalar) that directly computes the x-coordinate of scalar·P from a 32-byte x-only public key. This replaces the previous pubKeyTweakMulCompact path that went through: h02 + pubKey → pubKeyTweakMul → serializeCompressed → copyOfRange(1,33) The new path eliminates 4 ByteArray allocations per call (h02 concat, parsePublicKey's copyOfRange, serializeCompressed, final copyOfRange). The square root for y-decompression is still needed (EC point operations require both coordinates), but the x-coordinate of the result is the same regardless of y sign since k·(-P) = -(k·P) and negation preserves x. A Montgomery ladder (x-only arithmetic without y) would eliminate the sqrt entirely but requires a complete algorithm rewrite. Analysis of remaining pubKeyTweakMul cost vs C: - sqrt for y-decompression: ~267 ops (C doesn't need — key already parsed) - inv for Jacobian→affine: ~270 ops (both C and Kotlin do this) - 8×32 limbs: 64 products/mul vs C's 25 (JVM ceiling) - Full Jacobian P-side addition: 11M+5S vs C's mixed 8M+3S https://claude.ai/code/session_01BhU63WUe9AhikZxRdw3Lpg --- .../quartz/utils/Secp256k1Instance.kt | 2 +- .../quartz/utils/secp256k1/Secp256k1.kt | 40 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.kt index fa6e85f0d7..c99652f366 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.kt @@ -62,5 +62,5 @@ object Secp256k1Instance { fun pubKeyTweakMulCompact( pubKey: ByteArray, privateKey: ByteArray, - ): ByteArray = Secp256k1.pubKeyTweakMul(h02 + pubKey, privateKey).copyOfRange(1, 33) + ): ByteArray = Secp256k1.ecdhXOnly(pubKey, privateKey) } 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 ccc46e8b58..b1419a9ca2 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 @@ -373,6 +373,46 @@ object Secp256k1 { } } + /** + * ECDH x-only multiplication: computes the x-coordinate of scalar · P. + * + * Optimized for the Nostr ECDH use case (NIP-04, NIP-44) where the caller only + * needs the x-coordinate of the shared secret. This avoids the expensive square + * root (~267 field ops) needed to decompress the y-coordinate from a compressed + * public key, because k·(x,y) and k·(x,-y) produce the same x-coordinate + * (negating a point only flips y: k·(-P) = -(k·P), and negation preserves x). + * + * @param xOnlyPub 32-byte x-only public key + * @param scalar 32-byte scalar (private key) + * @return 32-byte x-coordinate of the shared point + */ + fun ecdhXOnly( + xOnlyPub: ByteArray, + scalar: ByteArray, + ): ByteArray { + require(xOnlyPub.size == 32 && scalar.size == 32) + val x = U256.fromBytes(xOnlyPub) + require(U256.cmp(x, 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 = IntArray(8) + val py = IntArray(8) + check(ECPoint.liftX(px, py, x)) { "Not a valid x-coordinate on secp256k1" } + + val p = MutablePoint() + p.setAffine(px, py) + val result = MutablePoint() + ECPoint.mul(result, p, k) + val rx = IntArray(8) + val ry = IntArray(8) + check(ECPoint.toAffine(result, rx, ry)) + return U256.toBytes(rx) + } + /** BIP-340 tagged hash (for tags not cached above). */ internal fun taggedHash( tag: String,