perf: eliminate heap allocations in key operations

Replace U256.fromBytes (allocates LongArray(4)) with
U256.fromBytesInto (writes to pre-allocated scratch) across
all key operations:

- pubkeyCreate: scalar → sc.scalarTmp1 (safe: mulG doesn't use it)
- signSchnorrWithPubKey: d0 → sc.zInv (safe: signSchnorrInternal
  doesn't use it; d0 is read at lines 322-324 and 391 before zInv
  is needed)
- signSchnorrWithXOnlyPubKey: same pattern with sc.zInv
- pubKeyTweakMul: scalar → sc.scalarTmp1 (safe: ECPoint.mul
  doesn't use scalarTmp fields)
- ecdhXOnly: k → sc.scalarTmp1 (same)
- privKeyTweakAdd: already fixed in previous commit

signSchnorr keeps its allocation because d0 must survive through
mulG (which destroys splitK*) AND signSchnorrInternal (which uses
scalarTmp*). The single LongArray(4) alloc is negligible vs mulG
cost (~100μs).

Each eliminated allocation saves ~10-20ns of GC pressure on ART
and ~5ns on K/Native. For signSchnorrWithPubKey (the cached-pk
fast path), this removes the only unnecessary allocation.

https://claude.ai/code/session_015CtM5k88rF7WFgX8o2AGNR
This commit is contained in:
Claude
2026-04-09 21:08:35 +00:00
parent 6b1bc947a9
commit cb486778db
@@ -127,9 +127,10 @@ object Secp256k1 {
/** Create a 65-byte uncompressed public key (04 || x || y) from a 32-byte secret key. */
fun pubkeyCreate(seckey: ByteArray): ByteArray {
require(seckey.size == 32)
val scalar = U256.fromBytes(seckey)
require(ScalarN.isValid(scalar))
val sc = ECPoint.getScratch()
val scalar = sc.scalarTmp1
U256.fromBytesInto(scalar, seckey, 0)
require(ScalarN.isValid(scalar))
ECPoint.mulG(sc.entryResult, scalar)
check(ECPoint.toAffine(sc.entryResult, sc.entryPx, sc.entryPy, sc))
return KeyCodec.serializeUncompressed(sc.entryPx, sc.entryPy)
@@ -233,11 +234,10 @@ object Secp256k1 {
auxrand: ByteArray?,
): ByteArray {
require(seckey.size == 32)
// Allocate d0 separately — signSchnorrInternal uses all scalar scratch buffers.
// d0 must survive through mulG (which destroys splitK*) and signSchnorrInternal
// (which uses scalarTmp*). The allocation is negligible vs mulG cost (~100μs).
val d0 = U256.fromBytes(seckey)
require(ScalarN.isValid(d0))
// Derive public key (one G multiplication + one inversion)
val sc = ECPoint.getScratch()
ECPoint.mulG(sc.entryResult, d0)
check(ECPoint.toAffine(sc.entryResult, sc.entryPx, sc.entryPy, sc))
@@ -270,7 +270,10 @@ object Secp256k1 {
auxrand: ByteArray?,
): ByteArray {
require(seckey.size == 32 && compressedPub.size == 33)
val d0 = U256.fromBytes(seckey)
// Use zInv for d0 — not used by signSchnorrInternal.
val sc = ECPoint.getScratch()
val d0 = sc.zInv
U256.fromBytesInto(d0, seckey, 0)
require(ScalarN.isValid(d0))
val hasEvenY = compressedPub[0] == 0x02.toByte()
val xOnlyPub = compressedPub.copyOfRange(1, 33)
@@ -293,7 +296,10 @@ object Secp256k1 {
auxrand: ByteArray?,
): ByteArray {
require(seckey.size == 32 && xOnlyPub.size == 32)
val d0 = U256.fromBytes(seckey)
// Use zInv for d0 — not used by signSchnorrInternal.
val sc = ECPoint.getScratch()
val d0 = sc.zInv
U256.fromBytesInto(d0, seckey, 0)
require(ScalarN.isValid(d0))
// BIP-340: x-only pubkeys always have even y
return signSchnorrInternal(data, d0, xOnlyPub, true, auxrand)
@@ -541,7 +547,8 @@ object Secp256k1 {
require(tweak.size == 32)
val sc = ECPoint.getScratch()
check(KeyCodec.parsePublicKey(pubkey, sc.entryPx, sc.entryPy))
val scalar = U256.fromBytes(tweak)
val scalar = sc.scalarTmp1
U256.fromBytesInto(scalar, tweak, 0)
require(ScalarN.isValid(scalar))
sc.entryPoint.setAffine(sc.entryPx, sc.entryPy)
@@ -576,7 +583,8 @@ object Secp256k1 {
val sc = ECPoint.getScratch()
U256.fromBytesInto(sc.entryTmp, xOnlyPub, 0)
require(U256.cmp(sc.entryTmp, FieldP.P) < 0)
val k = U256.fromBytes(scalar)
val k = sc.scalarTmp1
U256.fromBytesInto(k, scalar, 0)
require(ScalarN.isValid(k))
// Compute y = sqrt(x³ + 7). We need SOME valid y for EC point operations,