perf: optimize secp256k1 for minimum allocations and maximum verify throughput

Key optimizations:

1. Mutable field operations: All FieldP hot-path methods (add, sub, mul, sqr)
   now write into caller-provided output arrays instead of allocating new ones.
   Thread-local IntArray(16) scratch for mulWide avoids per-mul allocation.

2. Mutable point operations: MutablePoint replaces immutable JPoint. Point
   doubling/addition write into output points. Aliasing protection via
   thread-local copy buffer for in-place doublePoint(out, out).

3. 4-bit windowed scalar multiplication: Processes 4 bits per iteration
   (16 table entries) instead of 1 bit. Reduces point additions by ~4x.

4. Precomputed G table: Static lazy table of 16*G multiples. Generator
   multiplication (signing, key creation) uses precomputed table directly.

5. Shamir's trick (mulDoubleG): Computes s*G + e*P in a single pass for
   verification, eliminating the need for two separate scalar multiplications.
   This roughly halves the cost of verifySchnorr.

6. Cached BIP-340 tag hashes: SHA256("BIP0340/challenge") etc. computed
   once and reused, eliminating 2 SHA256 calls per verify.

7. toBytesInto: Writes directly into existing ByteArray at offset,
   avoiding intermediate allocations in serialization.

https://claude.ai/code/session_01BhU63WUe9AhikZxRdw3Lpg
This commit is contained in:
Claude
2026-04-05 13:58:21 +00:00
parent 4c3b31fe8e
commit 4d55dfcff5
3 changed files with 826 additions and 610 deletions
@@ -25,13 +25,16 @@ package com.vitorpamplona.quartz.utils.secp256k1
*
* Numbers are represented as IntArray(8) in little-endian order where each element
* holds 32 bits (treated as unsigned). Element [0] is the least significant limb.
*
* Performance: All hot-path operations write into caller-provided output arrays
* to avoid allocation. Only conversion methods (fromBytes/toBytes) allocate.
*/
internal object U256 {
val ZERO = IntArray(8)
fun isZero(a: IntArray): Boolean {
for (i in 0 until 8) if (a[i] != 0) return false
return true
// Merge all limbs to avoid branches
return (a[0] or a[1] or a[2] or a[3] or a[4] or a[5] or a[6] or a[7]) == 0
}
/** Compare: returns negative if a < b, 0 if equal, positive if a > b */
@@ -42,87 +45,70 @@ internal object U256 {
for (i in 7 downTo 0) {
val ai = a[i].toLong() and 0xFFFFFFFFL
val bi = b[i].toLong() and 0xFFFFFFFFL
if (ai < bi) return -1
if (ai > bi) return 1
if (ai != bi) return if (ai < bi) -1 else 1
}
return 0
}
/** a + b, returns (result, carry) where carry is 0 or 1 */
fun addCarry(
/** a + b -> out, returns carry (0 or 1) */
fun addTo(
out: IntArray,
a: IntArray,
b: IntArray,
): Pair<IntArray, Int> {
val r = IntArray(8)
): Int {
var carry = 0L
for (i in 0 until 8) {
carry += (a[i].toLong() and 0xFFFFFFFFL) + (b[i].toLong() and 0xFFFFFFFFL)
r[i] = carry.toInt()
out[i] = carry.toInt()
carry = carry ushr 32
}
return Pair(r, carry.toInt())
return carry.toInt()
}
/** a - b, returns (result, borrow) where borrow is 0 or 1 */
fun subBorrow(
/** a - b -> out, returns borrow (0 or 1) */
fun subTo(
out: IntArray,
a: IntArray,
b: IntArray,
): Pair<IntArray, Int> {
val r = IntArray(8)
): Int {
var borrow = 0L
for (i in 0 until 8) {
val diff = (a[i].toLong() and 0xFFFFFFFFL) - (b[i].toLong() and 0xFFFFFFFFL) - borrow
r[i] = diff.toInt()
out[i] = diff.toInt()
borrow = if (diff < 0) 1L else 0L
}
return Pair(r, borrow.toInt())
return borrow.toInt()
}
/** Full 256x256 -> 512 bit multiplication. Result is IntArray(16). */
/** Full 256x256 -> 512 bit multiplication. Result written to out (size 16). */
fun mulWide(
out: IntArray,
a: IntArray,
b: IntArray,
): IntArray {
val r = IntArray(16)
) {
for (i in 0 until 16) out[i] = 0
for (i in 0 until 8) {
var carry = 0L
val ai = a[i].toLong() and 0xFFFFFFFFL
for (j in 0 until 8) {
val prod = ai * (b[j].toLong() and 0xFFFFFFFFL) + (r[i + j].toLong() and 0xFFFFFFFFL) + carry
r[i + j] = prod.toInt()
val prod = ai * (b[j].toLong() and 0xFFFFFFFFL) + (out[i + j].toLong() and 0xFFFFFFFFL) + carry
out[i + j] = prod.toInt()
carry = prod ushr 32
}
r[i + 8] = carry.toInt()
out[i + 8] = carry.toInt()
}
return r
}
/** Multiply 256-bit number by a small (fits in Long) constant. Result is IntArray(9). */
fun mulSmall(
a: IntArray,
b: Long,
): IntArray {
val r = IntArray(9)
var carry = 0L
for (i in 0 until 8) {
carry += (a[i].toLong() and 0xFFFFFFFFL) * b
r[i] = carry.toInt()
carry = carry ushr 32
}
r[8] = carry.toInt()
return r
}
/** Convert big-endian 32-byte array to IntArray(8) little-endian limbs */
fun fromBytes(bytes: ByteArray): IntArray {
require(bytes.size == 32) { "Expected 32 bytes, got ${bytes.size}" }
require(bytes.size == 32)
val r = IntArray(8)
for (i in 0 until 8) {
val offset = 28 - i * 4
r[i] = ((bytes[offset].toInt() and 0xFF) shl 24) or
((bytes[offset + 1].toInt() and 0xFF) shl 16) or
((bytes[offset + 2].toInt() and 0xFF) shl 8) or
(bytes[offset + 3].toInt() and 0xFF)
val o = 28 - i * 4
r[i] = ((bytes[o].toInt() and 0xFF) shl 24) or
((bytes[o + 1].toInt() and 0xFF) shl 16) or
((bytes[o + 2].toInt() and 0xFF) shl 8) or
(bytes[o + 3].toInt() and 0xFF)
}
return r
}
@@ -131,44 +117,69 @@ internal object U256 {
fun toBytes(a: IntArray): ByteArray {
val r = ByteArray(32)
for (i in 0 until 8) {
val offset = 28 - i * 4
r[offset] = (a[i] ushr 24).toByte()
r[offset + 1] = (a[i] ushr 16).toByte()
r[offset + 2] = (a[i] ushr 8).toByte()
r[offset + 3] = a[i].toByte()
val o = 28 - i * 4
r[o] = (a[i] ushr 24).toByte()
r[o + 1] = (a[i] ushr 16).toByte()
r[o + 2] = (a[i] ushr 8).toByte()
r[o + 3] = a[i].toByte()
}
return r
}
/** Check if bit at position pos is set (pos 0 = LSB) */
/** Write big-endian bytes into existing array at offset */
fun toBytesInto(
a: IntArray,
dest: ByteArray,
offset: Int,
) {
for (i in 0 until 8) {
val o = offset + 28 - i * 4
dest[o] = (a[i] ushr 24).toByte()
dest[o + 1] = (a[i] ushr 16).toByte()
dest[o + 2] = (a[i] ushr 8).toByte()
dest[o + 3] = a[i].toByte()
}
}
/** Get 4-bit nibble from scalar at position pos (pos 0 = lowest nibble) */
fun getNibble(
a: IntArray,
pos: Int,
): Int {
val limb = pos / 8
val shift = (pos % 8) * 4
return (a[limb] ushr shift) and 0xF
}
/** Check if bit at position pos is set */
fun testBit(
a: IntArray,
pos: Int,
): Boolean {
val limb = pos / 32
val bit = pos % 32
return (a[limb] ushr bit) and 1 == 1
}
): Boolean = (a[pos / 32] ushr (pos % 32)) and 1 == 1
/** XOR two 256-bit values */
fun xor(
/** XOR: out = a xor b */
fun xorTo(
out: IntArray,
a: IntArray,
b: IntArray,
): IntArray {
val r = IntArray(8)
for (i in 0 until 8) r[i] = a[i] xor b[i]
return r
) {
for (i in 0 until 8) out[i] = a[i] xor b[i]
}
fun clone(a: IntArray): IntArray = a.copyOf()
/** Copy a into out */
fun copyInto(
out: IntArray,
a: IntArray,
) {
a.copyInto(out)
}
}
/**
* Field arithmetic modulo p = 2^256 - 2^32 - 977 (= 2^256 - 4294968273).
* This is the base field of the secp256k1 curve.
* Field arithmetic modulo p = 2^256 - 2^32 - 977.
* All hot-path operations write results into caller-provided output arrays.
*/
internal object FieldP {
// p = FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
val P =
intArrayOf(
0xFFFFFC2F.toInt(),
@@ -181,133 +192,145 @@ internal object FieldP {
0xFFFFFFFF.toInt(),
)
// 2^256 mod p = 2^32 + 977 = 0x1000003D1 (fits in 33 bits)
// As limbs: [977, 1, 0, 0, 0, 0, 0, 0]
private val P_COMPLEMENT_LIMBS = intArrayOf(977, 1, 0, 0, 0, 0, 0, 0)
// Thread-local scratch space to avoid allocation in hot path
// Since Kotlin/JVM coroutines are cooperative (not preemptive on same thread),
// thread-locals are safe as long as we don't call suspend functions mid-computation.
private val wide = ThreadLocal.withInitial { IntArray(16) }
/** Reduce a value that might be >= p (but < 2p) */
fun reduce(a: IntArray): IntArray =
/** Reduce in-place: if a >= p, subtract p */
fun reduceSelf(a: IntArray) {
if (U256.cmp(a, P) >= 0) {
U256.subBorrow(a, P).first
} else {
a
U256.subTo(a, a, P)
}
}
/** Reduce a wide 512-bit product mod p */
fun reduceWide(w: IntArray): IntArray {
// w = hi * 2^256 + lo
// ≡ lo + hi * (2^32 + 977) (mod p)
//
/** Reduce a wide 512-bit value in w[0..15] -> out[0..7] mod p */
fun reduceWide(
out: IntArray,
w: IntArray,
) {
// w ≡ lo + hi * (2^32 + 977) (mod p)
// Split: hi * (2^32 + 977) = (hi << 32) + hi * 977
// hi * 977: each limb product fits in 42 bits, no overflow
// hi << 32: shift limbs by one position
val lo = IntArray(8)
val hi = IntArray(8)
for (i in 0 until 8) {
lo[i] = w[i]
hi[i] = w[i + 8]
}
// Compute hi * 977 (result fits in 266 bits = 9 limbs)
val hiTimes977 = U256.mulSmall(hi, 977L)
// Compute lo + hi * 977 + (hi << 32)
// hi << 32 means: [0, hi[0], hi[1], ..., hi[7]] (9 limbs)
val result = IntArray(8)
// Compute: out = lo + hi*977 + (hi << 32)
var carry = 0L
for (i in 0 until 8) {
carry += (lo[i].toLong() and 0xFFFFFFFFL) +
(hiTimes977[i].toLong() and 0xFFFFFFFFL) +
if (i > 0) (hi[i - 1].toLong() and 0xFFFFFFFFL) else 0L
result[i] = carry.toInt()
// lo[i]
carry += (w[i].toLong() and 0xFFFFFFFFL)
// hi[i] * 977
carry += (w[i + 8].toLong() and 0xFFFFFFFFL) * 977L
// hi[i-1] (the <<32 shift)
if (i > 0) carry += (w[i + 7].toLong() and 0xFFFFFFFFL)
out[i] = carry.toInt()
carry = carry ushr 32
}
// Remaining overflow: carry + hiTimes977[8] + hi[7]
var overflow =
carry +
(hiTimes977[8].toLong() and 0xFFFFFFFFL) +
(hi[7].toLong() and 0xFFFFFFFFL)
// Remaining: carry + hi[7]
var overflow = carry + (w[15].toLong() and 0xFFFFFFFFL)
// Second round: overflow * (2^32 + 977)
// overflow is at most ~35 bits, so overflow * 977 fits in Long easily
if (overflow > 0) {
val ov977 = overflow * 977L
var c2 = 0L
// Add ov977 to result[0..1], and overflow to result[1..2] (the <<32 part)
for (i in 0 until 8) {
c2 += (result[i].toLong() and 0xFFFFFFFFL)
c2 += (out[i].toLong() and 0xFFFFFFFFL)
if (i == 0) c2 += (ov977 and 0xFFFFFFFFL)
if (i == 1) c2 += (ov977 ushr 32) + (overflow and 0xFFFFFFFFL)
if (i == 2) c2 += (overflow ushr 32)
result[i] = c2.toInt()
out[i] = c2.toInt()
c2 = c2 ushr 32
}
// c2 should be 0 or very small; if > 0, do a third tiny round
if (c2 > 0) {
val tiny = c2 * 977L
var c3 = 0L
for (i in 0 until 3) {
c3 += (result[i].toLong() and 0xFFFFFFFFL)
c3 += (out[i].toLong() and 0xFFFFFFFFL)
if (i == 0) c3 += (tiny and 0xFFFFFFFFL)
if (i == 1) c3 += (tiny ushr 32) + (c2 and 0xFFFFFFFFL)
if (i == 2) c3 += (c2 ushr 32)
result[i] = c3.toInt()
out[i] = c3.toInt()
c3 = c3 ushr 32
}
}
}
// Final reduction: result might still be >= p
return reduce(result)
reduceSelf(out)
}
/** out = a + b mod p */
fun add(
out: IntArray,
a: IntArray,
b: IntArray,
): IntArray {
val (sum, carry) = U256.addCarry(a, b)
return if (carry != 0) {
// sum + 2^256 ≡ sum + (2^32 + 977) (mod p)
// carry is always 1 here, so just add the constant
val (r2, c2) = U256.addCarry(sum, P_COMPLEMENT_LIMBS)
if (c2 != 0) reduce(U256.addCarry(r2, P_COMPLEMENT_LIMBS).first) else reduce(r2)
} else {
reduce(sum)
) {
val carry = U256.addTo(out, a, b)
if (carry != 0) {
// out + 2^256 ≡ out + (2^32 + 977) mod p
var c = 977L + (out[0].toLong() and 0xFFFFFFFFL)
out[0] = c.toInt()
c = c ushr 32
c += 1L + (out[1].toLong() and 0xFFFFFFFFL)
out[1] = c.toInt()
c = c ushr 32
for (i in 2 until 8) {
c += (out[i].toLong() and 0xFFFFFFFFL)
out[i] = c.toInt()
c = c ushr 32
}
}
reduceSelf(out)
}
/** out = a - b mod p */
fun sub(
out: IntArray,
a: IntArray,
b: IntArray,
): IntArray {
val (diff, borrow) = U256.subBorrow(a, b)
return if (borrow != 0) {
// Add p back
U256.addCarry(diff, P).first
} else {
diff
) {
val borrow = U256.subTo(out, a, b)
if (borrow != 0) {
U256.addTo(out, out, P)
}
}
/** out = a * b mod p. Uses thread-local scratch space. */
fun mul(
out: IntArray,
a: IntArray,
b: IntArray,
): IntArray = reduceWide(U256.mulWide(a, b))
fun sqr(a: IntArray): IntArray = mul(a, a)
fun neg(a: IntArray): IntArray = if (U256.isZero(a)) IntArray(8) else U256.subBorrow(P, a).first
/** Modular inverse using Fermat's little theorem: a^(p-2) mod p */
fun inv(a: IntArray): IntArray {
require(!U256.isZero(a)) { "Cannot invert zero" }
// p - 2 = FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2D
// Use square-and-multiply with an optimized addition chain for secp256k1
return powModP(a, P_MINUS_2)
) {
val w = wide.get()
U256.mulWide(w, a, b)
reduceWide(out, w)
}
/** out = a² mod p. Uses thread-local scratch space. */
fun sqr(
out: IntArray,
a: IntArray,
) {
mul(out, a, a)
}
/** out = -a mod p */
fun neg(
out: IntArray,
a: IntArray,
) {
if (U256.isZero(a)) {
for (i in 0 until 8) out[i] = 0
} else {
U256.subTo(out, P, a)
}
}
/** out = a^(-1) mod p via Fermat's little theorem */
fun inv(
out: IntArray,
a: IntArray,
) {
require(!U256.isZero(a))
powModP(out, a, P_MINUS_2)
}
// p - 2
private val P_MINUS_2 =
intArrayOf(
0xFFFFFC2D.toInt(),
@@ -320,8 +343,6 @@ internal object FieldP {
0xFFFFFFFF.toInt(),
)
// (p + 1) / 4 = 3FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFBFFFFF0C
// Used for computing square roots since p ≡ 3 (mod 4)
private val P_PLUS_1_DIV_4 =
intArrayOf(
0xBFFFFF0C.toInt(),
@@ -334,39 +355,111 @@ internal object FieldP {
0x3FFFFFFF,
)
/** Square root mod p. Returns null if a is not a quadratic residue. */
fun sqrt(a: IntArray): IntArray? {
// Since p ≡ 3 (mod 4), sqrt(a) = a^((p+1)/4) mod p
val r = powModP(a, P_PLUS_1_DIV_4)
// Verify: r^2 == a (mod p)
return if (U256.cmp(mul(r, r), reduce(a)) == 0) r else null
/** out = sqrt(a) mod p, returns false if not a QR */
fun sqrt(
out: IntArray,
a: IntArray,
): Boolean {
powModP(out, a, P_PLUS_1_DIV_4)
// Verify: out² == a
val check = IntArray(8)
mul(check, out, out)
// Need a reduced copy of a for comparison
val ar = IntArray(8)
U256.copyInto(ar, a)
reduceSelf(ar)
return U256.cmp(check, ar) == 0
}
/** Generic modular exponentiation mod p using square-and-multiply */
/** out = base^exp mod p */
private fun powModP(
out: IntArray,
base: IntArray,
exp: IntArray,
): IntArray {
var result = intArrayOf(1, 0, 0, 0, 0, 0, 0, 0) // 1
var b = base.copyOf()
// Find highest set bit
) {
// Left-to-right square-and-multiply
val b = IntArray(8)
U256.copyInto(b, base)
var highBit = 255
while (highBit >= 0 && !U256.testBit(exp, highBit)) highBit--
for (i in 0..highBit) {
if (highBit < 0) {
out[0] = 1
for (i in 1 until 8) out[i] = 0
return
}
// Start with the base (first bit is always 1)
U256.copyInto(out, b)
for (i in highBit - 1 downTo 0) {
sqr(out, out) // out = out²
if (U256.testBit(exp, i)) {
result = mul(result, b)
}
if (i < highBit) {
b = sqr(b)
mul(out, out, b) // out = out * base
}
}
return result
}
// === Allocating convenience wrappers (for non-hot paths) ===
fun add(
a: IntArray,
b: IntArray,
): IntArray {
val r = IntArray(8)
add(r, a, b)
return r
}
fun sub(
a: IntArray,
b: IntArray,
): IntArray {
val r = IntArray(8)
sub(r, a, b)
return r
}
fun mul(
a: IntArray,
b: IntArray,
): IntArray {
val r = IntArray(8)
mul(r, a, b)
return r
}
fun sqr(a: IntArray): IntArray {
val r = IntArray(8)
sqr(r, a)
return r
}
fun neg(a: IntArray): IntArray {
val r = IntArray(8)
neg(r, a)
return r
}
fun inv(a: IntArray): IntArray {
val r = IntArray(8)
inv(r, a)
return r
}
fun sqrt(a: IntArray): IntArray? {
val r = IntArray(8)
return if (sqrt(r, a)) r else null
}
fun reduce(a: IntArray): IntArray {
val r = a.copyOf()
reduceSelf(r)
return r
}
}
/**
* Scalar arithmetic modulo n (the order of the secp256k1 group).
* n = FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
*/
internal object ScalarN {
val N =
@@ -381,99 +474,6 @@ internal object ScalarN {
0xFFFFFFFF.toInt(),
)
/** Check if 0 < a < n */
fun isValid(a: IntArray): Boolean {
if (U256.isZero(a)) return false
return U256.cmp(a, N) < 0
}
/** Reduce mod n: for values up to 2n */
fun reduce(a: IntArray): IntArray =
if (U256.cmp(a, N) >= 0) {
U256.subBorrow(a, N).first
} else {
a
}
/** Reduce a wide 512-bit value mod n */
fun reduceWide(w: IntArray): IntArray {
// Barrett-like reduction using schoolbook division
// For simplicity, we do repeated subtraction with shifts
// Since n is close to 2^256, the quotient is at most ~2^256.
// We use a simpler approach: reduce by subtracting n * (hi_estimate)
//
// Actually, we'll use the same approach as field reduction but
// for n which doesn't have a nice sparse form.
// n = 2^256 - nComplement where nComplement = 2^256 - n
// = 0x14551231950B75FC4402DA1732FC9BEBF
val lo = IntArray(8)
val hi = IntArray(8)
for (i in 0 until 8) {
lo[i] = w[i]
hi[i] = w[i + 8]
}
if (U256.isZero(hi)) return reduce(lo)
// 2^256 mod n = N_COMPLEMENT
// hi * 2^256 ≡ hi * N_COMPLEMENT (mod n)
val hiTimesNC = U256.mulWide(hi, N_COMPLEMENT)
// This is at most 384 bits. Add lo.
val sum = IntArray(16)
var carry = 0L
for (i in 0 until 16) {
carry += (hiTimesNC[i].toLong() and 0xFFFFFFFFL) +
if (i < 8) (lo[i].toLong() and 0xFFFFFFFFL) else 0L
sum[i] = carry.toInt()
carry = carry ushr 32
}
// Now sum might be up to ~384 bits. Repeat reduction.
val lo2 = IntArray(8)
val hi2 = IntArray(8)
for (i in 0 until 8) {
lo2[i] = sum[i]
hi2[i] = sum[i + 8]
}
if (U256.isZero(hi2)) return reduce(lo2)
// Another round
val hi2TimesNC = U256.mulWide(hi2, N_COMPLEMENT)
var carry2 = 0L
val result = IntArray(8)
for (i in 0 until 8) {
carry2 += (lo2[i].toLong() and 0xFFFFFFFFL) + (hi2TimesNC[i].toLong() and 0xFFFFFFFFL)
result[i] = carry2.toInt()
carry2 = carry2 ushr 32
}
// Handle any remaining overflow
var overflow = carry2
for (i in 8 until 16) {
overflow += (hi2TimesNC[i].toLong() and 0xFFFFFFFFL)
}
// overflow * 2^256 ≡ overflow * N_COMPLEMENT (mod n)
if (overflow > 0) {
val corr = U256.mulSmall(N_COMPLEMENT, overflow)
var c3 = 0L
for (i in 0 until 8) {
c3 += (result[i].toLong() and 0xFFFFFFFFL) + (corr[i].toLong() and 0xFFFFFFFFL)
result[i] = c3.toInt()
c3 = c3 ushr 32
}
}
// Final reductions
var r = result
while (U256.cmp(r, N) >= 0) {
r = U256.subBorrow(r, N).first
}
return r
}
// 2^256 - n = 14551231950B75FC4402DA1732FC9BEBF
// In little-endian limbs:
private val N_COMPLEMENT =
intArrayOf(
0x2FC9BEBF.toInt(),
@@ -486,46 +486,6 @@ internal object ScalarN {
0,
)
fun add(
a: IntArray,
b: IntArray,
): IntArray {
val (sum, carry) = U256.addCarry(a, b)
return if (carry != 0) {
// sum + 2^256 ≡ sum + N_COMPLEMENT (mod n)
val (r2, _) = U256.addCarry(sum, N_COMPLEMENT)
reduce(r2)
} else {
reduce(sum)
}
}
fun sub(
a: IntArray,
b: IntArray,
): IntArray {
val (diff, borrow) = U256.subBorrow(a, b)
return if (borrow != 0) {
U256.addCarry(diff, N).first
} else {
diff
}
}
fun mul(
a: IntArray,
b: IntArray,
): IntArray = reduceWide(U256.mulWide(a, b))
fun neg(a: IntArray): IntArray = if (U256.isZero(a)) IntArray(8) else U256.subBorrow(N, a).first
/** Modular inverse using Fermat's little theorem: a^(n-2) mod n */
fun inv(a: IntArray): IntArray {
require(!U256.isZero(a)) { "Cannot invert zero" }
return powModN(a, N_MINUS_2)
}
// n - 2
private val N_MINUS_2 =
intArrayOf(
0xD036413F.toInt(),
@@ -538,20 +498,155 @@ internal object ScalarN {
0xFFFFFFFF.toInt(),
)
fun isValid(a: IntArray): Boolean = !U256.isZero(a) && U256.cmp(a, N) < 0
fun reduce(a: IntArray): IntArray =
if (U256.cmp(a, N) >= 0) {
val r = IntArray(8)
U256.subTo(r, a, N)
r
} else {
a
}
fun add(
a: IntArray,
b: IntArray,
): IntArray {
val r = IntArray(8)
val carry = U256.addTo(r, a, b)
if (carry != 0) {
U256.addTo(r, r, N_COMPLEMENT)
reduceSelf(r)
} else {
reduceSelf(r)
}
return r
}
fun sub(
a: IntArray,
b: IntArray,
): IntArray {
val r = IntArray(8)
val borrow = U256.subTo(r, a, b)
if (borrow != 0) U256.addTo(r, r, N)
return r
}
fun mul(
a: IntArray,
b: IntArray,
): IntArray = reduceWide(mulWideAlloc(a, b))
fun neg(a: IntArray): IntArray {
if (U256.isZero(a)) return IntArray(8)
val r = IntArray(8)
U256.subTo(r, N, a)
return r
}
fun inv(a: IntArray): IntArray {
require(!U256.isZero(a))
return powModN(a, N_MINUS_2)
}
private fun reduceSelf(a: IntArray) {
if (U256.cmp(a, N) >= 0) U256.subTo(a, a, N)
}
private fun mulWideAlloc(
a: IntArray,
b: IntArray,
): IntArray {
val w = IntArray(16)
U256.mulWide(w, a, b)
return w
}
private fun reduceWide(w: IntArray): IntArray {
val lo = IntArray(8)
val hi = IntArray(8)
for (i in 0 until 8) {
lo[i] = w[i]
hi[i] = w[i + 8]
}
if (U256.isZero(hi)) {
reduceSelf(lo)
return lo
}
val hiTimesNC = IntArray(16)
U256.mulWide(hiTimesNC, hi, N_COMPLEMENT)
val sum = IntArray(16)
var carry = 0L
for (i in 0 until 16) {
carry += (hiTimesNC[i].toLong() and 0xFFFFFFFFL) +
if (i < 8) (lo[i].toLong() and 0xFFFFFFFFL) else 0L
sum[i] = carry.toInt()
carry = carry ushr 32
}
val lo2 = IntArray(8)
val hi2 = IntArray(8)
for (i in 0 until 8) {
lo2[i] = sum[i]
hi2[i] = sum[i + 8]
}
if (U256.isZero(hi2)) {
reduceSelf(lo2)
return lo2
}
val hi2NC = IntArray(16)
U256.mulWide(hi2NC, hi2, N_COMPLEMENT)
var c2 = 0L
val result = IntArray(8)
for (i in 0 until 8) {
c2 += (lo2[i].toLong() and 0xFFFFFFFFL) + (hi2NC[i].toLong() and 0xFFFFFFFFL)
result[i] = c2.toInt()
c2 = c2 ushr 32
}
var overflow = c2
for (i in 8 until 16) overflow += (hi2NC[i].toLong() and 0xFFFFFFFFL)
if (overflow > 0) {
val corr = IntArray(9)
var cc = 0L
for (i in 0 until 8) {
cc += (N_COMPLEMENT[i].toLong() and 0xFFFFFFFFL) * overflow
corr[i] = cc.toInt()
cc = cc ushr 32
}
var c3 = 0L
for (i in 0 until 8) {
c3 += (result[i].toLong() and 0xFFFFFFFFL) + (corr[i].toLong() and 0xFFFFFFFFL)
result[i] = c3.toInt()
c3 = c3 ushr 32
}
}
while (U256.cmp(result, N) >= 0) U256.subTo(result, result, N)
return result
}
private fun powModN(
base: IntArray,
exp: IntArray,
): IntArray {
var result = intArrayOf(1, 0, 0, 0, 0, 0, 0, 0)
var b = base.copyOf()
val result = IntArray(8)
val b = base.copyOf()
var highBit = 255
while (highBit >= 0 && !U256.testBit(exp, highBit)) highBit--
for (i in 0..highBit) {
if (highBit < 0) {
result[0] = 1
return result
}
U256.copyInto(result, b)
for (i in highBit - 1 downTo 0) {
val sq = mul(result, result)
U256.copyInto(result, sq)
if (U256.testBit(exp, i)) {
result = mul(result, b)
}
if (i < highBit) {
b = mul(b, b)
val prod = mul(result, b)
U256.copyInto(result, prod)
}
}
return result
@@ -21,24 +21,46 @@
package com.vitorpamplona.quartz.utils.secp256k1
/**
* Elliptic curve point operations on the secp256k1 curve: y² = x³ + 7 (mod p).
* Uses Jacobian coordinates (X, Y, Z) where affine (x, y) = (X/Z², Y/Z³).
* The point at infinity is represented by Z = 0.
* Mutable Jacobian point for in-place computation.
* (X, Y, Z) represents affine (X/Z², Y/Z³). Infinity: Z = 0.
*/
internal class JPoint(
val x: IntArray,
val y: IntArray,
val z: IntArray,
internal class MutablePoint(
val x: IntArray = IntArray(8),
val y: IntArray = IntArray(8),
val z: IntArray = IntArray(8),
) {
companion object {
val INFINITY = JPoint(IntArray(8), intArrayOf(1, 0, 0, 0, 0, 0, 0, 0), IntArray(8))
fun isInfinity(): Boolean = U256.isZero(z)
fun setInfinity() {
for (i in 0 until 8) {
x[i] = 0
z[i] = 0
}
y[0] = 1
for (i in 1 until 8) y[i] = 0
}
fun isInfinity(): Boolean = U256.isZero(z)
fun copyFrom(other: MutablePoint) {
other.x.copyInto(x)
other.y.copyInto(y)
other.z.copyInto(z)
}
fun setAffine(
ax: IntArray,
ay: IntArray,
) {
ax.copyInto(x)
ay.copyInto(y)
z[0] = 1
for (i in 1 until 8) z[i] = 0
}
/** Create a snapshot (immutable copy for table storage) */
fun snapshot(): MutablePoint = MutablePoint(x.copyOf(), y.copyOf(), z.copyOf())
}
internal object ECPoint {
// Generator point G
val GX =
intArrayOf(
0x16F81798.toInt(),
@@ -61,233 +83,361 @@ internal object ECPoint {
0x26A3C465.toInt(),
0x483ADA77.toInt(),
)
val G = JPoint(GX.copyOf(), GY.copyOf(), intArrayOf(1, 0, 0, 0, 0, 0, 0, 0))
// Curve constant b = 7
private val B = intArrayOf(7, 0, 0, 0, 0, 0, 0, 0)
private val ONE = intArrayOf(1, 0, 0, 0, 0, 0, 0, 0)
// Precomputed table for G: gTable[i] = (i+1)*G for i in 0..15
// Lazily initialized on first use.
private val gTable: Array<MutablePoint> by lazy { buildGTable() }
private fun buildGTable(): Array<MutablePoint> {
val table = Array(16) { MutablePoint() }
// table[0] = G
table[0].setAffine(GX, GY)
// table[i] = table[i-1] + G
val tmp = MutablePoint()
for (i in 1 until 16) {
addPoints(table[i], table[i - 1], table[0])
}
return Array(16) { table[it].snapshot() }
}
// ============ Scratch buffers for point operations (thread-local) ============
private class PointScratch {
val t = Array(12) { IntArray(8) } // temporary field elements
val dblCopy = MutablePoint() // copy buffer for in-place doubling
}
private val scratch = ThreadLocal.withInitial { PointScratch() }
/**
* Point doubling in Jacobian coordinates.
* Formula from https://hyperelliptic.org/EFD/g1p/auto-shortw-jacobian-0.html#doubling-dbl-2009-l
* Point doubling: out = 2*p.
* Formula: dbl-2009-l from https://hyperelliptic.org/EFD/g1p/auto-shortw-jacobian-0.html
*/
fun double(p: JPoint): JPoint {
if (p.isInfinity()) return JPoint.INFINITY
val x = p.x
val y = p.y
val z = p.z
val a = FieldP.sqr(x) // A = X1²
val b = FieldP.sqr(y) // B = Y1²
val c = FieldP.sqr(b) // C = B²
val xPlusB = FieldP.add(x, b)
val d =
FieldP.sub(
FieldP.sub(FieldP.sqr(xPlusB), a),
c,
)
val d2 = FieldP.add(d, d) // D = 2*((X1+B)²-A-C)
val e = FieldP.add(FieldP.add(a, a), a) // E = 3*A
val f = FieldP.sqr(e) // F = E²
val x3 = FieldP.sub(f, FieldP.add(d2, d2)) // X3 = F - 2*D
val c8 =
FieldP.add(
FieldP.add(FieldP.add(c, c), FieldP.add(c, c)),
FieldP.add(FieldP.add(c, c), FieldP.add(c, c)),
) // 8*C
val y3 = FieldP.sub(FieldP.mul(e, FieldP.sub(d2, x3)), c8) // Y3 = E*(D-X3) - 8*C
val z3 =
FieldP.sub(
FieldP.sub(FieldP.sqr(FieldP.add(y, z)), FieldP.sqr(y)),
FieldP.sqr(z),
) // Z3 = (Y1+Z1)² - B - Z1² ... but B = Y1² so this = 2*Y1*Z1
return JPoint(x3, y3, z3)
fun doublePoint(
out: MutablePoint,
inp: MutablePoint,
) {
if (inp.isInfinity()) {
out.setInfinity()
return
}
val s = scratch.get()
// If out aliases inp, copy inp to scratch first
val p =
if (out === inp) {
s.dblCopy.copyFrom(inp)
s.dblCopy
} else {
inp
}
val t = s.t
// t0=A=X², t1=B=Y², t2=C=B², t3=(X+B)²
FieldP.sqr(t[0], p.x)
FieldP.sqr(t[1], p.y)
FieldP.sqr(t[2], t[1])
FieldP.add(t[3], p.x, t[1])
FieldP.sqr(t[3], t[3])
// t3 = D = 2*((X+B)²-A-C)
FieldP.sub(t[3], t[3], t[0])
FieldP.sub(t[3], t[3], t[2])
FieldP.add(t[3], t[3], t[3]) // D
// t4 = E = 3*A
FieldP.add(t[4], t[0], t[0])
FieldP.add(t[4], t[4], t[0])
// t5 = F = E²
FieldP.sqr(t[5], t[4])
// X3 = F - 2*D
FieldP.add(t[6], t[3], t[3]) // 2D
FieldP.sub(out.x, t[5], t[6])
// Y3 = E*(D-X3) - 8*C
FieldP.sub(t[7], t[3], out.x)
FieldP.mul(t[7], t[4], t[7])
FieldP.add(t[2], t[2], t[2]) // 2C
FieldP.add(t[2], t[2], t[2]) // 4C
FieldP.add(t[2], t[2], t[2]) // 8C
FieldP.sub(out.y, t[7], t[2])
// Z3 = 2*Y*Z
FieldP.add(t[8], p.y, p.z)
FieldP.sqr(t[8], t[8])
FieldP.sub(t[8], t[8], t[1]) // -B
FieldP.sqr(t[9], p.z)
FieldP.sub(out.z, t[8], t[9])
}
/**
* Point addition in Jacobian coordinates.
* Mixed addition when q.z = 1 (affine point) for efficiency.
* Point addition: out = p + q. Handles p==q (doubling) and inverses.
*/
fun add(
p: JPoint,
q: JPoint,
): JPoint {
if (p.isInfinity()) return q
if (q.isInfinity()) return p
fun addPoints(
out: MutablePoint,
p: MutablePoint,
q: MutablePoint,
) {
if (p.isInfinity()) {
out.copyFrom(q)
return
}
if (q.isInfinity()) {
out.copyFrom(p)
return
}
val s = scratch.get()
val t = s.t
val z1sq = FieldP.sqr(p.z)
val z2sq = FieldP.sqr(q.z)
FieldP.sqr(t[0], p.z) // Z1²
FieldP.sqr(t[1], q.z) // Z2²
FieldP.mul(t[2], p.x, t[1]) // U1 = X1*Z2²
FieldP.mul(t[3], q.x, t[0]) // U2 = X2*Z1²
FieldP.mul(t[4], q.z, t[1]) // Z2³
FieldP.mul(t[4], p.y, t[4]) // S1 = Y1*Z2³
FieldP.mul(t[5], p.z, t[0]) // Z1³
FieldP.mul(t[5], q.y, t[5]) // S2 = Y2*Z1³
val u1 = FieldP.mul(p.x, z2sq) // U1 = X1*Z2²
val u2 = FieldP.mul(q.x, z1sq) // U2 = X2*Z1²
val s1 = FieldP.mul(p.y, FieldP.mul(q.z, z2sq)) // S1 = Y1*Z2³
val s2 = FieldP.mul(q.y, FieldP.mul(p.z, z1sq)) // S2 = Y2*Z1³
if (U256.cmp(u1, u2) == 0) {
return if (U256.cmp(s1, s2) == 0) {
double(p) // Same point
if (U256.cmp(t[2], t[3]) == 0) {
if (U256.cmp(t[4], t[5]) == 0) {
doublePoint(out, p)
} else {
JPoint.INFINITY // Inverse points
out.setInfinity()
}
return
}
val h = FieldP.sub(u2, u1) // H = U2 - U1
val i = FieldP.sqr(FieldP.add(h, h)) // I = (2*H)²
val j = FieldP.mul(h, i) // J = H*I
val r =
FieldP.add(
FieldP.sub(s2, s1),
FieldP.sub(s2, s1),
) // r = 2*(S2-S1)
val v = FieldP.mul(u1, i) // V = U1*I
val x3 =
FieldP.sub(
FieldP.sub(FieldP.sqr(r), j),
FieldP.add(v, v),
) // X3 = r² - J - 2*V
val y3 =
FieldP.sub(
FieldP.mul(r, FieldP.sub(v, x3)),
FieldP.add(FieldP.mul(s1, j), FieldP.mul(s1, j)),
) // Y3 = r*(V-X3) - 2*S1*J
val z3 =
FieldP.mul(
FieldP.sub(
FieldP.sub(
FieldP.sqr(FieldP.add(p.z, q.z)),
z1sq,
),
z2sq,
),
h,
) // Z3 = ((Z1+Z2)²-Z1²-Z2²)*H
return JPoint(x3, y3, z3)
FieldP.sub(t[6], t[3], t[2]) // H = U2-U1
FieldP.add(t[7], t[6], t[6]) // 2H
FieldP.sqr(t[7], t[7]) // I = (2H)²
FieldP.mul(t[8], t[6], t[7]) // J = H*I
FieldP.sub(t[9], t[5], t[4])
FieldP.add(t[9], t[9], t[9]) // r = 2*(S2-S1)
FieldP.mul(t[10], t[2], t[7]) // V = U1*I
// X3 = r² - J - 2V
FieldP.sqr(out.x, t[9])
FieldP.sub(out.x, out.x, t[8])
FieldP.sub(out.x, out.x, t[10])
FieldP.sub(out.x, out.x, t[10])
// Y3 = r*(V-X3) - 2*S1*J
FieldP.sub(t[11], t[10], out.x)
FieldP.mul(out.y, t[9], t[11])
FieldP.mul(t[11], t[4], t[8]) // S1*J
FieldP.add(t[11], t[11], t[11]) // 2*S1*J
FieldP.sub(out.y, out.y, t[11])
// Z3 = ((Z1+Z2)²-Z1²-Z2²)*H
FieldP.add(out.z, p.z, q.z)
FieldP.sqr(out.z, out.z)
FieldP.sub(out.z, out.z, t[0])
FieldP.sub(out.z, out.z, t[1])
FieldP.mul(out.z, out.z, t[6])
}
/**
* Scalar multiplication using double-and-add (left-to-right).
* Scalar multiplication: out = scalar * p, using 4-bit windowed method.
*/
fun mul(
p: JPoint,
out: MutablePoint,
p: MutablePoint,
scalar: IntArray,
): JPoint {
if (U256.isZero(scalar) || p.isInfinity()) return JPoint.INFINITY
) {
if (U256.isZero(scalar) || p.isInfinity()) {
out.setInfinity()
return
}
var result = JPoint.INFINITY
// Find highest set bit
var highBit = 255
while (highBit >= 0 && !U256.testBit(scalar, highBit)) highBit--
// Build 4-bit window table: table[i] = (i+1)*p for i in 0..15
val table = Array(16) { MutablePoint() }
table[0].copyFrom(p)
val tmp = MutablePoint()
for (i in 1 until 16) {
addPoints(table[i], table[i - 1], p)
}
for (i in highBit downTo 0) {
result = double(result)
if (U256.testBit(scalar, i)) {
result = add(result, p)
out.setInfinity()
// Process 4 bits at a time, MSB first (64 nibbles for 256 bits)
for (nibbleIdx in 63 downTo 0) {
// 4 doublings
doublePoint(out, out)
doublePoint(out, out)
doublePoint(out, out)
doublePoint(out, out)
val nib = U256.getNibble(scalar, nibbleIdx)
if (nib != 0) {
addPoints(tmp, out, table[nib - 1])
out.copyFrom(tmp)
}
}
return result
}
/**
* Convert Jacobian point to affine coordinates.
* Returns null if the point is at infinity.
* G multiplication using precomputed table: out = scalar * G.
* Uses 4-bit windowed method with static precomputed table.
*/
fun toAffine(p: JPoint): Pair<IntArray, IntArray>? {
if (p.isInfinity()) return null
val zInv = FieldP.inv(p.z)
val zInv2 = FieldP.sqr(zInv)
val zInv3 = FieldP.mul(zInv2, zInv)
val x = FieldP.mul(p.x, zInv2)
val y = FieldP.mul(p.y, zInv3)
return Pair(x, y)
}
/**
* Lift x-coordinate to a point on the curve.
* Returns the point with even y if it exists, null otherwise.
* Used by BIP-340 for x-only public keys.
*/
fun liftX(x: IntArray): Pair<IntArray, IntArray>? {
// Check x < p
if (U256.cmp(x, FieldP.P) >= 0) return null
// y² = x³ + 7
val x3 = FieldP.mul(FieldP.sqr(x), x)
val y2 = FieldP.add(x3, B)
val y = FieldP.sqrt(y2) ?: return null
// Return the even-y variant
val yBytes = U256.toBytes(y)
return if (yBytes[31].toInt() and 1 == 0) {
Pair(x, y)
} else {
Pair(x, FieldP.neg(y))
fun mulG(
out: MutablePoint,
scalar: IntArray,
) {
if (U256.isZero(scalar)) {
out.setInfinity()
return
}
val table = gTable // force lazy init
out.setInfinity()
val tmp = MutablePoint()
for (nibbleIdx in 63 downTo 0) {
doublePoint(out, out)
doublePoint(out, out)
doublePoint(out, out)
doublePoint(out, out)
val nib = U256.getNibble(scalar, nibbleIdx)
if (nib != 0) {
addPoints(tmp, out, table[nib - 1])
out.copyFrom(tmp)
}
}
}
/** Check if y coordinate is even */
fun hasEvenY(y: IntArray): Boolean {
// y is even if the least significant bit is 0
return y[0] and 1 == 0
/**
* Shamir's trick: out = s*G + e*P in a single pass.
* Much faster than computing s*G and e*P separately for verification.
*/
fun mulDoubleG(
out: MutablePoint,
s: IntArray,
p: MutablePoint,
e: IntArray,
) {
// Build 4-bit window table for P
val pTable = Array(16) { MutablePoint() }
pTable[0].copyFrom(p)
for (i in 1 until 16) {
addPoints(pTable[i], pTable[i - 1], p)
}
val gTab = gTable
val tmp = MutablePoint()
out.setInfinity()
for (nibbleIdx in 63 downTo 0) {
doublePoint(out, out)
doublePoint(out, out)
doublePoint(out, out)
doublePoint(out, out)
val sNib = U256.getNibble(s, nibbleIdx)
val eNib = U256.getNibble(e, nibbleIdx)
if (sNib != 0) {
addPoints(tmp, out, gTab[sNib - 1])
out.copyFrom(tmp)
}
if (eNib != 0) {
addPoints(tmp, out, pTable[eNib - 1])
out.copyFrom(tmp)
}
}
}
/**
* Parse a serialized public key (33 bytes compressed or 65 bytes uncompressed).
* Returns affine (x, y) or null on failure.
*/
fun parsePublicKey(pubkey: ByteArray): Pair<IntArray, IntArray>? {
/** Convert Jacobian to affine. Writes x, y into outX, outY. Returns false if infinity. */
fun toAffine(
p: MutablePoint,
outX: IntArray,
outY: IntArray,
): Boolean {
if (p.isInfinity()) return false
val zInv = IntArray(8)
val zInv2 = IntArray(8)
val zInv3 = IntArray(8)
FieldP.inv(zInv, p.z)
FieldP.sqr(zInv2, zInv)
FieldP.mul(zInv3, zInv2, zInv)
FieldP.mul(outX, p.x, zInv2)
FieldP.mul(outY, p.y, zInv3)
return true
}
/** Lift x-coordinate to even-y point. Returns false if not on curve. */
fun liftX(
outX: IntArray,
outY: IntArray,
x: IntArray,
): Boolean {
if (U256.cmp(x, FieldP.P) >= 0) return false
val t = IntArray(8)
FieldP.sqr(t, x)
FieldP.mul(t, t, x) // x³
FieldP.add(t, t, B) // x³+7
if (!FieldP.sqrt(outY, t)) return false
U256.copyInto(outX, x)
// Ensure even y
if (outY[0] and 1 != 0) FieldP.neg(outY, outY)
return true
}
fun hasEvenY(y: IntArray): Boolean = y[0] and 1 == 0
/** Parse serialized public key -> affine (outX, outY). Returns false on failure. */
fun parsePublicKey(
pubkey: ByteArray,
outX: IntArray,
outY: IntArray,
): Boolean {
return when {
pubkey.size == 33 && (pubkey[0] == 0x02.toByte() || pubkey[0] == 0x03.toByte()) -> {
val x = U256.fromBytes(pubkey.copyOfRange(1, 33))
if (U256.cmp(x, FieldP.P) >= 0) return null
val x3 = FieldP.mul(FieldP.sqr(x), x)
val y2 = FieldP.add(x3, B)
val y = FieldP.sqrt(y2) ?: return null
val isOdd = y[0] and 1 == 1
if (U256.cmp(x, FieldP.P) >= 0) return false
val t = IntArray(8)
FieldP.sqr(t, x)
FieldP.mul(t, t, x)
FieldP.add(t, t, B)
if (!FieldP.sqrt(outY, t)) return false
U256.copyInto(outX, x)
val isOdd = outY[0] and 1 == 1
val wantOdd = pubkey[0] == 0x03.toByte()
if (isOdd != wantOdd) Pair(x, FieldP.neg(y)) else Pair(x, y)
if (isOdd != wantOdd) FieldP.neg(outY, outY)
true
}
pubkey.size == 65 && pubkey[0] == 0x04.toByte() -> {
val x = U256.fromBytes(pubkey.copyOfRange(1, 33))
val y = U256.fromBytes(pubkey.copyOfRange(33, 65))
// Verify point is on curve: y² = x³ + 7
val y2 = FieldP.sqr(y)
val x3p7 = FieldP.add(FieldP.mul(FieldP.sqr(x), x), B)
if (U256.cmp(y2, x3p7) != 0) return null
Pair(x, y)
val y2 = IntArray(8)
val x3p7 = IntArray(8)
val t = IntArray(8)
FieldP.sqr(y2, y)
FieldP.sqr(t, x)
FieldP.mul(x3p7, t, x)
FieldP.add(x3p7, x3p7, B)
if (U256.cmp(y2, x3p7) != 0) return false
U256.copyInto(outX, x)
U256.copyInto(outY, y)
true
}
else -> {
null
false
}
}
}
/** Serialize affine point as 65-byte uncompressed key (04 || x || y) */
fun serializeUncompressed(
x: IntArray,
y: IntArray,
): ByteArray {
val result = ByteArray(65)
result[0] = 0x04
U256.toBytes(x).copyInto(result, 1)
U256.toBytes(y).copyInto(result, 33)
return result
val r = ByteArray(65)
r[0] = 0x04
U256.toBytesInto(x, r, 1)
U256.toBytesInto(y, r, 33)
return r
}
/** Serialize affine point as 33-byte compressed key (02/03 || x) */
fun serializeCompressed(
x: IntArray,
y: IntArray,
): ByteArray {
val result = ByteArray(33)
result[0] = if (hasEvenY(y)) 0x02 else 0x03
U256.toBytes(x).copyInto(result, 1)
return result
val r = ByteArray(33)
r[0] = if (hasEvenY(y)) 0x02 else 0x03
U256.toBytesInto(x, r, 1)
return r
}
// Convenience wrappers for non-hot paths
fun toAffinePair(p: MutablePoint): Pair<IntArray, IntArray>? {
val x = IntArray(8)
val y = IntArray(8)
return if (toAffine(p, x, y)) Pair(x, y) else null
}
}
@@ -24,208 +24,181 @@ import com.vitorpamplona.quartz.utils.sha256.sha256
/**
* Pure Kotlin implementation of secp256k1 elliptic curve operations.
* Provides the same functionality as fr.acinq.secp256k1.Secp256k1 but
* without requiring native bindings.
*
* Implements only the operations used by Secp256k1Instance:
* - pubkeyCreate / pubKeyCompress
* - secKeyVerify
* - signSchnorr / verifySchnorr (BIP-340)
* - privKeyTweakAdd
* - pubKeyTweakMul
* Performance optimizations:
* - Mutable field/point operations to minimize IntArray allocations
* - Precomputed 4-bit window table for generator G multiplication
* - Shamir's trick for verify: s*G + (-e)*P in a single scalar-mul pass
* - Cached BIP-340 tagged hash prefixes
* - Thread-local scratch buffers in field arithmetic
*/
object Secp256k1 {
/**
* Create a public key from a secret key.
* @param seckey 32-byte secret key
* @return 65-byte uncompressed public key (04 || x || y)
*/
fun pubkeyCreate(seckey: ByteArray): ByteArray {
require(seckey.size == 32) { "Secret key must be 32 bytes" }
val scalar = U256.fromBytes(seckey)
require(ScalarN.isValid(scalar)) { "Invalid secret key" }
// ============ Cached tag hash prefixes for BIP-340 ============
// SHA256(tag) || SHA256(tag) — precomputed once
private val CHALLENGE_PREFIX: ByteArray by lazy {
val h = sha256("BIP0340/challenge".encodeToByteArray())
h + h
}
private val AUX_PREFIX: ByteArray by lazy {
val h = sha256("BIP0340/aux".encodeToByteArray())
h + h
}
private val NONCE_PREFIX: ByteArray by lazy {
val h = sha256("BIP0340/nonce".encodeToByteArray())
h + h
}
val point = ECPoint.mul(ECPoint.G, scalar)
val (x, y) = ECPoint.toAffine(point) ?: error("Unexpected infinity")
fun pubkeyCreate(seckey: ByteArray): ByteArray {
require(seckey.size == 32)
val scalar = U256.fromBytes(seckey)
require(ScalarN.isValid(scalar))
val p = MutablePoint()
ECPoint.mulG(p, scalar)
val x = IntArray(8)
val y = IntArray(8)
check(ECPoint.toAffine(p, x, y))
return ECPoint.serializeUncompressed(x, y)
}
/**
* Compress a public key.
* @param pubkey 65-byte uncompressed public key or 33-byte compressed
* @return 33-byte compressed public key (02/03 || x)
*/
fun pubKeyCompress(pubkey: ByteArray): ByteArray {
val (x, y) = ECPoint.parsePublicKey(pubkey) ?: error("Invalid public key")
val x = IntArray(8)
val y = IntArray(8)
check(ECPoint.parsePublicKey(pubkey, x, y))
return ECPoint.serializeCompressed(x, y)
}
/**
* Verify that a secret key is valid (0 < key < n).
* @param seckey secret key bytes
* @return true if valid
*/
fun secKeyVerify(seckey: ByteArray): Boolean {
if (seckey.size != 32) return false
val scalar = U256.fromBytes(seckey)
return ScalarN.isValid(scalar)
return ScalarN.isValid(U256.fromBytes(seckey))
}
/**
* Create a Schnorr signature per BIP-340.
* @param data 32-byte message hash
* @param seckey 32-byte secret key
* @param auxrand optional 32-byte auxiliary randomness (null for deterministic)
* @return 64-byte signature
*/
fun signSchnorr(
data: ByteArray,
seckey: ByteArray,
auxrand: ByteArray?,
): ByteArray {
require(seckey.size == 32) { "Secret key must be 32 bytes" }
// Step 1-3: Compute keypair, negate secret key if needed
require(seckey.size == 32)
val d0 = U256.fromBytes(seckey)
require(ScalarN.isValid(d0)) { "Invalid secret key" }
require(ScalarN.isValid(d0))
val pubPoint = ECPoint.mul(ECPoint.G, d0)
val (px, py) = ECPoint.toAffine(pubPoint) ?: error("Unexpected infinity")
// Compute public key
val pubPoint = MutablePoint()
ECPoint.mulG(pubPoint, d0)
val px = IntArray(8)
val py = IntArray(8)
check(ECPoint.toAffine(pubPoint, px, py))
val d = if (ECPoint.hasEvenY(py)) d0 else ScalarN.neg(d0)
val dBytes = U256.toBytes(d)
val pBytes = U256.toBytes(px) // x-only public key
val pBytes = U256.toBytes(px)
// Step 4: Compute t = xor(d, tagged_hash("BIP0340/aux", auxrand))
// t = xor(d, tagged_hash("BIP0340/aux", auxrand))
val t =
if (auxrand != null) {
require(auxrand.size == 32) { "Aux randomness must be 32 bytes" }
val auxHash = taggedHash("BIP0340/aux", auxrand)
val tArray = U256.fromBytes(dBytes)
val auxArray = U256.fromBytes(auxHash)
U256.toBytes(U256.xor(tArray, auxArray))
require(auxrand.size == 32)
val auxHash = sha256(AUX_PREFIX + auxrand)
val tArr = IntArray(8)
val auxArr = U256.fromBytes(auxHash)
U256.xorTo(tArr, U256.fromBytes(dBytes), auxArr)
U256.toBytes(tArr)
} else {
dBytes
}
// Step 5: rand = tagged_hash("BIP0340/nonce", t || pBytes || data)
val nonceInput = t + pBytes + data
val rand = taggedHash("BIP0340/nonce", nonceInput)
// Step 6: k' = int(rand) mod n
// rand = tagged_hash("BIP0340/nonce", t || P || msg)
val rand = sha256(NONCE_PREFIX + t + pBytes + data)
val k0 = ScalarN.reduce(U256.fromBytes(rand))
require(!U256.isZero(k0)) { "Nonce is zero" }
require(!U256.isZero(k0))
// Step 7: R = k'·G
val rPoint = ECPoint.mul(ECPoint.G, k0)
val (rx, ry) = ECPoint.toAffine(rPoint) ?: error("Unexpected infinity")
// R = k'·G
val rPoint = MutablePoint()
ECPoint.mulG(rPoint, k0)
val rx = IntArray(8)
val ry = IntArray(8)
check(ECPoint.toAffine(rPoint, rx, ry))
// Step 8: k = k' if has_even_y(R), else n - k'
val k = if (ECPoint.hasEvenY(ry)) k0 else ScalarN.neg(k0)
// Step 9: e = int(tagged_hash("BIP0340/challenge", bytes(R) || bytes(P) || msg)) mod n
// e = tagged_hash("BIP0340/challenge", R || P || msg) mod n
val rBytes = U256.toBytes(rx)
val challengeInput = rBytes + pBytes + data
val eHash = taggedHash("BIP0340/challenge", challengeInput)
val eHash = sha256(CHALLENGE_PREFIX + rBytes + pBytes + data)
val e = ScalarN.reduce(U256.fromBytes(eHash))
// Step 10: sig = bytes(R) || bytes((k + e*d) mod n)
// sig = R || (k + e*d) mod n
val s = ScalarN.add(k, ScalarN.mul(e, d))
val sig = rBytes + U256.toBytes(s)
// Step 11: Verify (optional safety check - can be removed for performance)
require(verifySchnorr(sig, data, pBytes)) { "Signature verification failed" }
// Safety verify
require(verifySchnorr(sig, data, pBytes))
return sig
}
/**
* Verify a Schnorr signature per BIP-340.
* @param signature 64-byte signature
* @param data 32-byte message hash
* @param pub 32-byte x-only public key
* @return true if the signature is valid
*/
fun verifySchnorr(
signature: ByteArray,
data: ByteArray,
pub: ByteArray,
): Boolean {
if (signature.size != 64) return false
if (pub.size != 32) return false
if (signature.size != 64 || pub.size != 32) return false
// Step 1: P = lift_x(int(pk))
val px = U256.fromBytes(pub)
val (pxCoord, pyCoord) = ECPoint.liftX(px) ?: return false
// P = lift_x(pub)
val px = IntArray(8)
val py = IntArray(8)
if (!ECPoint.liftX(px, py, U256.fromBytes(pub))) return false
// Step 2: r = int(sig[0:32])
val rBytes = signature.copyOfRange(0, 32)
val r = U256.fromBytes(rBytes)
// r, s from signature
val r = U256.fromBytes(signature.copyOfRange(0, 32))
if (U256.cmp(r, FieldP.P) >= 0) return false
// Step 3: s = int(sig[32:64])
val s = U256.fromBytes(signature.copyOfRange(32, 64))
if (U256.cmp(s, ScalarN.N) >= 0) return false
// Step 4: e = int(tagged_hash("BIP0340/challenge", bytes(r) || bytes(P) || msg)) mod n
val challengeInput = rBytes + pub + data
val eHash = taggedHash("BIP0340/challenge", challengeInput)
// e = tagged_hash("BIP0340/challenge", sig[0:32] || pub || msg) mod n
val eHash = sha256(CHALLENGE_PREFIX + signature.copyOfRange(0, 32) + pub + data)
val e = ScalarN.reduce(U256.fromBytes(eHash))
// Step 5: R = s·G - e·P
val sG = ECPoint.mul(ECPoint.G, s)
// R = s*G - e*P using Shamir's trick (combined as s*G + (-e)*P)
val negE = ScalarN.neg(e)
val pJac = JPoint(pxCoord, pyCoord, intArrayOf(1, 0, 0, 0, 0, 0, 0, 0))
val ePneg = ECPoint.mul(pJac, negE)
val rPoint = ECPoint.add(sG, ePneg)
val pPoint = MutablePoint()
pPoint.setAffine(px, py)
val result = MutablePoint()
ECPoint.mulDoubleG(result, s, pPoint, negE)
// Step 6: Fail if R is infinity, or if R has odd y, or if x(R) != r
if (rPoint.isInfinity()) return false
val (rx, ry) = ECPoint.toAffine(rPoint) ?: return false
// Check: R is not infinity, has even y, and x(R) == r
if (result.isInfinity()) return false
val rx = IntArray(8)
val ry = IntArray(8)
if (!ECPoint.toAffine(result, rx, ry)) return false
if (!ECPoint.hasEvenY(ry)) return false
if (U256.cmp(rx, r) != 0) return false
return true
return U256.cmp(rx, r) == 0
}
/**
* Add a tweak to a private key: (seckey + tweak) mod n.
* @param seckey 32-byte secret key
* @param tweak 32-byte tweak
* @return 32-byte tweaked secret key
*/
fun privKeyTweakAdd(
seckey: ByteArray,
tweak: ByteArray,
): ByteArray {
require(seckey.size == 32) { "Secret key must be 32 bytes" }
require(tweak.size == 32) { "Tweak must be 32 bytes" }
val a = U256.fromBytes(seckey)
val b = U256.fromBytes(tweak)
val result = ScalarN.add(a, b)
require(!U256.isZero(result)) { "Result is zero" }
require(U256.cmp(result, ScalarN.N) < 0) { "Result >= n" }
require(seckey.size == 32 && tweak.size == 32)
val result = ScalarN.add(U256.fromBytes(seckey), U256.fromBytes(tweak))
require(!U256.isZero(result) && U256.cmp(result, ScalarN.N) < 0)
return U256.toBytes(result)
}
/**
* Multiply a public key by a tweak (scalar multiplication).
* @param pubkey 33-byte compressed or 65-byte uncompressed public key
* @param tweak 32-byte tweak/scalar
* @return public key in the same format as input
*/
fun pubKeyTweakMul(
pubkey: ByteArray,
tweak: ByteArray,
): ByteArray {
require(tweak.size == 32) { "Tweak must be 32 bytes" }
val (x, y) = ECPoint.parsePublicKey(pubkey) ?: error("Invalid public key")
require(tweak.size == 32)
val x = IntArray(8)
val y = IntArray(8)
check(ECPoint.parsePublicKey(pubkey, x, y))
val scalar = U256.fromBytes(tweak)
require(ScalarN.isValid(scalar)) { "Invalid tweak" }
require(ScalarN.isValid(scalar))
val point = JPoint(x, y, intArrayOf(1, 0, 0, 0, 0, 0, 0, 0))
val result = ECPoint.mul(point, scalar)
val (rx, ry) = ECPoint.toAffine(result) ?: error("Result is infinity")
val p = MutablePoint()
p.setAffine(x, y)
val result = MutablePoint()
ECPoint.mul(result, p, scalar)
val rx = IntArray(8)
val ry = IntArray(8)
check(ECPoint.toAffine(result, rx, ry))
return if (pubkey.size == 33) {
ECPoint.serializeCompressed(rx, ry)
@@ -234,9 +207,7 @@ object Secp256k1 {
}
}
// ============ Tagged Hash (BIP-340) ============
/** BIP-340 tagged hash: SHA256(SHA256(tag) || SHA256(tag) || msg) */
/** BIP-340 tagged hash (for non-cached tags) */
internal fun taggedHash(
tag: String,
msg: ByteArray,