feat: add signSchnorrWithXOnlyPubKey for zero-copy cached signing

BIP-340 public keys always have even y, so the y-parity prefix byte
(0x02) is redundant when the caller already has the 32-byte x-only
pubkey. This new overload takes the x-only pubkey directly, avoiding:

1. The expensive G multiplication to derive the pubkey (~20μs on Android)
2. The 33→32 byte array copy that signSchnorrWithPubKey does internally

Added to:
- Secp256k1.signSchnorrWithXOnlyPubKey (core implementation)
- Secp256k1Instance (expect/actual, falls back to C lib's signSchnorr
  since the native C lib always derives pubkey internally)
- Secp256k1InstanceOurs (uses the optimized pure-Kotlin path)
- Nip01Crypto.signWithPubKey (app-level convenience)

The app's KeyPair already stores the 32-byte x-only pubkey. Callers
like EventAssembler.hashAndSign can pass it through to skip the
G multiplication entirely.

https://claude.ai/code/session_01EMY5RnXb9rnsyU2KbXrSaY
This commit is contained in:
Claude
2026-04-09 02:17:25 +00:00
parent 0c27ed89bc
commit 65652d1256
7 changed files with 72 additions and 0 deletions
@@ -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,
@@ -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,6 +48,14 @@ 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,
@@ -282,6 +282,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.
@@ -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,
@@ -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,