perf: reduce ByteArray allocations in verify and sign paths

- Replace 3-way ByteArray concatenation for tagged hash inputs with
  single pre-sized array + copyInto calls (avoids 3 intermediate arrays)
- Add U256.fromBytes(bytes, offset) overload to decode from a slice
  without copyOfRange allocation
- Build signature output using toBytesInto instead of concatenation
- Apply same pattern to signSchnorr's nonce and challenge hash inputs

https://claude.ai/code/session_01BhU63WUe9AhikZxRdw3Lpg
This commit is contained in:
Claude
2026-04-05 19:24:13 +00:00
parent 0e3c2fcda5
commit 3cbe6ee73b
2 changed files with 33 additions and 11 deletions
@@ -138,7 +138,12 @@ object Secp256k1 {
dBytes
}
val rand = sha256(NONCE_PREFIX + t + pBytes + data)
val nonceInput = ByteArray(64 + 32 + 32 + data.size)
NONCE_PREFIX.copyInto(nonceInput, 0)
t.copyInto(nonceInput, 64)
pBytes.copyInto(nonceInput, 96)
data.copyInto(nonceInput, 128)
val rand = sha256(nonceInput)
val k0 = ScalarN.reduce(U256.fromBytes(rand))
require(!U256.isZero(k0))
@@ -149,12 +154,18 @@ object Secp256k1 {
check(ECPoint.toAffine(rPoint, rx, ry))
val k = if (ECPoint.hasEvenY(ry)) k0 else ScalarN.neg(k0)
val rBytes = U256.toBytes(rx)
val eHash = sha256(CHALLENGE_PREFIX + rBytes + pBytes + data)
val chalInput = ByteArray(64 + 32 + 32 + data.size)
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))
val s = ScalarN.add(k, ScalarN.mul(e, d))
val sig = rBytes + U256.toBytes(s)
val sScalar = ScalarN.add(k, ScalarN.mul(e, d))
val sig = ByteArray(64)
U256.toBytesInto(rx, sig, 0)
U256.toBytesInto(sScalar, sig, 32)
require(verifySchnorr(sig, data, pBytes)) { "Signature self-verification failed" }
return sig
@@ -186,12 +197,18 @@ object Secp256k1 {
val py = IntArray(8)
if (!ECPoint.liftX(px, py, U256.fromBytes(pub))) return false
val r = U256.fromBytes(signature.copyOfRange(0, 32))
val r = U256.fromBytes(signature, 0)
if (U256.cmp(r, FieldP.P) >= 0) return false
val s = U256.fromBytes(signature.copyOfRange(32, 64))
val s = U256.fromBytes(signature, 32)
if (U256.cmp(s, ScalarN.N) >= 0) return false
val eHash = sha256(CHALLENGE_PREFIX + signature.copyOfRange(0, 32) + pub + data)
// Build challenge hash input in a single array: prefix(64) + r(32) + pub(32) + data(N)
val hashInput = ByteArray(64 + 32 + 32 + data.size)
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)
val e = ScalarN.reduce(U256.fromBytes(eHash))
// R = s·G + (-e)·P via Shamir's trick
@@ -216,11 +216,16 @@ internal object U256 {
// ==================== Serialization ====================
/** Decode a big-endian 32-byte array into little-endian IntArray(8). */
fun fromBytes(bytes: ByteArray): IntArray {
require(bytes.size == 32)
fun fromBytes(bytes: ByteArray): IntArray = fromBytes(bytes, 0)
/** Decode 32 big-endian bytes starting at [offset] into little-endian IntArray(8). */
fun fromBytes(
bytes: ByteArray,
offset: Int,
): IntArray {
val r = IntArray(8)
for (i in 0 until 8) {
val o = 28 - i * 4
val o = offset + 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