mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 16:14:40 +00:00
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:
+64
-38
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+8
@@ -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,
|
||||
|
||||
+60
@@ -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) }
|
||||
}
|
||||
+44
@@ -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
|
||||
|
||||
+513
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+7
-1
@@ -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 {
|
||||
|
||||
+97
-47
@@ -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
|
||||
}
|
||||
|
||||
+175
-40
@@ -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
|
||||
|
||||
+11
@@ -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,
|
||||
|
||||
+57
@@ -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)
|
||||
}
|
||||
+38
@@ -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
|
||||
+64
-99
@@ -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
|
||||
}
|
||||
|
||||
+291
@@ -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
|
||||
}
|
||||
}
|
||||
+7
@@ -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,
|
||||
|
||||
+42
@@ -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) }
|
||||
}
|
||||
+34
@@ -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)
|
||||
Reference in New Issue
Block a user