feat: add same-pubkey batch Schnorr verification (5-6x throughput)

Add verifySchnorrBatch(pub, signatures, messages) that verifies n
signatures from the same public key using scalar and point summation
instead of n individual mulDoubleG calls.

The batch equation exploits linearity of Schnorr signatures:
  (Σ sᵢ)·G = (Σ Rᵢ) + (Σ eᵢ)·P
This combines n verifications into:
  - n scalar additions for S = Σsᵢ and E = Σeᵢ (trivial)
  - n liftX + (n-1) addMixed for R_sum = ΣRᵢ (cheap point additions)
  - ONE mulDoubleG(S, P, -E) (the expensive EC operation, done once)
  - Check result - R_sum = O (no toAffine needed)

JVM benchmark results (same pubkey, 1000 iterations, 500 warmup):
  batch( 4):  4.1x faster than individual  (40,121 events/s)
  batch( 8):  5.3x faster                  (49,637 events/s)
  batch(16):  5.5x faster                  (49,815 events/s)
  batch(32):  5.9x faster                  (51,272 events/s)

This fits the Nostr pattern perfectly: when loading a profile or
connecting to a relay, many events from the same followed author
arrive together and can be batch-verified.

If the batch fails (returns false), the caller falls back to
individual verification to identify which signature(s) are invalid.

Security: by linearity, individual errors cannot cancel without
solving the discrete log. For extra hardening, duplicate events
from multiple relays can be verified individually as cross-checks.

https://claude.ai/code/session_017UbWduFi1sLUsgVUMUH2nx
This commit is contained in:
Claude
2026-04-08 02:56:45 +00:00
parent cf7e2d4b0d
commit df9c8a9f4b
3 changed files with 235 additions and 0 deletions
@@ -306,6 +306,63 @@ class Secp256k1Benchmark {
println(r)
}
println("=".repeat(90))
// ==================== Batch verification benchmark ====================
// Same pubkey, n events — the typical Nostr pattern (feed from one author)
val batchPub = kotlinXOnlyPub
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(
com.vitorpamplona.quartz.utils.secp256k1.Secp256k1
.signSchnorr(m, privKey, auxRand),
)
msgs.add(m)
}
// Warmup both paths
repeat(500) {
for (j in 0 until batchSize) {
com.vitorpamplona.quartz.utils.secp256k1.Secp256k1
.verifySchnorr(sigs[j], msgs[j], batchPub)
}
}
repeat(500) {
com.vitorpamplona.quartz.utils.secp256k1.Secp256k1
.verifySchnorrBatch(batchPub, sigs, msgs)
}
// Time individual
val iters = 1000
val indivStart = System.nanoTime()
repeat(iters) {
for (j in 0 until batchSize) {
com.vitorpamplona.quartz.utils.secp256k1.Secp256k1
.verifySchnorr(sigs[j], msgs[j], batchPub)
}
}
val indivNs = System.nanoTime() - indivStart
val indivPerEvent = iters.toLong() * batchSize * 1_000_000_000L / indivNs
// Time batch
val batchStart = System.nanoTime()
repeat(iters) {
com.vitorpamplona.quartz.utils.secp256k1.Secp256k1
.verifySchnorrBatch(batchPub, sigs, msgs)
}
val batchNs = System.nanoTime() - batchStart
val batchPerEvent = iters.toLong() * batchSize * 1_000_000_000L / batchNs
val speedup = indivNs.toDouble() / batchNs.toDouble()
println(
String.format(
" batch(%2d): individual %,7d ev/s batch %,7d ev/s speedup %.1fx",
batchSize,
indivPerEvent,
batchPerEvent,
speedup,
),
)
}
println("=".repeat(90))
println()
}