Merge pull request #2188 from vitorpamplona/claude/optimize-secp256k1-TFUD5

Optimize secp256k1 field arithmetic with fused multiply-reduce and platform-specific intrinsics
This commit is contained in:
Vitor Pamplona
2026-04-09 11:12:47 -04:00
committed by GitHub
29 changed files with 1843 additions and 330 deletions
@@ -35,9 +35,17 @@ import org.junit.runner.RunWith
/**
* Android microbenchmark for all secp256k1 operations used by Nostr.
*
* Uses AndroidX Benchmark (Jetpack Microbenchmark) for accurate, warmed-up
* measurements on real Android hardware/emulator. Results appear in Android
* Studio's benchmark output with ns/op, allocations, and percentiles.
* Measures the same operations as the JVM benchmark (Secp256k1Benchmark.kt) and the
* K/Native benchmark (Secp256k1NativeBenchmark.kt) for cross-platform comparability.
*
* Tests are structured as matched pairs: "Foo" uses the native C library (ACINQ/JNI),
* "FooOurs" uses the pure-Kotlin implementation.
*
* NOTE: verifySchnorr uses the same pubkey repeatedly, so it benefits from the
* pubkey decompression cache and P-table cache. This is realistic for Nostr
* (feed from one author) but not representative of first-time verification.
*
* Test data is the same across all 3 platform benchmarks for cross-platform comparability.
*
* Run with: ./gradlew :benchmark:connectedAndroidTest
*/
@@ -45,7 +53,7 @@ import org.junit.runner.RunWith
class Secp256k1Benchmark {
@get:Rule val benchmarkRule = BenchmarkRule()
// Test data
// Test data (same across all 3 platform benchmarks for comparability)
private val privKey = "67E56582298859DDAE725F972992A07C6C4FB9F62A8FFF58CE3CA926A1063530".hexToByteArray()
private val msg32 = "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89".hexToByteArray()
private val auxRand = "0000000000000000000000000000000000000000000000000000000000000001".hexToByteArray()
@@ -54,19 +62,27 @@ class Secp256k1Benchmark {
private val compressedPubKey = Secp256k1Instance.compressedPubKeyFor(privKey)
private val xOnlyPub = compressedPubKey.copyOfRange(1, 33)
private val signature = Secp256k1Instance.signSchnorr(msg32, privKey, auxRand)
private val uncompressedPubKey by lazy {
val seckey2 = "3982F19BEF1615BCCFBB05E321C10E1D4CBA3DF0E841C2E41EEB6016347653C3".hexToByteArray()
val compressed = Secp256k1Instance.compressedPubKeyFor(seckey2)
// We need an uncompressed key for pubKeyCompress benchmark
// Use the x-coordinate and compute full key
compressed // will be compressed for the benchmark
}
// ECDH test data
private val privKey2 = "3982F19BEF1615BCCFBB05E321C10E1D4CBA3DF0E841C2E41EEB6016347653C3".hexToByteArray()
private val pubKey2XOnly = Secp256k1Instance.compressedPubKeyFor(privKey2).copyOfRange(1, 33)
// ==================== Core Nostr operations ====================
// Batch verification data
private val batch8 = buildBatch(8)
private val batch16 = buildBatch(16)
private fun buildBatch(size: Int): Pair<List<ByteArray>, List<ByteArray>> {
val sigs = mutableListOf<ByteArray>()
val msgs = mutableListOf<ByteArray>()
for (i in 0 until size) {
val m = ByteArray(32) { (i * 7 + it).toByte() }
sigs.add(Secp256k1InstanceOurs.signSchnorr(m, privKey, auxRand))
msgs.add(m)
}
return Pair(sigs, msgs)
}
// ==================== Native C (ACINQ/JNI) ====================
@Test
fun verifySchnorr() {
@@ -89,8 +105,6 @@ class Secp256k1Benchmark {
}
}
// ==================== Key operations ====================
@Test
fun secKeyVerify() {
benchmarkRule.measureRepeated {
@@ -98,14 +112,6 @@ class Secp256k1Benchmark {
}
}
@Test
fun pubKeyCompress() {
// Compress an already-compressed key (fast path)
benchmarkRule.measureRepeated {
assertNotNull(Secp256k1Instance.compressedPubKeyFor(privKey))
}
}
@Test
fun privateKeyAdd() {
benchmarkRule.measureRepeated {
@@ -113,15 +119,15 @@ class Secp256k1Benchmark {
}
}
// ==================== ECDH (NIP-04, NIP-44) ====================
@Test
fun pubKeyTweakMulCompact() {
fun ecdhXOnly() {
benchmarkRule.measureRepeated {
assertNotNull(Secp256k1Instance.pubKeyTweakMulCompact(pubKey2XOnly, privKey))
}
}
// ==================== Pure Kotlin ====================
@Test
fun verifySchnorrOurs() {
benchmarkRule.measureRepeated {
@@ -129,6 +135,13 @@ class Secp256k1Benchmark {
}
}
@Test
fun verifySchnorrFastOurs() {
benchmarkRule.measureRepeated {
assertTrue(Secp256k1InstanceOurs.verifySchnorrFast(signature, msg32, xOnlyPub))
}
}
@Test
fun signSchnorrOurs() {
benchmarkRule.measureRepeated {
@@ -136,6 +149,13 @@ class Secp256k1Benchmark {
}
}
@Test
fun signSchnorrCachedPkOurs() {
benchmarkRule.measureRepeated {
assertNotNull(Secp256k1InstanceOurs.signSchnorrWithXOnlyPubKey(msg32, privKey, xOnlyPub, auxRand))
}
}
@Test
fun compressedPubKeyForOurs() {
benchmarkRule.measureRepeated {
@@ -143,8 +163,6 @@ class Secp256k1Benchmark {
}
}
// ==================== Key operations ====================
@Test
fun secKeyVerifyOurs() {
benchmarkRule.measureRepeated {
@@ -152,14 +170,6 @@ class Secp256k1Benchmark {
}
}
@Test
fun pubKeyCompressOurs() {
// Compress an already-compressed key (fast path)
benchmarkRule.measureRepeated {
assertNotNull(Secp256k1InstanceOurs.compressedPubKeyFor(privKey))
}
}
@Test
fun privateKeyAddOurs() {
benchmarkRule.measureRepeated {
@@ -167,12 +177,28 @@ class Secp256k1Benchmark {
}
}
// ==================== ECDH (NIP-04, NIP-44) ====================
@Test
fun pubKeyTweakMulCompactOurs() {
fun ecdhXOnlyOurs() {
benchmarkRule.measureRepeated {
assertNotNull(Secp256k1InstanceOurs.pubKeyTweakMulCompact(pubKey2XOnly, privKey))
}
}
// ==================== Batch verification (Pure Kotlin) ====================
// Same pubkey, n events — typical Nostr pattern (feed from one author).
// Per-event cost = ns/op ÷ batchSize.
@Test
fun verifyBatch8Ours() {
benchmarkRule.measureRepeated {
assertTrue(Secp256k1InstanceOurs.verifySchnorrBatch(xOnlyPub, batch8.first, batch8.second))
}
}
@Test
fun verifyBatch16Ours() {
benchmarkRule.measureRepeated {
assertTrue(Secp256k1InstanceOurs.verifySchnorrBatch(xOnlyPub, batch16.first, batch16.second))
}
}
}
+20
View File
@@ -1,6 +1,8 @@
import com.vanniktech.maven.publish.KotlinMultiplatform
import com.vanniktech.maven.publish.SourcesJar
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.plugin.mpp.NativeBuildType.DEBUG
import org.jetbrains.kotlin.gradle.plugin.mpp.NativeBuildType.RELEASE
import org.jetbrains.kotlin.gradle.targets.native.tasks.KotlinNativeTest
@@ -14,6 +16,14 @@ plugins {
kotlin {
compilerOptions {
freeCompilerArgs.add("-Xexpect-actual-classes")
// Remove Kotlin null-check assertions on parameters, return values, and receivers.
// These generate invokestatic Intrinsics.checkNotNullParameter at the entry of every
// function that takes non-null reference types (~4,000+ calls per Schnorr verify).
// Safe for this module: all internal secp256k1 code uses non-null LongArray params
// that are never null. Each check costs ~2-3ns on ART, totaling ~8-12μs per verify.
freeCompilerArgs.add("-Xno-param-assertions")
freeCompilerArgs.add("-Xno-call-assertions")
freeCompilerArgs.add("-Xno-receiver-assertions")
}
jvm {
compilerOptions {
@@ -91,6 +101,16 @@ kotlin {
environment("SIMCTL_CHILD_TEST_RESOURCES_ROOT", rootDir)
}
// Enable LLVM optimizations for native test binaries (benchmark accuracy).
// Without -opt, K/N test binaries compile in debug mode (~20× slower).
targets.withType<org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget>().configureEach {
compilations["test"].compileTaskProvider.configure {
compilerOptions {
freeCompilerArgs.add("-opt")
}
}
}
// Source set declarations.
// Declaring a target automatically creates a source set with the same name. By default, the
// Kotlin Gradle Plugin creates additional source sets that depend on each other, since it is
@@ -41,6 +41,14 @@ actual object Secp256k1Instance {
privKey: ByteArray,
): ByteArray = secp256k1.signSchnorr(data, privKey, null)
// Native C lib always derives pubkey internally — ignore the cached pubkey.
actual fun signSchnorrWithXOnlyPubKey(
data: ByteArray,
privKey: ByteArray,
xOnlyPubKey: ByteArray,
nonce: ByteArray?,
): ByteArray = secp256k1.signSchnorr(data, privKey, nonce)
actual fun verifySchnorr(
signature: ByteArray,
hash: ByteArray,
@@ -0,0 +1,60 @@
/*
* 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
/**
* Android field multiply/square — uses pure-Kotlin fallback for multiply-high.
*
* WHY NOT use Math.unsignedMultiplyHigh (API 35+) or Math.multiplyHigh (API 31+)?
* Because D8/R8 desugaring generates a synthetic backport wrapper
* (ExternalSyntheticBackport0.m) for ANY Math.xxx call when minSdk < the API
* that introduced it (minSdk=26 < 31/35). This backport wrapper:
* - Adds 139ns per call (traced on Pixel 8 with Android 16)
* - Is called 24,875 times per Schnorr verify
* - Costs 3.45ms total = 17.5% of verify time
* The pure-Kotlin fallback (4 Long multiplies + shifts) avoids the backport
* entirely and is FASTER than the backported intrinsic path.
*
* WHY NOT use API-level dispatch (fieldMulApi35/Api31/Fallback)?
* Profiling showed the D8 backport overhead dominates. The UMULH/SMULH
* intrinsic behind the backport is ~1ns, but the backport wrapper adds ~138ns.
* The pure fallback at ~10-20ns is much faster than 1+138=139ns.
*
* ALSO: uLt calls accounted for 20.7% of verify time (49,924 calls × 82ns each)
* because it's an expect/actual function (can't be inline). The inline XOR
* comparison in the crossinline lambda eliminates all those function calls.
*/
internal actual fun fieldMulReduce(
out: LongArray,
a: LongArray,
b: LongArray,
w: LongArray,
) {
fieldMulReduceWith(out, a, b, w) { x, y -> unsignedMultiplyHighFallback(x, y) }
}
internal actual fun fieldSqrReduce(
out: LongArray,
a: LongArray,
w: LongArray,
) {
fieldSqrReduceWith(out, a, w) { x, y -> unsignedMultiplyHighFallback(x, y) }
}
@@ -0,0 +1,44 @@
/*
* 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
/**
* Android: XOR-with-MIN_VALUE trick for unsigned comparison.
*
* DO NOT use Long.compareUnsigned here (even though it's an ART intrinsic since API 26).
* Kotlin's `a.toULong() < b.toULong()` compiles to Long.compareUnsigned BUT also emits
* 2 ULong.constructor-impl NOOP invokestatic calls per comparison. These NOOPs add ~2-3ns
* each on ART, totaling ~35-54μs per verify (~17,800 comparisons).
*
* DO NOT use Long.compareUnsigned directly either — ART may or may not inline the function
* call depending on method size budgets, adding unpredictable overhead.
*
* The XOR trick produces pure arithmetic bytecode with ZERO method calls:
* Bytecode: lload a, ldc MIN_VALUE, lxor, lload b, ldc MIN_VALUE, lxor, lcmp, ifge
* ARM64: EOR x0, a, #0x8000...; EOR x1, b, #0x8000...; CMP x0, x1; CSET x2, LT
*
* This approach was validated by bytecode analysis (javap) and benchmarked on Pixel 8
* (Android 16): ~14% improvement across all EC point operations.
*/
internal actual fun uLt(
a: Long,
b: Long,
): Boolean = (a xor Long.MIN_VALUE) < (b xor Long.MIN_VALUE)
@@ -43,3 +43,21 @@ actual fun sha256(data: ByteArray): ByteArray {
return digest.toByteArray()
}
@OptIn(ExperimentalForeignApi::class)
actual fun sha256Into(
out: ByteArray,
data: ByteArray,
len: Int,
): ByteArray {
data.usePinned { inputPinned ->
out.asUByteArray().usePinned { digestPinned ->
CC_SHA256(
inputPinned.addressOf(0),
len.convert(),
digestPinned.addressOf(0),
)
}
}
return out
}
@@ -34,6 +34,18 @@ object Nip01Crypto {
nonce: ByteArray? = RandomInstance.bytes(32),
): ByteArray = Secp256k1Instance.signSchnorr(data, privKey, nonce)
/**
* Fast sign with pre-computed x-only pubkey (32 bytes).
* Skips the expensive G multiplication to derive the pubkey (~20μs on Android).
* Use when the caller already has the pubkey (e.g., from KeyPair.pubKey).
*/
fun signWithPubKey(
data: ByteArray,
privKey: ByteArray,
xOnlyPubKey: ByteArray,
nonce: ByteArray? = RandomInstance.bytes(32),
): ByteArray = Secp256k1Instance.signSchnorrWithXOnlyPubKey(data, privKey, xOnlyPubKey, nonce)
fun verify(
signature: ByteArray,
hash: ByteArray,
@@ -36,6 +36,13 @@ expect object Secp256k1Instance {
privKey: ByteArray,
): ByteArray
fun signSchnorrWithXOnlyPubKey(
data: ByteArray,
privKey: ByteArray,
xOnlyPubKey: ByteArray,
nonce: ByteArray? = RandomInstance.bytes(32),
): ByteArray
fun verifySchnorr(
signature: ByteArray,
hash: ByteArray,
@@ -48,12 +48,40 @@ object Secp256k1InstanceOurs {
nonce: ByteArray? = RandomInstance.bytes(32),
): ByteArray = Secp256k1.signSchnorrWithPubKey(data, privKey, compressedPubKey, nonce)
/** Fast signing with pre-computed x-only public key — zero-copy, zero-allocation. */
fun signSchnorrWithXOnlyPubKey(
data: ByteArray,
privKey: ByteArray,
xOnlyPubKey: ByteArray,
nonce: ByteArray? = RandomInstance.bytes(32),
): ByteArray = Secp256k1.signSchnorrWithXOnlyPubKey(data, privKey, xOnlyPubKey, nonce)
fun verifySchnorr(
signature: ByteArray,
hash: ByteArray,
pubKey: ByteArray,
): Boolean = Secp256k1.verifySchnorr(signature, hash, pubKey)
/**
* Fast Nostr verify — skips BIP-340 y-parity inversion (~15% faster).
* Safe for Nostr event verification. See Secp256k1.verifySchnorrFast KDoc.
*/
fun verifySchnorrFast(
signature: ByteArray,
hash: ByteArray,
pubKey: ByteArray,
): Boolean = Secp256k1.verifySchnorrFast(signature, hash, pubKey)
/**
* Batch-verify multiple signatures from the same pubkey.
* Uses scalar+point summation — one mulDoubleG for the whole batch.
*/
fun verifySchnorrBatch(
pubKey: ByteArray,
signatures: List<ByteArray>,
messages: List<ByteArray>,
): Boolean = Secp256k1.verifySchnorrBatch(pubKey, signatures, messages)
fun privateKeyAdd(
first: ByteArray,
second: ByteArray,
@@ -677,29 +677,80 @@ internal object ECPoint {
cur.setInfinity()
val negY = sc.mixNegY
// Inner loop: double + conditionally add from 4 wNAF streams.
//
// The wNAF zero-check is INLINED here rather than delegated to
// addWnafMixedPP. ~70% of digits are zero, so this avoids ~364
// function calls per verify. Each function call on ART has ~5-8ns
// overhead (parameter null checks, frame setup), saving ~2-3μs total.
val negK1s = sSplit.negK1
val negK2s = sSplit.negK2
val negK1e = eSplit.negK1
val negK2e = eSplit.negK2
for (i in bits - 1 downTo 0) {
doublePoint(alt, cur, sc)
var t = cur
cur = alt
alt = t
// Streams 1-2: G-side (affine tables, mixed addition)
if (addWnafMixedPP(cur, alt, negY, wnafS1, i, gOdd, sSplit.negK1, sc)) {
var d: Int
// Stream 1: s₁ (G-side)
d = wnafS1[i]
if (d != 0) {
val idx = (if (d > 0) d else -d) / 2
if ((d < 0) xor negK1s) {
FieldP.neg(negY, gOdd[idx].y)
addMixed(alt, cur, gOdd[idx].x, negY, sc)
} else {
addMixed(alt, cur, gOdd[idx].x, gOdd[idx].y, sc)
}
t = cur
cur = alt
alt = t
}
if (addWnafMixedPP(cur, alt, negY, wnafS2, i, gLam, sSplit.negK2, sc)) {
// Stream 2: s₂ (λ(G)-side)
d = wnafS2[i]
if (d != 0) {
val idx = (if (d > 0) d else -d) / 2
if ((d < 0) xor negK2s) {
FieldP.neg(negY, gLam[idx].y)
addMixed(alt, cur, gLam[idx].x, negY, sc)
} else {
addMixed(alt, cur, gLam[idx].x, gLam[idx].y, sc)
}
t = cur
cur = alt
alt = t
}
// Streams 3-4: P-side (affine tables via effective-affine, mixed addition)
if (addWnafMixedPP(cur, alt, negY, wnafE1, i, pOdd, eSplit.negK1, sc)) {
// Stream 3: e₁ (P-side)
d = wnafE1[i]
if (d != 0) {
val idx = (if (d > 0) d else -d) / 2
if ((d < 0) xor negK1e) {
FieldP.neg(negY, pOdd[idx].y)
addMixed(alt, cur, pOdd[idx].x, negY, sc)
} else {
addMixed(alt, cur, pOdd[idx].x, pOdd[idx].y, sc)
}
t = cur
cur = alt
alt = t
}
if (addWnafMixedPP(cur, alt, negY, wnafE2, i, pLamOdd, eSplit.negK2, sc)) {
// Stream 4: e₂ (λ(P)-side)
d = wnafE2[i]
if (d != 0) {
val idx = (if (d > 0) d else -d) / 2
if ((d < 0) xor negK2e) {
FieldP.neg(negY, pLamOdd[idx].y)
addMixed(alt, cur, pLamOdd[idx].x, negY, sc)
} else {
addMixed(alt, cur, pLamOdd[idx].x, pLamOdd[idx].y, sc)
}
t = cur
cur = alt
alt = t
@@ -0,0 +1,513 @@
/*
* 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
// =====================================================================================
// FUSED FIELD MULTIPLY + REDUCE — PLATFORM-SPECIFIC INTRINSIC DISPATCH
// =====================================================================================
//
// PROBLEM:
// The original code path was: FieldP.mul → U256.mulWide → unsignedMultiplyHigh → Math.xxx
// Each field multiply called unsignedMultiplyHigh 20 times (16 in mulWide + 4 in reduceWide).
// On Android, unsignedMultiplyHigh is a wrapper with per-call API-level branching:
// if (API >= 35) Math.unsignedMultiplyHigh else if (API >= 31) Math.multiplyHigh+correction else fallback
// ART's JIT (inlining depth ~3 levels vs HotSpot's 8+) could not inline this 6-level-deep
// call chain, resulting in ~10,000 un-inlined function calls per Schnorr verify.
//
// SOLUTION:
// Fuse mulWide + reduceWide into a single `inline` function (fieldMulReduceWith) that
// accepts the multiply-high intrinsic as a `crossinline` lambda. The lambda is substituted
// at each call site by the Kotlin compiler (not by the JIT), producing platform-specific
// bytecode with the intrinsic call directly embedded — zero wrapper overhead.
//
// WHY `inline` + `crossinline` LAMBDA (not expect/actual for the whole function)?
// Writing the full 200-line mulWide+reduceWide in each platform file would require
// maintaining 3 identical copies (Android/JVM/Native) that differ only in the
// multiply-high call. The inline+crossinline pattern keeps the arithmetic in one place
// (commonMain) while the platform files provide just the intrinsic.
//
// WHY NOT a single function with all API branches?
// Tested: inlining all 3 API-level branches into one fieldMulReduce created ~600 DEX
// instructions. ART's register allocator produced suboptimal code with stack spills,
// causing verify to REGRESS. The split approach (separate private functions per API level,
// see FieldMulPlatform.android.kt) keeps each at ~200 DEX instructions.
//
// WHY NOT 5×52-bit limbs with lazy reduction (like C libsecp256k1)?
// Tested: 5×52 requires 25 multiplyHigh calls per field multiply (vs our 16 with 4×64).
// On ART where each multiplyHigh has overhead, the 56% increase in multiply calls
// overwhelmed the savings from skipping reduceSelf after add/sub. Net result: slower.
//
// MEASURED IMPACT (Pixel 8, Android 16):
// signSchnorr: 186,610 → 109,711 ns (-41%)
// verifySchnorr: 160,869 → 135,885 ns (-16%)
// Combined with uLtInline() and @JvmField: verify → 116,450 ns (-28% total)
//
// =====================================================================================
/**
* Fused field multiplication: out = (a × b) mod p.
* Platform implementations call fieldMulReduceWith with the best available intrinsic.
*/
internal expect fun fieldMulReduce(
out: LongArray,
a: LongArray,
b: LongArray,
w: LongArray,
)
/**
* Fused field squaring: out = a² mod p.
* Platform implementations call fieldSqrReduceWith with the best available intrinsic.
*/
internal expect fun fieldSqrReduce(
out: LongArray,
a: LongArray,
w: LongArray,
)
// P[0] constant for reduceSelf (duplicated here because inline functions can't
// access private members of other objects).
private const val FIELD_P0 = -4294968273L // 0xFFFFFFFEFFFFFC2F
// uLtInline is defined in U256.kt (package-wide, internal inline).
// It replaces all uLt() calls in hot-path code to avoid function dispatch.
/**
* Fused 4×4 schoolbook multiplication + mod-p reduction.
*
* Combines U256.mulWide and FieldP.reduceWide into a single inline function.
* The umulh parameter is inlined at each call site, producing platform-specific
* code with direct intrinsic calls and zero wrapper overhead.
*
* @param umulh Returns the upper 64 bits of the unsigned 128-bit product.
*/
@Suppress("LongMethod")
internal inline fun fieldMulReduceWith(
out: LongArray,
a: LongArray,
b: LongArray,
w: LongArray,
umulh: (Long, Long) -> Long,
) {
// === Stage 1: 4×4 schoolbook multiplication (16 products) ===
val a0 = a[0]
val a1 = a[1]
val a2 = a[2]
val a3 = a[3]
val b0 = b[0]
val b1 = b[1]
val b2 = b[2]
val b3 = b[3]
var lo: Long
var hi: Long
var prev: Long
var s: Long
var c1: Long
var c2: Long
var carry: Long
// Row 0: a0 × [b0,b1,b2,b3]
lo = a0 * b0
w[0] = lo
carry = umulh(a0, b0)
lo = a0 * b1
s = lo + carry
c1 = if (uLtInline(s, lo)) 1L else 0L
w[1] = s
carry = umulh(a0, b1) + c1
lo = a0 * b2
s = lo + carry
c1 = if (uLtInline(s, lo)) 1L else 0L
w[2] = s
carry = umulh(a0, b2) + c1
lo = a0 * b3
s = lo + carry
c1 = if (uLtInline(s, lo)) 1L else 0L
w[3] = s
w[4] = umulh(a0, b3) + c1
// Row 1: a1 × [b0,b1,b2,b3]
lo = a1 * b0
hi = umulh(a1, b0)
prev = w[1]
s = prev + lo
c1 = if (uLtInline(s, prev)) 1L else 0L
w[1] = s
carry = hi + c1
lo = a1 * b1
hi = umulh(a1, b1)
prev = w[2]
s = prev + lo
c1 = if (uLtInline(s, prev)) 1L else 0L
s += carry
c2 = if (uLtInline(s, carry)) 1L else 0L
w[2] = s
carry = hi + c1 + c2
lo = a1 * b2
hi = umulh(a1, b2)
prev = w[3]
s = prev + lo
c1 = if (uLtInline(s, prev)) 1L else 0L
s += carry
c2 = if (uLtInline(s, carry)) 1L else 0L
w[3] = s
carry = hi + c1 + c2
lo = a1 * b3
hi = umulh(a1, b3)
prev = w[4]
s = prev + lo
c1 = if (uLtInline(s, prev)) 1L else 0L
s += carry
c2 = if (uLtInline(s, carry)) 1L else 0L
w[4] = s
w[5] = hi + c1 + c2
// Row 2: a2 × [b0,b1,b2,b3]
lo = a2 * b0
hi = umulh(a2, b0)
prev = w[2]
s = prev + lo
c1 = if (uLtInline(s, prev)) 1L else 0L
w[2] = s
carry = hi + c1
lo = a2 * b1
hi = umulh(a2, b1)
prev = w[3]
s = prev + lo
c1 = if (uLtInline(s, prev)) 1L else 0L
s += carry
c2 = if (uLtInline(s, carry)) 1L else 0L
w[3] = s
carry = hi + c1 + c2
lo = a2 * b2
hi = umulh(a2, b2)
prev = w[4]
s = prev + lo
c1 = if (uLtInline(s, prev)) 1L else 0L
s += carry
c2 = if (uLtInline(s, carry)) 1L else 0L
w[4] = s
carry = hi + c1 + c2
lo = a2 * b3
hi = umulh(a2, b3)
prev = w[5]
s = prev + lo
c1 = if (uLtInline(s, prev)) 1L else 0L
s += carry
c2 = if (uLtInline(s, carry)) 1L else 0L
w[5] = s
w[6] = hi + c1 + c2
// Row 3: a3 × [b0,b1,b2,b3]
lo = a3 * b0
hi = umulh(a3, b0)
prev = w[3]
s = prev + lo
c1 = if (uLtInline(s, prev)) 1L else 0L
w[3] = s
carry = hi + c1
lo = a3 * b1
hi = umulh(a3, b1)
prev = w[4]
s = prev + lo
c1 = if (uLtInline(s, prev)) 1L else 0L
s += carry
c2 = if (uLtInline(s, carry)) 1L else 0L
w[4] = s
carry = hi + c1 + c2
lo = a3 * b2
hi = umulh(a3, b2)
prev = w[5]
s = prev + lo
c1 = if (uLtInline(s, prev)) 1L else 0L
s += carry
c2 = if (uLtInline(s, carry)) 1L else 0L
w[5] = s
carry = hi + c1 + c2
lo = a3 * b3
hi = umulh(a3, b3)
prev = w[6]
s = prev + lo
c1 = if (uLtInline(s, prev)) 1L else 0L
s += carry
c2 = if (uLtInline(s, carry)) 1L else 0L
w[6] = s
w[7] = hi + c1 + c2
// === Stage 2: mod-p reduction (2^256 ≡ 2^32 + 977 mod p) ===
reduceWideInline(out, w, umulh)
}
/**
* Fused squaring + mod-p reduction (10 products via symmetry).
*
* @param umulh Returns the upper 64 bits of the unsigned 128-bit product.
*/
@Suppress("LongMethod")
internal inline fun fieldSqrReduceWith(
out: LongArray,
a: LongArray,
w: LongArray,
umulh: (Long, Long) -> Long,
) {
val a0 = a[0]
val a1 = a[1]
val a2 = a[2]
val a3 = a[3]
var lo: Long
var hi: Long
var prev: Long
var s: Long
var c1: Long
var c2: Long
var carry: Long
var v: Long
// Pass 1: cross-products a[i]*a[j] for i < j
w[0] = 0L
lo = a0 * a1
w[1] = lo
carry = umulh(a0, a1)
lo = a0 * a2
s = lo + carry
c1 = if (uLtInline(s, lo)) 1L else 0L
w[2] = s
carry = umulh(a0, a2) + c1
lo = a0 * a3
s = lo + carry
c1 = if (uLtInline(s, lo)) 1L else 0L
w[3] = s
w[4] = umulh(a0, a3) + c1
lo = a1 * a2
hi = umulh(a1, a2)
prev = w[3]
s = prev + lo
c1 = if (uLtInline(s, prev)) 1L else 0L
w[3] = s
carry = hi + c1
lo = a1 * a3
hi = umulh(a1, a3)
prev = w[4]
s = prev + lo
c1 = if (uLtInline(s, prev)) 1L else 0L
s += carry
c2 = if (uLtInline(s, carry)) 1L else 0L
w[4] = s
w[5] = hi + c1 + c2
lo = a2 * a3
hi = umulh(a2, a3)
prev = w[5]
s = prev + lo
c1 = if (uLtInline(s, prev)) 1L else 0L
w[5] = s
w[6] = hi + c1
// Pass 2: double all cross-products (shift left by 1 bit)
v = w[1]
w[1] = v shl 1
var shiftCarry = v ushr 63
v = w[2]
w[2] = (v shl 1) or shiftCarry
shiftCarry = v ushr 63
v = w[3]
w[3] = (v shl 1) or shiftCarry
shiftCarry = v ushr 63
v = w[4]
w[4] = (v shl 1) or shiftCarry
shiftCarry = v ushr 63
v = w[5]
w[5] = (v shl 1) or shiftCarry
shiftCarry = v ushr 63
v = w[6]
w[6] = (v shl 1) or shiftCarry
shiftCarry = v ushr 63
w[7] = shiftCarry
// Pass 3: add diagonal products a[i]²
lo = a0 * a0
hi = umulh(a0, a0)
w[0] = lo
s = w[1] + hi
c1 = if (uLtInline(s, w[1])) 1L else 0L
w[1] = s
var dCarry = c1
lo = a1 * a1
hi = umulh(a1, a1)
s = w[2] + lo
c1 = if (uLtInline(s, w[2])) 1L else 0L
s += dCarry
c2 = if (uLtInline(s, dCarry)) 1L else 0L
w[2] = s
prev = w[3] + hi
val c3a = if (uLtInline(prev, w[3])) 1L else 0L
prev += c1 + c2
val c4a = if (uLtInline(prev, c1 + c2)) 1L else 0L
w[3] = prev
dCarry = c3a + c4a
lo = a2 * a2
hi = umulh(a2, a2)
s = w[4] + lo
c1 = if (uLtInline(s, w[4])) 1L else 0L
s += dCarry
c2 = if (uLtInline(s, dCarry)) 1L else 0L
w[4] = s
prev = w[5] + hi
val c3b = if (uLtInline(prev, w[5])) 1L else 0L
prev += c1 + c2
val c4b = if (uLtInline(prev, c1 + c2)) 1L else 0L
w[5] = prev
dCarry = c3b + c4b
lo = a3 * a3
hi = umulh(a3, a3)
s = w[6] + lo
c1 = if (uLtInline(s, w[6])) 1L else 0L
s += dCarry
c2 = if (uLtInline(s, dCarry)) 1L else 0L
w[6] = s
prev = w[7] + hi
prev += c1 + c2
w[7] = prev
// === Reduction ===
reduceWideInline(out, w, umulh)
}
/**
* Inline mod-p reduction of 512-bit value. Shared by fieldMulReduceWith and fieldSqrReduceWith.
*
* Uses 2^256 ≡ 2^32 + 977 (mod p) to fold high limbs into low limbs.
*/
@Suppress("LongMethod")
internal inline fun reduceWideInline(
out: LongArray,
w: LongArray,
umulh: (Long, Long) -> Long,
) {
val c = 4294968273L // 2^32 + 977
var hcLo: Long
var hcHi: Long
var s1: Long
var s2: Long
var c1: Long
var c2: Long
// Round 1: acc = lo + hi × C
hcLo = w[4] * c
hcHi = umulh(w[4], c)
s1 = w[0] + hcLo
c1 = if (uLtInline(s1, w[0])) 1L else 0L
out[0] = s1
var carry = hcHi + c1
hcLo = w[5] * c
hcHi = umulh(w[5], c)
s1 = w[1] + hcLo
c1 = if (uLtInline(s1, w[1])) 1L else 0L
s2 = s1 + carry
c2 = if (uLtInline(s2, s1)) 1L else 0L
out[1] = s2
carry = hcHi + c1 + c2
hcLo = w[6] * c
hcHi = umulh(w[6], c)
s1 = w[2] + hcLo
c1 = if (uLtInline(s1, w[2])) 1L else 0L
s2 = s1 + carry
c2 = if (uLtInline(s2, s1)) 1L else 0L
out[2] = s2
carry = hcHi + c1 + c2
hcLo = w[7] * c
hcHi = umulh(w[7], c)
s1 = w[3] + hcLo
c1 = if (uLtInline(s1, w[3])) 1L else 0L
s2 = s1 + carry
c2 = if (uLtInline(s2, s1)) 1L else 0L
out[3] = s2
carry = hcHi + c1 + c2
// Round 2: fold carry × C
if (carry != 0L) {
val ccLo = carry * c
val ccHi = umulh(carry, c)
s1 = out[0] + ccLo
c1 = if (uLtInline(s1, out[0])) 1L else 0L
out[0] = s1
var prop = ccHi + c1
if (prop != 0L) {
s1 = out[1] + prop
prop = if (uLtInline(s1, out[1])) 1L else 0L
out[1] = s1
if (prop != 0L) {
s1 = out[2] + prop
prop = if (uLtInline(s1, out[2])) 1L else 0L
out[2] = s1
if (prop != 0L) {
s1 = out[3] + prop
prop = if (uLtInline(s1, out[3])) 1L else 0L
out[3] = s1
}
}
}
if (prop != 0L) {
s1 = out[0] + c
c1 = if (uLtInline(s1, out[0])) 1L else 0L
out[0] = s1
if (c1 != 0L) {
out[1]++
if (out[1] == 0L) {
out[2]++
if (out[2] == 0L) out[3]++
}
}
}
}
// Final normalization: ensure out < p
if (out[3] == -1L && out[2] == -1L && out[1] == -1L &&
(out[0] xor Long.MIN_VALUE) >= (FIELD_P0 xor Long.MIN_VALUE)
) {
out[0] -= FIELD_P0
out[1] = 0L
out[2] = 0L
out[3] = 0L
}
}
@@ -68,7 +68,7 @@ internal object FieldP {
if (carry != 0) {
// Overflow past 2^256: add 2^256 mod p = 2^32 + 977 = 0x1000003D1
val s1 = out[0] + 4294968273L
val c1 = if (s1.toULong() < out[0].toULong()) 1L else 0L
val c1 = if (uLt(s1, out[0])) 1L else 0L
out[0] = s1
if (c1 != 0L) {
out[1]++
@@ -95,7 +95,7 @@ internal object FieldP {
if (borrow != 0) {
// Add P = [P0, -1, -1, -1].
val s0 = out[0] + P0
val c0 = if (s0.toULong() < out[0].toULong()) 1L else 0L
val c0 = if (uLt(s0, out[0])) 1L else 0L
out[0] = s0
// For limbs 1-3: adding P[i]=-1 with carry c:
// c=1 → result unchanged, carry out=1 (identity propagation)
@@ -124,8 +124,7 @@ internal object FieldP {
b: LongArray,
) {
val w = wide.get()
U256.mulWide(w, a, b)
reduceWide(out, w)
fieldMulReduce(out, a, b, w)
}
/** Multiply with caller-provided wide buffer (hot path — no ThreadLocal lookup). */
@@ -135,8 +134,7 @@ internal object FieldP {
b: LongArray,
w: LongArray,
) {
U256.mulWide(w, a, b)
reduceWide(out, w)
fieldMulReduce(out, a, b, w)
}
/** Square with ThreadLocal wide buffer (convenience for non-hot paths). */
@@ -145,8 +143,7 @@ internal object FieldP {
a: LongArray,
) {
val w = wide.get()
U256.sqrWide(w, a)
reduceWide(out, w)
fieldSqrReduce(out, a, w)
}
/** Square with caller-provided wide buffer (hot path — no ThreadLocal lookup). */
@@ -155,8 +152,7 @@ internal object FieldP {
a: LongArray,
w: LongArray,
) {
U256.sqrWide(w, a)
reduceWide(out, w)
fieldSqrReduce(out, a, w)
}
/**
@@ -177,7 +173,7 @@ internal object FieldP {
}
// P - a: limb 0 is P0 - a[0], limbs 1-3 are (-1) - a[i] = ~a[i]
out[0] = P0 - a[0]
val borrow = if (a[0].toULong() > P0.toULong()) 1L else 0L
val borrow = if (uLt(P0, a[0])) 1L else 0L
// ~a[i] - borrow. New borrow only if ~a[i] == 0 (i.e., a[i] == -1) and borrow == 1
out[1] = a[1].inv() - borrow
val b1 = if (a[1] == -1L && borrow != 0L) 1L else 0L
@@ -204,28 +200,28 @@ internal object FieldP {
// Conditional add: out = a + (P & mask), unrolled
// Limb 0
s1 = a[0] + p0
c1 = if (s1.toULong() < a[0].toULong()) 1L else 0L
c1 = if (uLt(s1, a[0])) 1L else 0L
out[0] = s1
var carry = c1
// Limb 1
s1 = a[1] + mask
c1 = if (s1.toULong() < a[1].toULong()) 1L else 0L
c1 = if (uLt(s1, a[1])) 1L else 0L
s2 = s1 + carry
c2 = if (s2.toULong() < s1.toULong()) 1L else 0L
c2 = if (uLt(s2, s1)) 1L else 0L
out[1] = s2
carry = c1 + c2
// Limb 2
s1 = a[2] + mask
c1 = if (s1.toULong() < a[2].toULong()) 1L else 0L
c1 = if (uLt(s1, a[2])) 1L else 0L
s2 = s1 + carry
c2 = if (s2.toULong() < s1.toULong()) 1L else 0L
c2 = if (uLt(s2, s1)) 1L else 0L
out[2] = s2
carry = c1 + c2
// Limb 3
s1 = a[3] + mask
c1 = if (s1.toULong() < a[3].toULong()) 1L else 0L
c1 = if (uLt(s1, a[3])) 1L else 0L
s2 = s1 + carry
c2 = if (s2.toULong() < s1.toULong()) 1L else 0L
c2 = if (uLt(s2, s1)) 1L else 0L
out[3] = s2
carry = c1 + c2
@@ -409,7 +405,7 @@ internal object FieldP {
hcLo = w[4] * c
hcHi = unsignedMultiplyHigh(w[4], c)
s1 = w[0] + hcLo
c1 = if (s1.toULong() < w[0].toULong()) 1L else 0L
c1 = if (uLt(s1, w[0])) 1L else 0L
out[0] = s1
var carry = hcHi + c1
@@ -417,9 +413,9 @@ internal object FieldP {
hcLo = w[5] * c
hcHi = unsignedMultiplyHigh(w[5], c)
s1 = w[1] + hcLo
c1 = if (s1.toULong() < w[1].toULong()) 1L else 0L
c1 = if (uLt(s1, w[1])) 1L else 0L
s2 = s1 + carry
c2 = if (s2.toULong() < s1.toULong()) 1L else 0L
c2 = if (uLt(s2, s1)) 1L else 0L
out[1] = s2
carry = hcHi + c1 + c2
@@ -427,9 +423,9 @@ internal object FieldP {
hcLo = w[6] * c
hcHi = unsignedMultiplyHigh(w[6], c)
s1 = w[2] + hcLo
c1 = if (s1.toULong() < w[2].toULong()) 1L else 0L
c1 = if (uLt(s1, w[2])) 1L else 0L
s2 = s1 + carry
c2 = if (s2.toULong() < s1.toULong()) 1L else 0L
c2 = if (uLt(s2, s1)) 1L else 0L
out[2] = s2
carry = hcHi + c1 + c2
@@ -437,9 +433,9 @@ internal object FieldP {
hcLo = w[7] * c
hcHi = unsignedMultiplyHigh(w[7], c)
s1 = w[3] + hcLo
c1 = if (s1.toULong() < w[3].toULong()) 1L else 0L
c1 = if (uLt(s1, w[3])) 1L else 0L
s2 = s1 + carry
c2 = if (s2.toULong() < s1.toULong()) 1L else 0L
c2 = if (uLt(s2, s1)) 1L else 0L
out[3] = s2
carry = hcHi + c1 + c2
@@ -448,21 +444,21 @@ internal object FieldP {
val ccLo = carry * c
val ccHi = unsignedMultiplyHigh(carry, c)
s1 = out[0] + ccLo
c1 = if (s1.toULong() < out[0].toULong()) 1L else 0L
c1 = if (uLt(s1, out[0])) 1L else 0L
out[0] = s1
// Propagate carry (unrolled, with early exit)
var prop = ccHi + c1
if (prop != 0L) {
s1 = out[1] + prop
prop = if (s1.toULong() < out[1].toULong()) 1L else 0L
prop = if (uLt(s1, out[1])) 1L else 0L
out[1] = s1
if (prop != 0L) {
s1 = out[2] + prop
prop = if (s1.toULong() < out[2].toULong()) 1L else 0L
prop = if (uLt(s1, out[2])) 1L else 0L
out[2] = s1
if (prop != 0L) {
s1 = out[3] + prop
prop = if (s1.toULong() < out[3].toULong()) 1L else 0L
prop = if (uLt(s1, out[3])) 1L else 0L
out[3] = s1
}
}
@@ -470,7 +466,7 @@ internal object FieldP {
// Overflow past 256 bits: 2^256 ≡ C (mod p)
if (prop != 0L) {
s1 = out[0] + c
c1 = if (s1.toULong() < out[0].toULong()) 1L else 0L
c1 = if (uLt(s1, out[0])) 1L else 0L
out[0] = s1
if (c1 != 0L) {
out[1]++
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.quartz.utils.secp256k1
import kotlin.jvm.JvmField
// =====================================================================================
// GLV ENDOMORPHISM AND WNAF ENCODING FOR secp256k1
// =====================================================================================
@@ -39,6 +41,7 @@ package com.vitorpamplona.quartz.utils.secp256k1
internal object Glv {
/** β: cube root of unity mod p. φ(x,y) = (β·x, y). */
@JvmField
val BETA =
longArrayOf(
-4523465429756870162L,
@@ -50,10 +53,10 @@ internal object Glv {
// ==================== GLV Scalar Decomposition ====================
class Split(
val k1: LongArray,
val k2: LongArray,
val negK1: Boolean,
val negK2: Boolean,
@JvmField val k1: LongArray,
@JvmField val k2: LongArray,
@JvmField val negK1: Boolean,
@JvmField val negK2: Boolean,
)
fun splitScalar(k: LongArray): Split {
@@ -224,7 +227,7 @@ internal object Glv {
for (i in limb until s.size) {
val old = s[i]
s[i] = old + if (i == limb) addVal else 1L
if (s[i].toULong() >= old.toULong() || (i == limb && addVal == 0L)) break
if (!uLt(s[i], old) || (i == limb && addVal == 0L)) break
// overflowed — carry to next limb
}
}
@@ -52,8 +52,14 @@ internal expect fun unsignedMultiplyHigh(
* if b < 0) and the unsigned correction terms (+ (a & (b >> 63)) + (b & (a >> 63))).
* Saves ~8 instructions per call on Android < API 31, where this is the hot path
* (~30,000 calls per signature verify).
*
* MUST be inline: this function is called ~32,000 times per verify via the fused
* fieldMulReduce crossinline lambda. Without inline, each call is a real function
* dispatch (~82ns on ART). With inline, the Kotlin compiler embeds the arithmetic
* directly at each call site — zero dispatch overhead.
*/
internal fun unsignedMultiplyHighFallback(
@Suppress("NOTHING_TO_INLINE")
internal inline fun unsignedMultiplyHighFallback(
a: Long,
b: Long,
): Long {
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.quartz.utils.secp256k1
import kotlin.jvm.JvmField
/**
* Mutable Jacobian point for in-place computation.
*
@@ -32,11 +34,17 @@ package com.vitorpamplona.quartz.utils.secp256k1
*
* Mutable to avoid allocating new objects during the inner loop of scalar
* multiplication, which performs thousands of doublings and additions per operation.
*
* @JvmField is REQUIRED on x/y/z. Without it, Kotlin generates getX()/getY()/getZ()
* virtual getter methods. Bytecode analysis showed ~7,450 invokevirtual getter calls
* per Schnorr verify (130 doublePoints × 13 getXYZ each + 160 addMixed × 23 each).
* @JvmField compiles property access to direct field reads (getfield bytecode), which
* is ~3-4ns faster per access on ART. On non-JVM targets, @JvmField is ignored.
*/
internal class MutablePoint(
val x: LongArray = LongArray(4),
val y: LongArray = LongArray(4),
val z: LongArray = LongArray(4),
@JvmField val x: LongArray = LongArray(4),
@JvmField val y: LongArray = LongArray(4),
@JvmField val z: LongArray = LongArray(4),
) {
fun isInfinity(): Boolean = U256.isZero(z)
@@ -69,10 +77,11 @@ internal class MutablePoint(
/**
* Affine point (x, y) — no Z coordinate.
* Used for precomputed tables where we want compact storage and mixed addition.
* @JvmField: see MutablePoint for rationale (eliminates virtual getter calls).
*/
internal class AffinePoint(
val x: LongArray = LongArray(4),
val y: LongArray = LongArray(4),
@JvmField val x: LongArray = LongArray(4),
@JvmField val y: LongArray = LongArray(4),
)
/**
@@ -88,55 +97,96 @@ internal class AffinePoint(
* 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).
*
* @JvmField on ALL properties: without it, each property access compiles to an
* invokevirtual getter call. Bytecode analysis showed ~2,000+ getter calls per
* verify from ECPoint accessing scratch.t, scratch.w, scratch.dblCopy, etc.
* @JvmField compiles these to direct field reads. On non-JVM targets, ignored.
*/
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
@JvmField val t = Array(12) { LongArray(4) }
// 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)
@JvmField val dblCopy = MutablePoint()
// Pre-allocated scratch for wNAF mixed addition
val mixTmp = MutablePoint()
val mixNegY = LongArray(4)
@JvmField val w = LongArray(8)
// 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
@JvmField val wnaf1 = IntArray(145)
// 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)
@JvmField val wnaf2 = IntArray(145)
// 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
@JvmField val wnaf3 = IntArray(145)
// Pre-allocated scratch for toAffine / toAffineX (avoids 3 LongArray allocs per call)
val zInv = LongArray(4)
val zInv2 = LongArray(4)
val zInv3 = LongArray(4)
@JvmField val wnaf4 = IntArray(145)
// 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
@JvmField val wnafTmp = LongArray(4)
@JvmField val mixTmp = MutablePoint()
@JvmField val mixNegY = LongArray(4)
@JvmField val pOddJac = Array(8) { MutablePoint() }
@JvmField val pLamOddJac = Array(8) { MutablePoint() }
@JvmField val pOddAff = Array(8) { AffinePoint() }
@JvmField val pLamOddAff = Array(8) { AffinePoint() }
@JvmField val p2 = MutablePoint()
@JvmField val cumZ = Array(8) { LongArray(4) }
@JvmField val batchInv = LongArray(4)
@JvmField val batchZInv = LongArray(4)
@JvmField val batchZInv2 = LongArray(4)
@JvmField val batchZInv3 = LongArray(4)
@JvmField val splitWide = LongArray(8)
@JvmField val splitT1 = LongArray(4)
@JvmField val splitT2 = LongArray(4)
@JvmField val splitK1 = LongArray(4)
@JvmField val splitK2 = LongArray(4)
@JvmField val zInv = LongArray(4)
@JvmField val zInv2 = LongArray(4)
@JvmField val zInv3 = LongArray(4)
@JvmField val entryPx = LongArray(4)
@JvmField val entryPy = LongArray(4)
@JvmField val entryPoint = MutablePoint()
@JvmField val entryResult = MutablePoint()
@JvmField val entryTmp = LongArray(4)
@JvmField val entryTmp2 = LongArray(4)
// Pre-allocated byte buffers for sign/verify (eliminates ByteArray allocations).
// hashBuf: reusable buffer for BIP-340 tagged hash inputs (prefix(64) + fields).
// The max size is 64 + 32 + 32 + msgLen. For 32-byte messages (event IDs), that's 160.
// For larger messages, signSchnorrInternal must still allocate.
@JvmField val hashBuf = ByteArray(256)
// 32-byte scratch for serialized field elements / scalars (avoids U256.toBytes allocs)
@JvmField val bytesTmp1 = ByteArray(32)
@JvmField val bytesTmp2 = ByteArray(32)
// Scratch LongArray(4) for intermediate scalar results (avoids ScalarN alloc)
@JvmField val scalarTmp1 = LongArray(4)
@JvmField val scalarTmp2 = LongArray(4)
@JvmField val scalarTmp3 = LongArray(4)
}
@@ -63,6 +63,18 @@ internal object ScalarN {
a
}
/** Allocation-free reduce: out = a mod n. Safe for out === a. */
fun reduceTo(
out: LongArray,
a: LongArray,
) {
if (U256.cmp(a, N) >= 0) {
U256.subTo(out, a, N)
} else if (out !== a) {
U256.copyInto(out, a)
}
}
fun add(
a: LongArray,
b: LongArray,
@@ -198,9 +210,9 @@ internal object ScalarN {
0L
}
val s1 = hiTimesNC[i] + loVal
val c1 = if (s1.toULong() < hiTimesNC[i].toULong()) 1L else 0L
val c1 = if (uLt(s1, hiTimesNC[i])) 1L else 0L
val s2 = s1 + carry
val c2 = if (s2.toULong() < s1.toULong()) 1L else 0L
val c2 = if (uLt(s2, s1)) 1L else 0L
w[i] = s2
carry = c1 + c2
}
@@ -238,9 +250,9 @@ internal object ScalarN {
saved3
}
val s1 = loVal + hi2NC[i]
val c1 = if (s1.toULong() < loVal.toULong()) 1L else 0L
val c1 = if (uLt(s1, loVal)) 1L else 0L
val s2 = s1 + c2
val cc = if (s2.toULong() < s1.toULong()) 1L else 0L
val cc = if (uLt(s2, s1)) 1L else 0L
out[i] = s2
c2 = c1 + cc
}
@@ -252,13 +264,13 @@ internal object ScalarN {
val c1lo = ov * N_COMPLEMENT[1]
val c1hi = unsignedMultiplyHigh(ov, N_COMPLEMENT[1])
val s0 = out[0] + c0lo
val carry0 = if (s0.toULong() < out[0].toULong()) 1L else 0L
val carry0 = if (uLt(s0, out[0])) 1L else 0L
out[0] = s0
val s1 = out[1] + c0hi + c1lo + carry0
val carry1 = if (s1.toULong() < out[1].toULong()) 1L else 0L
val carry1 = if (uLt(s1, out[1])) 1L else 0L
out[1] = s1
val s2 = out[2] + c1hi + ov + carry1
val carry2 = if (s2.toULong() < out[2].toULong()) 1L else 0L
val carry2 = if (uLt(s2, out[2])) 1L else 0L
out[2] = s2
out[3] += carry2
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.quartz.utils.secp256k1
import com.vitorpamplona.quartz.utils.sha256.sha256
import com.vitorpamplona.quartz.utils.sha256.sha256Into
/**
* Pure Kotlin implementation of secp256k1 elliptic curve operations for Nostr.
@@ -241,6 +242,7 @@ object Secp256k1 {
auxrand: ByteArray?,
): ByteArray {
require(seckey.size == 32)
// Allocate d0 separately — signSchnorrInternal uses all scalar scratch buffers.
val d0 = U256.fromBytes(seckey)
require(ScalarN.isValid(d0))
@@ -249,6 +251,8 @@ object Secp256k1 {
ECPoint.mulG(sc.entryResult, d0)
check(ECPoint.toAffine(sc.entryResult, sc.entryPx, sc.entryPy, sc))
// Allocate xOnlyPub — signSchnorrInternal reuses bytesTmp1/2 internally.
// This 32-byte allocation is negligible next to the mulG cost (~100μs).
val xOnlyPub = U256.toBytes(sc.entryPx)
return signSchnorrInternal(data, d0, xOnlyPub, KeyCodec.hasEvenY(sc.entryPy), auxrand)
}
@@ -282,6 +286,28 @@ object Secp256k1 {
return signSchnorrInternal(data, d0, xOnlyPub, hasEvenY, auxrand)
}
/**
* Fast signing with a pre-computed x-only public key (32 bytes).
*
* BIP-340 public keys always have even y, so the y-parity is known (even = true).
* This avoids both the expensive G multiplication to derive the pubkey AND the
* 33→32 byte array copy that signSchnorrWithPubKey does internally.
*
* Use when the caller already has the 32-byte x-only pubkey (e.g., from KeyPair.pubKey).
*/
fun signSchnorrWithXOnlyPubKey(
data: ByteArray,
seckey: ByteArray,
xOnlyPub: ByteArray,
auxrand: ByteArray?,
): ByteArray {
require(seckey.size == 32 && xOnlyPub.size == 32)
val d0 = U256.fromBytes(seckey)
require(ScalarN.isValid(d0))
// BIP-340: x-only pubkeys always have even y
return signSchnorrInternal(data, d0, xOnlyPub, true, auxrand)
}
/**
* Internal signing implementation shared by both public overloads.
* Performs: nonce derivation → R = k·G → challenge → s = k + e·d.
@@ -295,34 +321,46 @@ object Secp256k1 {
auxrand: ByteArray?,
): ByteArray {
val sc = ECPoint.getScratch()
val tmp = sc.entryTmp
val d =
if (pubKeyHasEvenY) {
d0
} else {
ScalarN.negTo(tmp, d0)
tmp
ScalarN.negTo(sc.entryTmp, d0)
sc.entryTmp
}
val dBytes = U256.toBytes(d)
// Serialize d into scratch byte buffer (avoids U256.toBytes allocation)
val dBytes = sc.bytesTmp1
U256.toBytesInto(d, dBytes, 0)
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)
// Build AUX_PREFIX + auxrand in scratch hashBuf (avoids concatenation alloc)
AUX_PREFIX.copyInto(sc.hashBuf, 0)
auxrand.copyInto(sc.hashBuf, 64)
sha256Into(sc.bytesTmp2, sc.hashBuf, 96)
// XOR d with auxHash — reuse limb scratch
U256.fromBytesInto(sc.scalarTmp1, dBytes, 0)
U256.fromBytesInto(sc.scalarTmp2, sc.bytesTmp2, 0)
U256.xorTo(sc.scalarTmp3, sc.scalarTmp1, sc.scalarTmp2)
tBytes = sc.bytesTmp2 // reuse bytesTmp2 for tBytes
U256.toBytesInto(sc.scalarTmp3, tBytes, 0)
} else {
tBytes = dBytes
}
val nonceInput = ByteArray(64 + 32 + 32 + data.size)
// Build nonce input. Reuse hashBuf if it fits.
val nonceLen = 64 + 32 + 32 + data.size
val nonceInput = if (nonceLen <= sc.hashBuf.size) sc.hashBuf else ByteArray(nonceLen)
NONCE_PREFIX.copyInto(nonceInput, 0)
tBytes.copyInto(nonceInput, 64)
tBytes.copyInto(nonceInput, 64, 0, 32)
pBytes.copyInto(nonceInput, 96)
data.copyInto(nonceInput, 128)
val rand = sha256(nonceInput)
val k0 = ScalarN.reduce(U256.fromBytes(rand))
sha256Into(sc.bytesTmp2, nonceInput, nonceLen) // rand → bytesTmp2
U256.fromBytesInto(sc.scalarTmp1, sc.bytesTmp2, 0)
ScalarN.reduceTo(sc.scalarTmp1, sc.scalarTmp1)
val k0 = sc.scalarTmp1
require(!U256.isZero(k0))
// R = k0·G
@@ -331,22 +369,35 @@ object Secp256k1 {
val ry = sc.entryPy
check(ECPoint.toAffine(sc.entryResult, rx, ry, sc))
val k = if (KeyCodec.hasEvenY(ry)) k0 else ScalarN.neg(k0)
val k =
if (KeyCodec.hasEvenY(ry)) {
k0
} else {
ScalarN.negTo(sc.scalarTmp2, k0)
sc.scalarTmp2
}
// Challenge: e = H(R || P || msg)
val chalInput = ByteArray(64 + 32 + 32 + data.size)
// Challenge: e = H(R || P || msg) — reuse hashBuf
val chalLen = 64 + 32 + 32 + data.size
val chalInput = if (chalLen <= sc.hashBuf.size) sc.hashBuf else ByteArray(chalLen)
CHALLENGE_PREFIX.copyInto(chalInput, 0)
U256.toBytesInto(rx, chalInput, 64)
pBytes.copyInto(chalInput, 96)
data.copyInto(chalInput, 128)
val eHash = sha256(chalInput)
val e = ScalarN.reduce(U256.fromBytes(eHash))
sha256Into(sc.bytesTmp1, chalInput, chalLen) // eHash → bytesTmp1
U256.fromBytesInto(sc.scalarTmp3, sc.bytesTmp1, 0)
ScalarN.reduceTo(sc.scalarTmp3, sc.scalarTmp3)
val e = sc.scalarTmp3
// s = k + e·d mod n
val sScalar = ScalarN.add(k, ScalarN.mul(e, d))
// Note: d may alias sc.entryTmp (when !pubKeyHasEvenY), so use splitK1 for mulTo output.
ScalarN.mulTo(sc.splitK1, e, d, sc.splitWide) // e·d → splitK1
ScalarN.addTo(sc.entryTmp2, k, sc.splitK1) // k + e·d → entryTmp2
// Build output signature (the only required allocation)
val sig = ByteArray(64)
U256.toBytesInto(rx, sig, 0)
U256.toBytesInto(sScalar, sig, 32)
U256.toBytesInto(sc.entryTmp2, sig, 32)
return sig
}
@@ -384,27 +435,104 @@ object Secp256k1 {
U256.fromBytesInto(s, signature, 32)
if (U256.cmp(s, ScalarN.N) >= 0) return false
// Build challenge hash input in a single array: prefix(64) + r(32) + pub(32) + data(N)
val hashInput = ByteArray(64 + 32 + 32 + data.size)
// Build challenge hash input. Reuse scratch byte buffer if message fits,
// otherwise allocate (rare for Nostr: event IDs are 32 bytes → total 160).
val hashLen = 64 + 32 + 32 + data.size
val hashInput = if (hashLen <= sc.hashBuf.size) sc.hashBuf else ByteArray(hashLen)
CHALLENGE_PREFIX.copyInto(hashInput, 0)
signature.copyInto(hashInput, 64, 0, 32) // r bytes from signature
pub.copyInto(hashInput, 96)
data.copyInto(hashInput, 128)
val eHash = sha256(hashInput)
// Reuse entryPx for e (liftX result already copied into pPoint below)
val e = sc.zInv // safe: zInv not used until toAffine after mulDoubleG
val eHash = sha256Into(sc.bytesTmp1, hashInput, hashLen)
// Reuse zInv for e (safe: zInv not used until toAffine, which we skip here)
val e = sc.zInv
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
// Q = s·G + (-e)·P via Shamir's trick
ScalarN.negTo(e, e) // negate in-place
sc.entryPoint.setAffine(sc.entryPx, sc.entryPy) // copies px/py, so entryPx is free
ECPoint.mulDoubleG(sc.entryResult, s, sc.entryPoint, e)
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
// Check x-coordinate in Jacobian FIRST (2 field ops, no inversion): X/Z² == r → X == r·Z².
val w = sc.w
FieldP.sqr(sc.zInv2, sc.entryResult.z, w) // Z²
FieldP.mul(sc.zInv3, r, sc.zInv2, w) // r·Z²
if (U256.cmp(sc.entryResult.x, sc.zInv3) != 0) return false // x mismatch → reject fast
// x matches — check y-parity (requires inversion, ~270 field ops)
FieldP.inv(sc.zInv, sc.entryResult.z)
FieldP.sqr(sc.zInv2, sc.zInv)
FieldP.mul(sc.zInv3, sc.zInv2, sc.zInv)
FieldP.mul(sc.entryPy, sc.entryResult.y, sc.zInv3)
return KeyCodec.hasEvenY(sc.entryPy)
}
/**
* Fast Nostr event signature verification — skips the BIP-340 y-parity check.
*
* BIP-340 requires R.y to be even. The full [verifySchnorr] enforces this with a
* field inversion (~270 field ops, ~14% of verify cost). This variant skips that
* check, verifying only that R.x matches the signature's r value.
*
* WHY THIS IS SAFE FOR NOSTR:
* For a given x-coordinate on secp256k1, there are exactly two curve points:
* (x, y_even) and (x, y_odd). A signature that produces the correct x but wrong
* y-parity would require solving the discrete log problem — computationally
* equivalent to forging the signature entirely. The y-parity check is
* defense-in-depth, not a distinct security boundary.
*
* DO NOT use this for Bitcoin transaction validation or any financial protocol
* where strict BIP-340 compliance is required.
*
* @param signature 64-byte signature (R.x || s)
* @param data Message bytes (any length)
* @param pub 32-byte x-only public key
*/
fun verifySchnorrFast(
signature: ByteArray,
data: ByteArray,
pub: ByteArray,
): Boolean {
if (signature.size != 64 || pub.size != 32) return false
val sc = ECPoint.getScratch()
if (!liftXCached(sc.entryPx, sc.entryPy, pub)) return false
val r = sc.entryTmp
U256.fromBytesInto(r, signature, 0)
if (U256.cmp(r, FieldP.P) >= 0) return false
val s = sc.entryTmp2
U256.fromBytesInto(s, signature, 32)
if (U256.cmp(s, ScalarN.N) >= 0) return false
// Build challenge hash
val hashLen = 64 + 32 + 32 + data.size
val hashInput = if (hashLen <= sc.hashBuf.size) sc.hashBuf else ByteArray(hashLen)
CHALLENGE_PREFIX.copyInto(hashInput, 0)
signature.copyInto(hashInput, 64, 0, 32)
pub.copyInto(hashInput, 96)
data.copyInto(hashInput, 128)
val eHash = sha256Into(sc.bytesTmp1, hashInput, hashLen)
val e = sc.zInv
U256.fromBytesInto(e, eHash, 0)
if (U256.cmp(e, ScalarN.N) >= 0) U256.subTo(e, e, ScalarN.N)
// Q = s·G + (-e)·P
ScalarN.negTo(e, e)
sc.entryPoint.setAffine(sc.entryPx, sc.entryPy)
ECPoint.mulDoubleG(sc.entryResult, s, sc.entryPoint, e)
if (sc.entryResult.isInfinity()) return false
// Jacobian x-check only — no inversion, no y-parity check.
// Saves ~270 field ops (~14% of verify).
val w = sc.w
FieldP.sqr(sc.zInv2, sc.entryResult.z, w)
FieldP.mul(sc.zInv3, r, sc.zInv2, w)
return U256.cmp(sc.entryResult.x, sc.zInv3) == 0
}
// ==================== Tweak operations ====================
@@ -544,36 +672,43 @@ object Secp256k1 {
rSum.setInfinity()
val rTmp = sc.entryResult // reuse as temp for addMixed
// Pre-allocated scratch for the per-signature loop (eliminates ~7 allocs per sig)
val r = sc.scalarTmp1 // reuse for r parsing
val s = sc.scalarTmp2 // reuse for s parsing
val e = sc.scalarTmp3 // reuse for e scalar
val rx = sc.entryTmp // reuse for liftX output x
val ry = sc.entryTmp2 // reuse for liftX output y
for (i in 0 until n) {
val sig = signatures[i]
val msg = messages[i]
if (sig.size != 64) return false
// Parse r, s from signature
val r = U256.fromBytes(sig, 0)
// Parse r, s from signature into scratch
U256.fromBytesInto(r, sig, 0)
if (U256.cmp(r, FieldP.P) >= 0) return false
val s = U256.fromBytes(sig, 32)
U256.fromBytesInto(s, sig, 32)
if (U256.cmp(s, ScalarN.N) >= 0) return false
// Accumulate s: sSum += sᵢ mod n
ScalarN.addTo(sSum, sSum, s)
// Compute challenge eᵢ = H(rᵢ || pub || msgᵢ)
val hashInput = ByteArray(64 + 32 + 32 + msg.size)
// Compute challenge eᵢ = H(rᵢ || pub || msgᵢ) using scratch buffers
val hashLen = 64 + 32 + 32 + msg.size
val hashInput = if (hashLen <= sc.hashBuf.size) sc.hashBuf else ByteArray(hashLen)
CHALLENGE_PREFIX.copyInto(hashInput, 0)
sig.copyInto(hashInput, 64, 0, 32)
pub.copyInto(hashInput, 96)
msg.copyInto(hashInput, 128)
val eHash = sha256(hashInput)
val e = ScalarN.reduce(U256.fromBytes(eHash))
sha256Into(sc.bytesTmp1, hashInput, hashLen)
U256.fromBytesInto(e, sc.bytesTmp1, 0)
ScalarN.reduceTo(e, e)
// Accumulate e: eSum += eᵢ mod n
ScalarN.addTo(eSum, eSum, e)
// Decompress Rᵢ = liftX(rᵢ) and accumulate into rSum
val rx = LongArray(4)
val ry = LongArray(4)
if (!KeyCodec.liftX(rx, ry, r)) return false
if (!KeyCodec.liftX(rx, ry, r, sc.zInv)) return false
// rSum += Rᵢ (mixed addition: Rᵢ is affine)
if (rSum.isInfinity()) {
@@ -588,6 +723,7 @@ object Secp256k1 {
ScalarN.negTo(eSum, eSum)
val pPoint = sc.entryPoint
pPoint.setAffine(px, py)
// mulDoubleG uses sc.mixTmp internally (ping-pong), so output must be separate.
val q = MutablePoint()
ECPoint.mulDoubleG(q, sSum, pPoint, eSum)
@@ -596,9 +732,8 @@ object Secp256k1 {
FieldP.neg(rSum.y, rSum.y)
// Add Q + (-R_sum) and check if result is infinity
val result = MutablePoint()
ECPoint.addPoints(result, q, rSum, sc)
ECPoint.addPoints(sc.entryResult, q, rSum, sc)
return result.isInfinity()
return sc.entryResult.isInfinity()
}
}
@@ -45,6 +45,54 @@ package com.vitorpamplona.quartz.utils.secp256k1
// Package structure: U256 → FieldP/ScalarN → Glv/KeyCodec → Point → Secp256k1
// =====================================================================================
/**
* Unsigned less-than comparison: returns true if a < b when both are treated as unsigned.
*
* This MUST be platform-specific (expect/actual) because the optimal implementation
* differs between JIT compilers:
*
* WHY NOT `a.toULong() < b.toULong()` (the Kotlin-idiomatic approach)?
* Kotlin's ULong inline class generates 2 `invokestatic ULong.constructor-impl` calls
* per comparison — NOOPs that return the input unchanged. Bytecode analysis showed
* ~17,800 of these per Schnorr verify. On ART, each invokestatic has ~2-3ns overhead
* even when inlined, adding ~35-54μs to verify. On HotSpot, C2 eliminates them entirely.
*
* WHY NOT a single `inline fun` with XOR trick in commonMain?
* The XOR trick `(a xor MIN_VALUE) < (b xor MIN_VALUE)` generates pure arithmetic
* bytecode with zero method calls — great for ART. But on HotSpot, it's ~30% slower
* than `Long.compareUnsigned` because HotSpot recognizes compareUnsigned as a JIT
* intrinsic (single CMP + SETB) but does NOT optimize the XOR pattern equivalently.
* A commonMain inline fun with XOR regressed JVM verify from 1.6× to 2.1× vs native.
*
* Platform implementations:
* - JVM: `Long.compareUnsigned(a, b) < 0` — HotSpot JIT intrinsic → CMP + SETB
* - Android: `(a xor MIN_VALUE) < (b xor MIN_VALUE)` — zero invokestatic, ART → EOR + CMP
* - Native: same XOR trick — Kotlin/Native AOT handles it efficiently
*/
internal expect fun uLt(
a: Long,
b: Long,
): Boolean
/**
* Inline unsigned less-than for use inside hot-path functions.
*
* The expect/actual `uLt` function can't be `inline` (KMP limitation), so every
* call from commonMain is a real function dispatch (~82-91ns on ART). This adds up
* to ~1ms per verify from U256.addTo/subTo and FieldP.add/sub/half alone.
*
* This inline version uses the XOR-with-MIN_VALUE trick directly. The Kotlin compiler
* inlines it at every call site — zero dispatch overhead. On JVM, this is slightly
* slower than Long.compareUnsigned (HotSpot intrinsic), but the JVM's unfused path
* calls `uLt` (the expect/actual) which uses Long.compareUnsigned. Only the
* commonMain hot-path code (U256, FieldP, ScalarN) uses this inline version.
*/
@Suppress("NOTHING_TO_INLINE")
internal inline fun uLtInline(
a: Long,
b: Long,
): Boolean = (a xor Long.MIN_VALUE) < (b xor Long.MIN_VALUE)
/**
* Raw 256-bit unsigned integer arithmetic using 4×64-bit limbs.
*/
@@ -58,7 +106,7 @@ internal object U256 {
): Int {
for (i in 3 downTo 0) {
if (a[i] != b[i]) {
return if (a[i].toULong() < b[i].toULong()) -1 else 1
return if (uLt(a[i], b[i])) -1 else 1
}
}
return 0
@@ -77,31 +125,31 @@ internal object U256 {
// Limb 0 (no carry input)
s1 = a[0] + b[0]
c1 = if (s1.toULong() < a[0].toULong()) 1L else 0L
c1 = if (uLt(s1, a[0])) 1L else 0L
out[0] = s1
var carry = c1
// Limb 1
s1 = a[1] + b[1]
c1 = if (s1.toULong() < a[1].toULong()) 1L else 0L
c1 = if (uLt(s1, a[1])) 1L else 0L
s2 = s1 + carry
c2 = if (s2.toULong() < s1.toULong()) 1L else 0L
c2 = if (uLt(s2, s1)) 1L else 0L
out[1] = s2
carry = c1 + c2
// Limb 2
s1 = a[2] + b[2]
c1 = if (s1.toULong() < a[2].toULong()) 1L else 0L
c1 = if (uLt(s1, a[2])) 1L else 0L
s2 = s1 + carry
c2 = if (s2.toULong() < s1.toULong()) 1L else 0L
c2 = if (uLt(s2, s1)) 1L else 0L
out[2] = s2
carry = c1 + c2
// Limb 3
s1 = a[3] + b[3]
c1 = if (s1.toULong() < a[3].toULong()) 1L else 0L
c1 = if (uLt(s1, a[3])) 1L else 0L
s2 = s1 + carry
c2 = if (s2.toULong() < s1.toULong()) 1L else 0L
c2 = if (uLt(s2, s1)) 1L else 0L
out[3] = s2
carry = c1 + c2
@@ -121,31 +169,31 @@ internal object U256 {
// Limb 0 (no borrow input)
d1 = a[0] - b[0]
c1 = if (a[0].toULong() < b[0].toULong()) 1L else 0L
c1 = if (uLt(a[0], b[0])) 1L else 0L
out[0] = d1
var borrow = c1
// Limb 1
d1 = a[1] - b[1]
c1 = if (a[1].toULong() < b[1].toULong()) 1L else 0L
c1 = if (uLt(a[1], b[1])) 1L else 0L
d2 = d1 - borrow
c2 = if (d1.toULong() < borrow.toULong()) 1L else 0L
c2 = if (uLt(d1, borrow)) 1L else 0L
out[1] = d2
borrow = c1 + c2
// Limb 2
d1 = a[2] - b[2]
c1 = if (a[2].toULong() < b[2].toULong()) 1L else 0L
c1 = if (uLt(a[2], b[2])) 1L else 0L
d2 = d1 - borrow
c2 = if (d1.toULong() < borrow.toULong()) 1L else 0L
c2 = if (uLt(d1, borrow)) 1L else 0L
out[2] = d2
borrow = c1 + c2
// Limb 3
d1 = a[3] - b[3]
c1 = if (a[3].toULong() < b[3].toULong()) 1L else 0L
c1 = if (uLt(a[3], b[3])) 1L else 0L
d2 = d1 - borrow
c2 = if (d1.toULong() < borrow.toULong()) 1L else 0L
c2 = if (uLt(d1, borrow)) 1L else 0L
out[3] = d2
borrow = c1 + c2
@@ -187,19 +235,19 @@ internal object U256 {
lo = a0 * b1
s = lo + carry
c1 = if (s.toULong() < lo.toULong()) 1L else 0L
c1 = if (uLt(s, lo)) 1L else 0L
out[1] = s
carry = unsignedMultiplyHigh(a0, b1) + c1
lo = a0 * b2
s = lo + carry
c1 = if (s.toULong() < lo.toULong()) 1L else 0L
c1 = if (uLt(s, lo)) 1L else 0L
out[2] = s
carry = unsignedMultiplyHigh(a0, b2) + c1
lo = a0 * b3
s = lo + carry
c1 = if (s.toULong() < lo.toULong()) 1L else 0L
c1 = if (uLt(s, lo)) 1L else 0L
out[3] = s
out[4] = unsignedMultiplyHigh(a0, b3) + c1
@@ -208,7 +256,7 @@ internal object U256 {
hi = unsignedMultiplyHigh(a1, b0)
prev = out[1]
s = prev + lo
c1 = if (s.toULong() < prev.toULong()) 1L else 0L
c1 = if (uLt(s, prev)) 1L else 0L
out[1] = s
carry = hi + c1
@@ -216,9 +264,9 @@ internal object U256 {
hi = unsignedMultiplyHigh(a1, b1)
prev = out[2]
s = prev + lo
c1 = if (s.toULong() < prev.toULong()) 1L else 0L
c1 = if (uLt(s, prev)) 1L else 0L
s += carry
c2 = if (s.toULong() < carry.toULong()) 1L else 0L
c2 = if (uLt(s, carry)) 1L else 0L
out[2] = s
carry = hi + c1 + c2
@@ -226,9 +274,9 @@ internal object U256 {
hi = unsignedMultiplyHigh(a1, b2)
prev = out[3]
s = prev + lo
c1 = if (s.toULong() < prev.toULong()) 1L else 0L
c1 = if (uLt(s, prev)) 1L else 0L
s += carry
c2 = if (s.toULong() < carry.toULong()) 1L else 0L
c2 = if (uLt(s, carry)) 1L else 0L
out[3] = s
carry = hi + c1 + c2
@@ -236,9 +284,9 @@ internal object U256 {
hi = unsignedMultiplyHigh(a1, b3)
prev = out[4]
s = prev + lo
c1 = if (s.toULong() < prev.toULong()) 1L else 0L
c1 = if (uLt(s, prev)) 1L else 0L
s += carry
c2 = if (s.toULong() < carry.toULong()) 1L else 0L
c2 = if (uLt(s, carry)) 1L else 0L
out[4] = s
out[5] = hi + c1 + c2
@@ -247,7 +295,7 @@ internal object U256 {
hi = unsignedMultiplyHigh(a2, b0)
prev = out[2]
s = prev + lo
c1 = if (s.toULong() < prev.toULong()) 1L else 0L
c1 = if (uLt(s, prev)) 1L else 0L
out[2] = s
carry = hi + c1
@@ -255,9 +303,9 @@ internal object U256 {
hi = unsignedMultiplyHigh(a2, b1)
prev = out[3]
s = prev + lo
c1 = if (s.toULong() < prev.toULong()) 1L else 0L
c1 = if (uLt(s, prev)) 1L else 0L
s += carry
c2 = if (s.toULong() < carry.toULong()) 1L else 0L
c2 = if (uLt(s, carry)) 1L else 0L
out[3] = s
carry = hi + c1 + c2
@@ -265,9 +313,9 @@ internal object U256 {
hi = unsignedMultiplyHigh(a2, b2)
prev = out[4]
s = prev + lo
c1 = if (s.toULong() < prev.toULong()) 1L else 0L
c1 = if (uLt(s, prev)) 1L else 0L
s += carry
c2 = if (s.toULong() < carry.toULong()) 1L else 0L
c2 = if (uLt(s, carry)) 1L else 0L
out[4] = s
carry = hi + c1 + c2
@@ -275,9 +323,9 @@ internal object U256 {
hi = unsignedMultiplyHigh(a2, b3)
prev = out[5]
s = prev + lo
c1 = if (s.toULong() < prev.toULong()) 1L else 0L
c1 = if (uLt(s, prev)) 1L else 0L
s += carry
c2 = if (s.toULong() < carry.toULong()) 1L else 0L
c2 = if (uLt(s, carry)) 1L else 0L
out[5] = s
out[6] = hi + c1 + c2
@@ -286,7 +334,7 @@ internal object U256 {
hi = unsignedMultiplyHigh(a3, b0)
prev = out[3]
s = prev + lo
c1 = if (s.toULong() < prev.toULong()) 1L else 0L
c1 = if (uLt(s, prev)) 1L else 0L
out[3] = s
carry = hi + c1
@@ -294,9 +342,9 @@ internal object U256 {
hi = unsignedMultiplyHigh(a3, b1)
prev = out[4]
s = prev + lo
c1 = if (s.toULong() < prev.toULong()) 1L else 0L
c1 = if (uLt(s, prev)) 1L else 0L
s += carry
c2 = if (s.toULong() < carry.toULong()) 1L else 0L
c2 = if (uLt(s, carry)) 1L else 0L
out[4] = s
carry = hi + c1 + c2
@@ -304,9 +352,9 @@ internal object U256 {
hi = unsignedMultiplyHigh(a3, b2)
prev = out[5]
s = prev + lo
c1 = if (s.toULong() < prev.toULong()) 1L else 0L
c1 = if (uLt(s, prev)) 1L else 0L
s += carry
c2 = if (s.toULong() < carry.toULong()) 1L else 0L
c2 = if (uLt(s, carry)) 1L else 0L
out[5] = s
carry = hi + c1 + c2
@@ -314,9 +362,9 @@ internal object U256 {
hi = unsignedMultiplyHigh(a3, b3)
prev = out[6]
s = prev + lo
c1 = if (s.toULong() < prev.toULong()) 1L else 0L
c1 = if (uLt(s, prev)) 1L else 0L
s += carry
c2 = if (s.toULong() < carry.toULong()) 1L else 0L
c2 = if (uLt(s, carry)) 1L else 0L
out[6] = s
out[7] = hi + c1 + c2
}
@@ -353,13 +401,13 @@ internal object U256 {
lo = a0 * a2
s = lo + carry
c1 = if (s.toULong() < lo.toULong()) 1L else 0L
c1 = if (uLt(s, lo)) 1L else 0L
out[2] = s
carry = unsignedMultiplyHigh(a0, a2) + c1
lo = a0 * a3
s = lo + carry
c1 = if (s.toULong() < lo.toULong()) 1L else 0L
c1 = if (uLt(s, lo)) 1L else 0L
out[3] = s
out[4] = unsignedMultiplyHigh(a0, a3) + c1
@@ -368,7 +416,7 @@ internal object U256 {
hi = unsignedMultiplyHigh(a1, a2)
prev = out[3]
s = prev + lo
c1 = if (s.toULong() < prev.toULong()) 1L else 0L
c1 = if (uLt(s, prev)) 1L else 0L
out[3] = s
carry = hi + c1
@@ -376,9 +424,9 @@ internal object U256 {
hi = unsignedMultiplyHigh(a1, a3)
prev = out[4]
s = prev + lo
c1 = if (s.toULong() < prev.toULong()) 1L else 0L
c1 = if (uLt(s, prev)) 1L else 0L
s += carry
c2 = if (s.toULong() < carry.toULong()) 1L else 0L
c2 = if (uLt(s, carry)) 1L else 0L
out[4] = s
out[5] = hi + c1 + c2
@@ -387,7 +435,7 @@ internal object U256 {
hi = unsignedMultiplyHigh(a2, a3)
prev = out[5]
s = prev + lo
c1 = if (s.toULong() < prev.toULong()) 1L else 0L
c1 = if (uLt(s, prev)) 1L else 0L
out[5] = s
out[6] = hi + c1
@@ -419,7 +467,7 @@ internal object U256 {
hi = unsignedMultiplyHigh(a0, a0)
out[0] = lo // out[0] was 0
s = out[1] + hi
c1 = if (s.toULong() < out[1].toULong()) 1L else 0L
c1 = if (uLt(s, out[1])) 1L else 0L
out[1] = s
var dCarry = c1
@@ -427,14 +475,14 @@ internal object U256 {
lo = a1 * a1
hi = unsignedMultiplyHigh(a1, a1)
s = out[2] + lo
c1 = if (s.toULong() < out[2].toULong()) 1L else 0L
c1 = if (uLt(s, out[2])) 1L else 0L
s += dCarry
c2 = if (s.toULong() < dCarry.toULong()) 1L else 0L
c2 = if (uLt(s, dCarry)) 1L else 0L
out[2] = s
prev = out[3] + hi
val c3a = if (prev.toULong() < out[3].toULong()) 1L else 0L
val c3a = if (uLt(prev, out[3])) 1L else 0L
prev += c1 + c2
val c4a = if (prev.toULong() < (c1 + c2).toULong()) 1L else 0L
val c4a = if (uLt(prev, c1 + c2)) 1L else 0L
out[3] = prev
dCarry = c3a + c4a
@@ -442,14 +490,14 @@ internal object U256 {
lo = a2 * a2
hi = unsignedMultiplyHigh(a2, a2)
s = out[4] + lo
c1 = if (s.toULong() < out[4].toULong()) 1L else 0L
c1 = if (uLt(s, out[4])) 1L else 0L
s += dCarry
c2 = if (s.toULong() < dCarry.toULong()) 1L else 0L
c2 = if (uLt(s, dCarry)) 1L else 0L
out[4] = s
prev = out[5] + hi
val c3b = if (prev.toULong() < out[5].toULong()) 1L else 0L
val c3b = if (uLt(prev, out[5])) 1L else 0L
prev += c1 + c2
val c4b = if (prev.toULong() < (c1 + c2).toULong()) 1L else 0L
val c4b = if (uLt(prev, c1 + c2)) 1L else 0L
out[5] = prev
dCarry = c3b + c4b
@@ -457,12 +505,12 @@ internal object U256 {
lo = a3 * a3
hi = unsignedMultiplyHigh(a3, a3)
s = out[6] + lo
c1 = if (s.toULong() < out[6].toULong()) 1L else 0L
c1 = if (uLt(s, out[6])) 1L else 0L
s += dCarry
c2 = if (s.toULong() < dCarry.toULong()) 1L else 0L
c2 = if (uLt(s, dCarry)) 1L else 0L
out[6] = s
prev = out[7] + hi
val c3c = if (prev.toULong() < out[7].toULong()) 1L else 0L
val c3c = if (uLt(prev, out[7])) 1L else 0L
prev += c1 + c2
out[7] = prev
}
@@ -21,3 +21,14 @@
package com.vitorpamplona.quartz.utils.sha256
expect fun sha256(data: ByteArray): ByteArray
/**
* Allocation-free SHA-256: hash `data[0..len)` and write the 32-byte digest into `out`.
* Returns `out` for convenience. `out` must be at least 32 bytes.
* `data` may be longer than `len` (only the first `len` bytes are hashed).
*/
expect fun sha256Into(
out: ByteArray,
data: ByteArray,
len: Int = data.size,
): ByteArray
@@ -37,6 +37,17 @@ val threadLocalDigest =
actual fun sha256(data: ByteArray): ByteArray = threadLocalDigest.get().digest(data)
actual fun sha256Into(
out: ByteArray,
data: ByteArray,
len: Int,
): ByteArray {
val md = threadLocalDigest.get()
md.update(data, 0, len)
md.digest(out, 0, 32)
return out
}
/**
* Calculate SHA256 hash while counting bytes read from the stream.
* Returns both the hash and the number of bytes processed.
@@ -41,6 +41,14 @@ actual object Secp256k1Instance {
privKey: ByteArray,
): ByteArray = secp256k1.signSchnorr(data, privKey, null)
// Native C lib always derives pubkey internally — ignore the cached pubkey.
actual fun signSchnorrWithXOnlyPubKey(
data: ByteArray,
privKey: ByteArray,
xOnlyPubKey: ByteArray,
nonce: ByteArray?,
): ByteArray = secp256k1.signSchnorr(data, privKey, nonce)
actual fun verifySchnorr(
signature: ByteArray,
hash: ByteArray,
@@ -0,0 +1,57 @@
/*
* 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
/**
* JVM field multiply/square — delegates to the original unfused path.
*
* WHY NOT use the fused fieldMulReduceWith (like Android)?
* The fused inline+crossinline approach was designed for ART's limited inlining.
* On HotSpot C2, it produces a 2351-bytecode method (with 180 wasted bytecodes
* from lambda parameter shuffling) that exceeds FreqInlineSize (325), preventing
* HotSpot from inlining fieldMulReduce into FieldP.mul.
*
* The unfused path (mulWide + reduceWide) produces smaller methods (~320 bytecodes
* each) that HotSpot inlines individually and optimizes across boundaries. HotSpot's
* 8+ level inlining depth handles the full chain:
* FieldP.mul → fieldMulReduce → U256.mulWide → unsignedMultiplyHigh → Math.unsignedMultiplyHigh
* Each Math.unsignedMultiplyHigh is a C2 intrinsic → single MULQ on x86-64.
*
* Benchmark confirmed: unfused path is equal or faster on HotSpot JVM 21.
*/
internal actual fun fieldMulReduce(
out: LongArray,
a: LongArray,
b: LongArray,
w: LongArray,
) {
U256.mulWide(w, a, b)
FieldP.reduceWide(out, w)
}
internal actual fun fieldSqrReduce(
out: LongArray,
a: LongArray,
w: LongArray,
) {
U256.sqrWide(w, a)
FieldP.reduceWide(out, w)
}
@@ -0,0 +1,38 @@
/*
* 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
/**
* JVM: Long.compareUnsigned is a HotSpot C2 JIT intrinsic.
*
* DO NOT replace with the XOR trick `(a xor MIN_VALUE) < (b xor MIN_VALUE)`.
* HotSpot recognizes Long.compareUnsigned and compiles it to a single unsigned
* CMP + SETB instruction pair. The XOR trick generates 2 extra XOR instructions
* that HotSpot does NOT optimize away, causing a ~30% regression on verify
* (1.6× → 2.1× vs native, measured on JDK 21 x86-64).
*
* This is the opposite of Android/ART where the XOR trick is faster because
* it avoids ULong.constructor-impl overhead. See UnsignedCompare.android.kt.
*/
internal actual fun uLt(
a: Long,
b: Long,
): Boolean = java.lang.Long.compareUnsigned(a, b) < 0
@@ -26,13 +26,19 @@ import fr.acinq.secp256k1.Secp256k1 as NativeSecp256k1
/**
* Benchmark comparing the pure-Kotlin secp256k1 implementation against
* the native ACINQ/secp256k1-kmp JNI bindings.
* the native ACINQ/secp256k1-kmp JNI bindings on JVM (HotSpot C2).
*
* Each benchmark runs warmup iterations, then measures the timed iterations.
* Results are printed as ops/sec and relative speed.
* Each benchmark runs warmup iterations to trigger JIT compilation, then
* measures timed iterations. Results are printed as ops/sec and relative speed.
*
* NOTE: verifySchnorr and signSchnorr use the same pubkey repeatedly, so they
* benefit from the pubkey decompression cache and P-table cache. This is realistic
* for Nostr (feed from one author) but not representative of first-time verification.
*
* Run with: ./gradlew :quartz:jvmTest --tests "*.Secp256k1Benchmark"
*/
class Secp256k1Benchmark {
// Test data
// Test data (same across all 3 platform benchmarks for comparability)
private val privKey = hexToBytes("67E56582298859DDAE725F972992A07C6C4FB9F62A8FFF58CE3CA926A1063530")
private val msg32 = hexToBytes("243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89")
private val auxRand = hexToBytes("0000000000000000000000000000000000000000000000000000000000000001")
@@ -55,11 +61,7 @@ class Secp256k1Benchmark {
// ECDH test data
private val privKey2 = hexToBytes("3982F19BEF1615BCCFBB05E321C10E1D4CBA3DF0E841C2E41EEB6016347653C3")
private val pubKey2Uncompressed =
hexToBytes(
"040A629506E1B65CD9D2E0BA9C75DF9C4FED0DB16DC9625ED14397F0AFC836FAE5" +
"95DC53F8B0EFE61E703075BD9B143BAC75EC0E19F82A2208CAEB32BE53414C40",
)
private val pub2xOnly = hexToBytes("c2f9d9948dc8c7c38321e4b85c8558872eafa0641cd269db76848a6073e69133")
private val h02 = byteArrayOf(0x02)
// ============================================================
@@ -78,7 +80,7 @@ class Secp256k1Benchmark {
override fun toString(): String =
String.format(
"%-25s Native: %,8d ops/s Kotlin: %,8d ops/s Ratio: %.1fx slower",
"%-25s Native: %,8d ops/s Kotlin: %,8d ops/s Ratio: %.1fx",
name,
nativeOpsPerSec,
kotlinOpsPerSec,
@@ -130,7 +132,23 @@ class Secp256k1Benchmark {
val results = mutableListOf<BenchResult>()
// --- verifySchnorr ---
// --- verifySchnorrFast (Nostr: x-check only, no y-parity inversion) ---
results +=
bench(
name = "verifySchnorrFast",
warmup = 2000,
iterations = 5000,
nativeOp = { native.verifySchnorr(nativeSig, msg32, nativeXOnlyPub) },
kotlinOp = {
com.vitorpamplona.quartz.utils.secp256k1.Secp256k1.verifySchnorrFast(
kotlinSig,
msg32,
kotlinXOnlyPub,
)
},
)
// --- verifySchnorr (strict BIP-340 with y-parity check) ---
results +=
bench(
name = "verifySchnorr",
@@ -146,7 +164,7 @@ class Secp256k1Benchmark {
},
)
// --- signSchnorr (derives pubkey from seckey each time) ---
// --- signSchnorr (derives pubkey from seckey each time — both sides do the same work) ---
results +=
bench(
name = "signSchnorr",
@@ -162,7 +180,11 @@ class Secp256k1Benchmark {
},
)
// --- signSchnorrWithPubKey (pre-computed pubkey, no self-verify — matches C) ---
// --- signSchnorr (cached pk) ---
// NOTE: The C library's signSchnorr always derives the pubkey internally.
// Our signSchnorrWithPubKey skips that derivation. This is NOT an apples-to-apples
// comparison — it shows what's possible when the caller caches the compressed pubkey.
// The native baseline here is the same signSchnorr (with pubkey derivation).
results +=
bench(
name = "signSchnorr (cached pk)",
@@ -179,79 +201,6 @@ class Secp256k1Benchmark {
},
)
// --- pubkeyCreate ---
results +=
bench(
name = "pubkeyCreate",
warmup = 1000,
iterations = 5000,
nativeOp = { native.pubkeyCreate(privKey) },
kotlinOp = {
com.vitorpamplona.quartz.utils.secp256k1.Secp256k1
.pubkeyCreate(privKey)
},
)
// --- pubKeyCompress ---
val uncompressedNative = native.pubkeyCreate(privKey)
val uncompressedKotlin =
com.vitorpamplona.quartz.utils.secp256k1.Secp256k1
.pubkeyCreate(privKey)
results +=
bench(
name = "pubKeyCompress",
warmup = 2000,
iterations = 50000,
nativeOp = { native.pubKeyCompress(uncompressedNative) },
kotlinOp = {
com.vitorpamplona.quartz.utils.secp256k1.Secp256k1
.pubKeyCompress(uncompressedKotlin)
},
)
// --- pubKeyTweakMul (ECDH) ---
results +=
bench(
name = "pubKeyTweakMul (ECDH)",
warmup = 1000,
iterations = 3000,
nativeOp = { native.pubKeyTweakMul(pubKey2Uncompressed.copyOf(), privKey) },
kotlinOp = {
com.vitorpamplona.quartz.utils.secp256k1.Secp256k1.pubKeyTweakMul(
pubKey2Uncompressed,
privKey,
)
},
)
// --- privKeyTweakAdd ---
results +=
bench(
name = "privKeyTweakAdd",
warmup = 1000,
iterations = 50000,
nativeOp = { native.privKeyTweakAdd(privKey.copyOf(), privKey2) },
kotlinOp = {
com.vitorpamplona.quartz.utils.secp256k1.Secp256k1.privKeyTweakAdd(
privKey,
privKey2,
)
},
)
// --- secKeyVerify ---
results +=
bench(
name = "secKeyVerify",
warmup = 5000,
iterations = 200000,
nativeOp = { native.secKeyVerify(privKey) },
kotlinOp = {
com.vitorpamplona.quartz.utils.secp256k1.Secp256k1
.secKeyVerify(privKey)
},
)
// --- compressedPubKeyFor (create + compress combined) ---
results +=
bench(
@@ -267,24 +216,38 @@ class Secp256k1Benchmark {
},
)
// --- pubKeyTweakMulCompact (old pattern via pubKeyTweakMul) ---
val pub2xOnly = hexToBytes("c2f9d9948dc8c7c38321e4b85c8558872eafa0641cd269db76848a6073e69133")
// --- secKeyVerify ---
results +=
bench(
name = "tweakMulCompact (old)",
warmup = 1000,
iterations = 3000,
nativeOp = { native.pubKeyTweakMul(h02 + pub2xOnly, privKey).copyOfRange(1, 33) },
name = "secKeyVerify",
warmup = 5000,
iterations = 200000,
nativeOp = { native.secKeyVerify(privKey) },
kotlinOp = {
com.vitorpamplona.quartz.utils.secp256k1.Secp256k1
.pubKeyTweakMul(
h02 + pub2xOnly,
privKey,
).copyOfRange(1, 33)
.secKeyVerify(privKey)
},
)
// --- ecdhXOnly (actual Nostr ECDH path) ---
// --- privKeyTweakAdd ---
// NOTE: The ACINQ wrapper mutates its first argument, requiring .copyOf() on
// the native side. This adds one allocation to the native measurement.
results +=
bench(
name = "privKeyTweakAdd",
warmup = 1000,
iterations = 50000,
nativeOp = { native.privKeyTweakAdd(privKey.copyOf(), privKey2) },
kotlinOp = {
com.vitorpamplona.quartz.utils.secp256k1.Secp256k1.privKeyTweakAdd(
privKey,
privKey2,
)
},
)
// --- ecdhXOnly (Nostr NIP-04/NIP-44 ECDH path) ---
// Native has no ecdhXOnly — compare against pubKeyTweakMul + extract x.
results +=
bench(
name = "ecdhXOnly (Nostr)",
@@ -300,7 +263,7 @@ class Secp256k1Benchmark {
// Print results
println()
println("=".repeat(90))
println("secp256k1 Benchmark: Native (ACINQ/JNI) vs Pure Kotlin")
println("secp256k1 Benchmark: Native (ACINQ/JNI) vs Pure Kotlin on JVM (HotSpot C2)")
println("=".repeat(90))
for (r in results) {
println(r)
@@ -308,7 +271,9 @@ class Secp256k1Benchmark {
println("=".repeat(90))
// ==================== Batch verification benchmark ====================
// Same pubkey, n events — the typical Nostr pattern (feed from one author)
// Same pubkey, n events — the typical Nostr pattern (feed from one author).
// Batch verify uses scalar+point summation: one mulDoubleG for the whole batch
// instead of n individual mulDoubleG calls.
val batchPub = kotlinXOnlyPub
for (batchSize in intArrayOf(4, 8, 16, 32)) {
val sigs = mutableListOf<ByteArray>()
@@ -28,3 +28,15 @@ actual fun sha256(data: ByteArray): ByteArray {
val hasher = provider.get(SHA256).hasher()
return hasher.hashBlocking(data)
}
actual fun sha256Into(
out: ByteArray,
data: ByteArray,
len: Int,
): ByteArray {
// Linux cryptography provider doesn't support writing into existing buffer.
// Fall back to allocating and copying.
val hash = sha256(if (len == data.size) data else data.copyOfRange(0, len))
hash.copyInto(out)
return out
}
@@ -0,0 +1,291 @@
/*
* 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
import kotlin.test.Test
import kotlin.test.assertTrue
import kotlin.time.TimeSource
/**
* Benchmark for the pure-Kotlin secp256k1 implementation on Kotlin/Native (LLVM AOT).
*
* Measures the same operations as the JVM benchmark (Secp256k1Benchmark.kt) and the
* Android benchmark (benchmark module) so results can be compared across runtimes:
* - Kotlin/Native LLVM AOT (this benchmark)
* - JVM HotSpot C2 JIT (jvmTest/Secp256k1Benchmark.kt)
* - Android ART JIT (benchmark module)
*
* No native C comparison is available (JNI doesn't exist on K/N).
* Compare against the JVM benchmark's native column for the C baseline.
*
* NOTE: verifySchnorr uses the same pubkey repeatedly, so it benefits from the
* pubkey decompression cache and P-table cache. This is realistic for Nostr
* (feed from one author) but not representative of first-time verification.
*
* Test data is the same across all 3 platform benchmarks for cross-platform comparability.
*
* Run with: ./gradlew :quartz:linuxX64Test --tests "*.Secp256k1NativeBenchmark"
*
* IMPORTANT: Requires -opt in the compiler options for meaningful results.
* Without it, K/N compiles in debug mode (~12x slower). See build.gradle.kts.
*/
class Secp256k1NativeBenchmark {
// Test data (same across all 3 platform benchmarks for comparability)
private val privKey = hexToBytes("67E56582298859DDAE725F972992A07C6C4FB9F62A8FFF58CE3CA926A1063530")
private val msg32 = hexToBytes("243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89")
private val auxRand = hexToBytes("0000000000000000000000000000000000000000000000000000000000000001")
// Pre-computed test data
private val pubKey = Secp256k1.pubKeyCompress(Secp256k1.pubkeyCreate(privKey))
private val xOnlyPub = pubKey.copyOfRange(1, 33)
private val sig = Secp256k1.signSchnorr(msg32, privKey, auxRand)
private val privKey2 = hexToBytes("3982F19BEF1615BCCFBB05E321C10E1D4CBA3DF0E841C2E41EEB6016347653C3")
private val pub2xOnly = hexToBytes("c2f9d9948dc8c7c38321e4b85c8558872eafa0641cd269db76848a6073e69133")
private val h02 = byteArrayOf(0x02)
// ============================================================
// Benchmark runner
// ============================================================
private data class BenchResult(
val name: String,
val nanos: Long,
val iterations: Int,
) {
val opsPerSec get() = iterations * 1_000_000_000L / nanos
val nsPerOp get() = nanos / iterations
}
private inline fun bench(
name: String,
warmup: Int,
iterations: Int,
crossinline op: () -> Unit,
): BenchResult {
// Warmup (LLVM AOT doesn't JIT, but warms up caches and branch predictors)
repeat(warmup) { op() }
val mark = TimeSource.Monotonic.markNow()
repeat(iterations) { op() }
val elapsed = mark.elapsedNow().inWholeNanoseconds
return BenchResult(name, elapsed, iterations)
}
private fun printResults(
title: String,
results: List<BenchResult>,
) {
println()
println("=".repeat(80))
println(title)
println("=".repeat(80))
for (r in results) {
println(
" ${r.name.padEnd(25)} ${
r.nsPerOp.toString().padStart(10)
} ns/op ${r.opsPerSec.toString().padStart(8)} ops/s",
)
}
println("=".repeat(80))
}
// ============================================================
// Individual benchmarks (same operations as JVM and Android)
// ============================================================
@Test
fun benchmarkAll() {
// Verify our test data is valid
assertTrue(Secp256k1.verifySchnorr(sig, msg32, xOnlyPub), "Self-verify failed")
val results = mutableListOf<BenchResult>()
// --- verifySchnorr (same pubkey = cache-warm, typical Nostr pattern) ---
results +=
bench("verifySchnorr", 2000, 5000) {
Secp256k1.verifySchnorr(sig, msg32, xOnlyPub)
}
// --- verifySchnorrFast (Nostr: x-check only, no y-parity inversion) ---
results +=
bench("verifySchnorrFast", 2000, 5000) {
Secp256k1.verifySchnorrFast(sig, msg32, xOnlyPub)
}
// --- signSchnorr (derives pubkey each time) ---
results +=
bench("signSchnorr", 1000, 3000) {
Secp256k1.signSchnorr(msg32, privKey, auxRand)
}
// --- signSchnorr (cached pubkey — skips G multiplication for pubkey derivation) ---
results +=
bench("signSchnorr (cached pk)", 1000, 5000) {
Secp256k1.signSchnorrWithPubKey(msg32, privKey, pubKey, auxRand)
}
// --- compressedPubKeyFor (create + compress combined) ---
results +=
bench("compressedPubKeyFor", 1000, 5000) {
Secp256k1.pubKeyCompress(Secp256k1.pubkeyCreate(privKey))
}
// --- secKeyVerify ---
results +=
bench("secKeyVerify", 5000, 200000) {
Secp256k1.secKeyVerify(privKey)
}
// --- privKeyTweakAdd ---
results +=
bench("privKeyTweakAdd", 1000, 50000) {
Secp256k1.privKeyTweakAdd(privKey, privKey2)
}
// --- ecdhXOnly (Nostr NIP-04/NIP-44 ECDH path) ---
results +=
bench("ecdhXOnly (Nostr)", 1000, 3000) {
Secp256k1.ecdhXOnly(pub2xOnly, privKey)
}
printResults("secp256k1 Benchmark: Kotlin/Native (LLVM AOT) on linuxX64", results)
// ==================== Batch verification ====================
// Same pubkey, n events — the typical Nostr pattern (feed from one author).
println()
for (batchSize in intArrayOf(4, 8, 16, 32)) {
val sigs = mutableListOf<ByteArray>()
val msgs = mutableListOf<ByteArray>()
for (i in 0 until batchSize) {
val m = ByteArray(32) { (i * 7 + it).toByte() }
sigs.add(Secp256k1.signSchnorr(m, privKey, auxRand))
msgs.add(m)
}
// Warmup
repeat(500) {
for (j in 0 until batchSize) Secp256k1.verifySchnorr(sigs[j], msgs[j], xOnlyPub)
}
repeat(500) { Secp256k1.verifySchnorrBatch(xOnlyPub, sigs, msgs) }
// Time individual
val iters = 1000
val indivMark = TimeSource.Monotonic.markNow()
repeat(iters) {
for (j in 0 until batchSize) Secp256k1.verifySchnorr(sigs[j], msgs[j], xOnlyPub)
}
val indivNs = indivMark.elapsedNow().inWholeNanoseconds
val indivPerEvent = iters.toLong() * batchSize * 1_000_000_000L / indivNs
// Time batch
val batchMark = TimeSource.Monotonic.markNow()
repeat(iters) { Secp256k1.verifySchnorrBatch(xOnlyPub, sigs, msgs) }
val batchNs = batchMark.elapsedNow().inWholeNanoseconds
val batchPerEvent = iters.toLong() * batchSize * 1_000_000_000L / batchNs
val speedup = indivNs.toDouble() / batchNs.toDouble()
println(
" batch(${batchSize.toString().padStart(2)}): " +
"individual ${indivPerEvent.toString().padStart(7)} ev/s " +
"batch ${batchPerEvent.toString().padStart(7)} ev/s " +
"speedup ${((speedup * 10).toLong() / 10.0)}x",
)
}
println("=".repeat(80))
println()
}
// ============================================================
// Field-level micro-benchmarks (to compare LLVM codegen vs JVM JIT)
// ============================================================
@Test
fun benchmarkFieldOps() {
// Use result-dependent chains to prevent LLVM from hoisting constant computations
// out of the loop (dead code elimination risk with constant inputs).
val a = U256.fromBytes(hexToBytes("79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798"))
val b = U256.fromBytes(hexToBytes("483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8"))
val out = LongArray(4)
val w = LongArray(8)
val results = mutableListOf<BenchResult>()
// --- FieldP.mul (the hottest operation) ---
// Uses `out` as both output and next input to create a data dependency chain.
a.copyInto(out)
results +=
bench("FieldP.mul", 5000, 100000) {
FieldP.mul(out, out, b, w)
}
// --- FieldP.sqr ---
a.copyInto(out)
results +=
bench("FieldP.sqr", 5000, 100000) {
FieldP.sqr(out, out, w)
}
// --- FieldP.add ---
a.copyInto(out)
results +=
bench("FieldP.add", 5000, 500000) {
FieldP.add(out, out, b)
}
// --- FieldP.sub ---
a.copyInto(out)
results +=
bench("FieldP.sub", 5000, 500000) {
FieldP.sub(out, out, b)
}
// --- FieldP.inv ---
results +=
bench("FieldP.inv", 500, 5000) {
FieldP.inv(out, a)
}
// --- unsignedMultiplyHigh (with varying input to prevent hoisting) ---
var hiSink = 0L
results +=
bench("unsignedMultiplyHigh", 10000, 1000000) {
hiSink = unsignedMultiplyHigh(a[0] xor hiSink, b[0])
}
// --- uLt (with varying input to prevent hoisting) ---
var ltSink = 0L
results +=
bench("uLt", 10000, 1000000) {
ltSink = if (uLt(a[0] xor ltSink, b[0])) 1L else 0L
}
printResults("secp256k1 Field Micro-Benchmarks: Kotlin/Native (LLVM AOT) on linuxX64", results)
// Use sinks to prevent dead code elimination of the entire benchmark
assertTrue(hiSink != Long.MIN_VALUE || ltSink != Long.MIN_VALUE || out[0] != Long.MIN_VALUE)
}
private fun hexToBytes(hex: String): ByteArray {
val len = hex.length / 2
val result = ByteArray(len)
for (i in 0 until len) {
result[i] = hex.substring(i * 2, i * 2 + 2).toInt(16).toByte()
}
return result
}
}
@@ -41,6 +41,13 @@ actual object Secp256k1Instance {
privKey: ByteArray,
): ByteArray = secp256k1Ref.signSchnorr(data, privKey, null)
actual fun signSchnorrWithXOnlyPubKey(
data: ByteArray,
privKey: ByteArray,
xOnlyPubKey: ByteArray,
nonce: ByteArray?,
): ByteArray = secp256k1Ref.signSchnorr(data, privKey, nonce)
actual fun verifySchnorr(
signature: ByteArray,
hash: ByteArray,
@@ -0,0 +1,42 @@
/*
* 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
/**
* Native (iOS/macOS) fused field multiply/square using pure-Kotlin fallback.
* Kotlin/Native compiles ahead-of-time, so inline + direct call is optimal.
*/
internal actual fun fieldMulReduce(
out: LongArray,
a: LongArray,
b: LongArray,
w: LongArray,
) {
fieldMulReduceWith(out, a, b, w) { x, y -> unsignedMultiplyHighFallback(x, y) }
}
internal actual fun fieldSqrReduce(
out: LongArray,
a: LongArray,
w: LongArray,
) {
fieldSqrReduceWith(out, a, w) { x, y -> unsignedMultiplyHighFallback(x, y) }
}
@@ -0,0 +1,34 @@
/*
* 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
/**
* Native: XOR-with-MIN_VALUE trick for unsigned comparison.
*
* No JVM intrinsics available on Kotlin/Native. The XOR trick compiles
* to efficient unsigned compare via Kotlin/Native's LLVM backend.
* See UnsignedCompare.android.kt for detailed rationale on why this
* approach is preferred over toULong().
*/
internal actual fun uLt(
a: Long,
b: Long,
): Boolean = (a xor Long.MIN_VALUE) < (b xor Long.MIN_VALUE)